diff --git a/prow/cmd/deck/main.go b/prow/cmd/deck/main.go
index 2ad4da012260b..8f1f89a1b0ffb 100644
--- a/prow/cmd/deck/main.go
+++ b/prow/cmd/deck/main.go
@@ -451,13 +451,14 @@ func prodOnlyMain(cfg config.Getter, o options, mux *http.ServeMux) *http.ServeM
githubOAuthConfig.InitGitHubOAuthConfig(cookie)
goa := githuboauth.NewAgent(&githubOAuthConfig, logrus.WithField("client", "githuboauth"))
- oauthClient := &oauth2.Config{
+ oauthClient := githuboauth.NewClient(&oauth2.Config{
ClientID: githubOAuthConfig.ClientID,
ClientSecret: githubOAuthConfig.ClientSecret,
RedirectURL: githubOAuthConfig.RedirectURL,
Scopes: githubOAuthConfig.Scopes,
Endpoint: github.Endpoint,
- }
+ },
+ )
repoSet := make(map[string]bool)
for r := range cfg().Presubmits {
@@ -1211,6 +1212,5 @@ func handleFavicon(staticFilesLocation string, cfg config.Getter) http.HandlerFu
func isValidatedGitOAuthConfig(githubOAuthConfig *config.GitHubOAuthConfig) bool {
return githubOAuthConfig.ClientID != "" && githubOAuthConfig.ClientSecret != "" &&
- githubOAuthConfig.RedirectURL != "" &&
- githubOAuthConfig.FinalRedirectURL != ""
+ githubOAuthConfig.RedirectURL != ""
}
diff --git a/prow/cmd/deck/static/common/common.ts b/prow/cmd/deck/static/common/common.ts
index c579f0e1ae010..a68e90c8c136a 100644
--- a/prow/cmd/deck/static/common/common.ts
+++ b/prow/cmd/deck/static/common/common.ts
@@ -201,3 +201,18 @@ export namespace tidehistory {
return link;
}
}
+
+export function getCookieByName(name: string): string {
+ if (!document.cookie) {
+ return "";
+ }
+ const docCookies = decodeURIComponent(document.cookie).split(";");
+ for (const cookie of docCookies) {
+ const c = cookie.trim();
+ const pref = name + "=";
+ if (c.indexOf(pref) === 0) {
+ return c.slice(pref.length);
+ }
+ }
+ return "";
+}
diff --git a/prow/cmd/deck/static/pr/pr.ts b/prow/cmd/deck/static/pr/pr.ts
index 403642c2607c9..b7e229cebbe50 100644
--- a/prow/cmd/deck/static/pr/pr.ts
+++ b/prow/cmd/deck/static/pr/pr.ts
@@ -4,7 +4,7 @@ import {Context} from '../api/github';
import {Label, PullRequest, UserData} from '../api/pr';
import {Job, JobState} from '../api/prow';
import {Blocker, TideData, TidePool, TideQuery as ITideQuery} from '../api/tide';
-import {tidehistory} from '../common/common';
+import {getCookieByName, tidehistory} from '../common/common';
declare const tideData: TideData;
declare const allBuilds: Job[];
@@ -179,24 +179,6 @@ function onLoadQuery(): string {
return "";
}
-/**
- * Gets cookie by its name.
- */
-function getCookieByName(name: string): string {
- if (!document.cookie) {
- return "";
- }
- const cookies = decodeURIComponent(document.cookie).split(";");
- for (const cookie of cookies) {
- const c = cookie.trim();
- const pref = name + "=";
- if (c.indexOf(pref) === 0) {
- return c.slice(pref.length);
- }
- }
- return "";
-}
-
/**
* Creates an alert for merge blocking issues on tide.
*/
@@ -1154,7 +1136,7 @@ function createPRCard(pr: PullRequest, builds: UnifiedContext[] = [], queries: P
* Redirect to initiate github login flow.
*/
function forceGitHubLogin(): void {
- window.location.href = window.location.origin + "/github-login";
+ window.location.href = window.location.origin + "/github-login?dest=%2Fpr";
}
type VagueState = "succeeded" | "failed" | "pending" | "unknown";
diff --git a/prow/cmd/deck/static/prow/prow.ts b/prow/cmd/deck/static/prow/prow.ts
index a66f01910e50f..9d86f2351cc40 100644
--- a/prow/cmd/deck/static/prow/prow.ts
+++ b/prow/cmd/deck/static/prow/prow.ts
@@ -1,6 +1,6 @@
import moment from "moment";
import {Job, JobState, JobType} from "../api/prow";
-import {cell, icon} from "../common/common";
+import {cell, getCookieByName, icon} from "../common/common";
import {FuzzySearch} from './fuzzy-search';
import {JobHistogram, JobSample} from './histogram';
@@ -397,6 +397,7 @@ function escapeRegexLiteral(s: string): string {
}
function redraw(fz: FuzzySearch): void {
+ const rerunStatus = getParameterByName("rerun");
const modal = document.getElementById('rerun')!;
const rerunCommand = document.getElementById('rerun-content')!;
window.onclick = (event) => {
@@ -648,12 +649,18 @@ function redraw(fz: FuzzySearch): void {
max = 2 * 3600;
}
drawJobHistogram(totalJob, jobHistogram, now - (12 * 3600), now, max);
+ if (rerunStatus != null) {
+ modal.style.display = "block";
+ rerunCommand.innerHTML = `Nice try! The direct rerun feature hasn't been implemented yet, so that button does nothing.`;
+ }
+
}
function createRerunCell(modal: HTMLElement, rerunElement: HTMLElement, prowjob: string): HTMLTableDataCellElement {
- const url = `https://${window.location.hostname}/rerun?prowjob=${prowjob}`;
+ const url = `/rerun?prowjob=${prowjob}`;
const c = document.createElement("td");
const i = icon.create("refresh", "Show instructions for rerunning this job");
+ const login = getCookieByName("github_login");
i.onclick = () => {
modal.style.display = "block";
rerunElement.innerHTML = `kubectl create -f "${url}"`;
@@ -662,6 +669,23 @@ function createRerunCell(modal: HTMLElement, rerunElement: HTMLElement, prowjob:
copyButton.onclick = () => copyToClipboardWithToast(`kubectl create -f "${url}"`);
copyButton.innerHTML = "file_copy";
rerunElement.appendChild(copyButton);
+ const runButton = document.createElement('a');
+ runButton.innerHTML = "";
+ if (login === "") {
+ runButton.href = `/github-login?dest=%2F?rerun=work_in_progress`;
+ } else {
+ if (rerunCreatesJob) {
+ runButton.onclick = () => {
+ const form = document.createElement('form');
+ form.method = 'POST';
+ form.action = `${url}`;
+ c.appendChild(form);
+ form.submit();
+ };
+ }
+ runButton.href = `/?rerun=work_in_progress`;
+ }
+ rerunElement.appendChild(runButton);
};
c.appendChild(i);
c.classList.add("icon-cell");
diff --git a/prow/cmd/deck/static/prow/prow.ts.orig b/prow/cmd/deck/static/prow/prow.ts.orig
new file mode 100644
index 0000000000000..41bc0b7f94883
--- /dev/null
+++ b/prow/cmd/deck/static/prow/prow.ts.orig
@@ -0,0 +1,955 @@
+import moment from "moment";
+import {Job, JobState, JobType} from "../api/prow";
+import {cell, getCookieByName, icon} from "../common/common";
+import {FuzzySearch} from './fuzzy-search';
+import {JobHistogram, JobSample} from './histogram';
+
+declare const allBuilds: Job[];
+declare const spyglass: boolean;
+declare const rerunCreatesJob: boolean;
+
+// http://stackoverflow.com/a/5158301/3694
+function getParameterByName(name: string): string | null {
+ const match = RegExp(`[?&]${name}=([^&/]*)`).exec(
+ window.location.search);
+ return match && decodeURIComponent(match[1].replace(/\+/g, ' '));
+}
+
+function shortenBuildRefs(buildRef: string): string {
+ return buildRef && buildRef.replace(/:[0-9a-f]*/g, '');
+}
+
+interface RepoOptions {
+ types: {[key: string]: boolean};
+ repos: {[key: string]: boolean};
+ jobs: {[key: string]: boolean};
+ authors: {[key: string]: boolean};
+ pulls: {[key: string]: boolean};
+ batches: {[key: string]: boolean};
+ states: {[key: string]: boolean};
+}
+
+function optionsForRepo(repo: string): RepoOptions {
+ const opts: RepoOptions = {
+ authors: {},
+ batches: {},
+ jobs: {},
+ pulls: {},
+ repos: {},
+ states: {},
+ types: {},
+ };
+
+ for (const build of allBuilds) {
+ opts.types[build.type] = true;
+ const repoKey = `${build.refs.org}/${build.refs.repo}`;
+ if (repoKey) {
+ opts.repos[repoKey] = true;
+ }
+ if (!repo || repo === repoKey) {
+ opts.jobs[build.job] = true;
+ opts.states[build.state] = true;
+ if (build.type === "presubmit" &&
+ build.refs.pulls &&
+ build.refs.pulls.length > 0) {
+ opts.authors[build.refs.pulls[0].author] = true;
+ opts.pulls[build.refs.pulls[0].number] = true;
+ } else if (build.type === "batch") {
+ opts.batches[shortenBuildRefs(build.refs_key)] = true;
+ }
+ }
+ }
+
+ return opts;
+}
+
+function redrawOptions(fz: FuzzySearch, opts: RepoOptions) {
+ const ts = Object.keys(opts.types).sort();
+ const selectedType = addOptions(ts, "type") as JobType;
+ const rs = Object.keys(opts.repos).filter((r) => r !== "/").sort();
+ addOptions(rs, "repo");
+ const js = Object.keys(opts.jobs).sort();
+ const jobInput = document.getElementById("job-input") as HTMLInputElement;
+ const jobList = document.getElementById("job-list") as HTMLUListElement;
+ addOptionFuzzySearch(fz, js, "job", jobList, jobInput);
+ const as = Object.keys(opts.authors).sort(
+ (a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
+ addOptions(as, "author");
+ if (selectedType === "batch") {
+ opts.pulls = opts.batches;
+ }
+ if (selectedType !== "periodic" && selectedType !== "postsubmit") {
+ const ps = Object.keys(opts.pulls).sort((a, b) => Number(a) - Number(b));
+ addOptions(ps, "pull");
+ } else {
+ addOptions([], "pull");
+ }
+ const ss = Object.keys(opts.states).sort();
+ addOptions(ss, "state");
+}
+
+function adjustScroll(el: Element): void {
+ const parent = el.parentElement!;
+ const parentRect = parent.getBoundingClientRect();
+ const elRect = el.getBoundingClientRect();
+
+ if (elRect.top < parentRect.top) {
+ parent.scrollTop -= elRect.height;
+ } else if (elRect.top + elRect.height >= parentRect.top
+ + parentRect.height) {
+ parent.scrollTop += elRect.height;
+ }
+}
+
+function handleDownKey(): void {
+ const activeSearches =
+ document.getElementsByClassName("active-fuzzy-search");
+ if (activeSearches !== null && activeSearches.length !== 1) {
+ return;
+ }
+ const activeSearch = activeSearches[0];
+ if (activeSearch.tagName !== "UL" ||
+ activeSearch.childElementCount === 0) {
+ return;
+ }
+
+ const selectedJobs = activeSearch.getElementsByClassName("job-selected");
+ if (selectedJobs.length > 1) {
+ return;
+ }
+ if (selectedJobs.length === 0) {
+ // If no job selected, select the first one that visible in the list.
+ const jobs = Array.from(activeSearch.children)
+ .filter((elChild) => {
+ const childRect = elChild.getBoundingClientRect();
+ const listRect = activeSearch.getBoundingClientRect();
+ return childRect.top >= listRect.top &&
+ (childRect.top < listRect.top + listRect.height);
+ });
+ if (jobs.length === 0) {
+ return;
+ }
+ jobs[0].classList.add("job-selected");
+ return;
+ }
+ const selectedJob = selectedJobs[0] as Element;
+ const nextSibling = selectedJob.nextElementSibling;
+ if (!nextSibling) {
+ return;
+ }
+
+ selectedJob.classList.remove("job-selected");
+ nextSibling.classList.add("job-selected");
+ adjustScroll(nextSibling);
+}
+
+function handleUpKey(): void {
+ const activeSearches =
+ document.getElementsByClassName("active-fuzzy-search");
+ if (activeSearches && activeSearches.length !== 1) {
+ return;
+ }
+ const activeSearch = activeSearches[0];
+ if (activeSearch.tagName !== "UL" ||
+ activeSearch.childElementCount === 0) {
+ return;
+ }
+
+ const selectedJobs = activeSearch.getElementsByClassName("job-selected");
+ if (selectedJobs.length !== 1) {
+ return;
+ }
+
+ const selectedJob = selectedJobs[0] as Element;
+ const previousSibling = selectedJob.previousElementSibling;
+ if (!previousSibling) {
+ return;
+ }
+
+ selectedJob.classList.remove("job-selected");
+ previousSibling.classList.add("job-selected");
+ adjustScroll(previousSibling);
+}
+
+window.onload = (): void => {
+ const topNavigator = document.getElementById("top-navigator")!;
+ let navigatorTimeOut: number | undefined;
+ const main = document.querySelector("main")! as HTMLElement;
+ main.onscroll = () => {
+ topNavigator.classList.add("hidden");
+ if (navigatorTimeOut) {
+ clearTimeout(navigatorTimeOut);
+ }
+ navigatorTimeOut = setTimeout(() => {
+ if (main.scrollTop === 0) {
+ topNavigator.classList.add("hidden");
+ } else if (main.scrollTop > 100) {
+ topNavigator.classList.remove("hidden");
+ }
+ }, 100);
+ };
+ topNavigator.onclick = () => {
+ main.scrollTop = 0;
+ };
+
+ document.addEventListener("keydown", (event) => {
+ if (event.keyCode === 40) {
+ handleDownKey();
+ } else if (event.keyCode === 38) {
+ handleUpKey();
+ }
+ });
+ // Register selection on change functions
+ const filterBox = document.getElementById("filter-box")!;
+ const options = filterBox.querySelectorAll("select")!;
+ options.forEach((opt) => {
+ opt.onchange = () => {
+ redraw(fz);
+ };
+ });
+ // Attach job status bar on click
+ const stateFilter = document.getElementById("state")! as HTMLSelectElement;
+ document.querySelectorAll(".job-bar-state").forEach((jb) => {
+ const state = jb.id.slice("job-bar-".length);
+ if (state === "unknown") {
+ return;
+ }
+ jb.addEventListener("click", () => {
+ stateFilter.value = state;
+ stateFilter.onchange!.call(stateFilter, new Event("change"));
+ });
+ });
+ // Attach job histogram on click to scroll the selected build into view
+ const jobHistogram = document.getElementById("job-histogram-content") as HTMLTableSectionElement;
+ jobHistogram.addEventListener("click", (event) => {
+ const target = event.target as HTMLElement;
+ if (target == null) {
+ return;
+ }
+ if (!target.classList.contains('active')) {
+ return;
+ }
+ const row = target.dataset.sampleRow;
+ if (row == null || row.length === 0) {
+ return;
+ }
+ const rowNumber = Number(row);
+ const builds = document.getElementById("builds")!.getElementsByTagName("tbody")[0];
+ if (builds == null || rowNumber >= builds.childNodes.length) {
+ return;
+ }
+ const targetRow = builds.childNodes[rowNumber] as HTMLTableRowElement;
+ targetRow.scrollIntoView();
+ });
+ // set dropdown based on options from query string
+ const opts = optionsForRepo("");
+ const fz = initFuzzySearch(
+ "job",
+ "job-input",
+ "job-list",
+ Object.keys(opts.jobs).sort());
+ redrawOptions(fz, opts);
+ redraw(fz);
+};
+
+function displayFuzzySearchResult(el: HTMLElement, inputContainer: ClientRect | DOMRect): void {
+ el.classList.add("active-fuzzy-search");
+ el.style.top = inputContainer.height - 1 + "px";
+ el.style.width = inputContainer.width + "px";
+ el.style.height = 200 + "px";
+ el.style.zIndex = "9999";
+}
+
+function fuzzySearch(fz: FuzzySearch, id: string, list: HTMLElement, input: HTMLInputElement): void {
+ const inputValue = input.value.trim();
+ addOptionFuzzySearch(fz, fz.search(inputValue), id, list, input, true);
+}
+
+function validToken(token: number): boolean {
+ // 0-9
+ if (token >= 48 && token <= 57) {
+ return true;
+ }
+ // a-z
+ if (token >= 65 && token <= 90) {
+ return true;
+ }
+ // - and backspace
+ return token === 189 || token === 8;
+}
+
+function handleEnterKeyDown(fz: FuzzySearch, list: HTMLElement, input: HTMLInputElement): void {
+ const selectedJobs = list.getElementsByClassName("job-selected");
+ if (selectedJobs && selectedJobs.length === 1) {
+ input.value = (selectedJobs[0] as HTMLElement).innerHTML;
+ }
+ // TODO(@qhuynh96): according to discussion in https://github.com/kubernetes/test-infra/pull/7165, the
+ // fuzzy search should respect user input no matter it is in the list or not. User may
+ // experience being redirected back to default view if the search input is invalid.
+ input.blur();
+ list.classList.remove("active-fuzzy-search");
+ redraw(fz);
+}
+
+function registerFuzzySearchHandler(fz: FuzzySearch, id: string, list: HTMLElement, input: HTMLInputElement): void {
+ input.addEventListener("keydown", (event) => {
+ if (event.keyCode === 13) {
+ handleEnterKeyDown(fz, list, input);
+ } else if (validToken(event.keyCode)) {
+ // Delay 1 frame that the input character is recorded before getting
+ // input value
+ setTimeout(() => fuzzySearch(fz, id, list, input), 32);
+ }
+ });
+}
+
+function initFuzzySearch(id: string, inputId: string, listId: string,
+ data: string[]): FuzzySearch {
+ const fz = new FuzzySearch(data);
+ const el = document.getElementById(id)!;
+ const input = document.getElementById(inputId)! as HTMLInputElement;
+ const list = document.getElementById(listId)!;
+
+ list.classList.remove("active-fuzzy-search");
+ input.addEventListener("focus", () => {
+ fuzzySearch(fz, id, list, input);
+ displayFuzzySearchResult(list, el.getBoundingClientRect());
+ });
+ input.addEventListener("blur", () => list.classList.remove("active-fuzzy-search"));
+
+ registerFuzzySearchHandler(fz, id, list, input);
+ return fz;
+}
+
+function registerJobResultEventHandler(fz: FuzzySearch, li: HTMLElement, input: HTMLInputElement) {
+ li.addEventListener("mousedown", (event) => {
+ input.value = (event.currentTarget as HTMLElement).innerHTML;
+ redraw(fz);
+ });
+ li.addEventListener("mouseover", (event) => {
+ const selectedJobs = document.getElementsByClassName("job-selected");
+ if (!selectedJobs) {
+ return;
+ }
+
+ for (const job of Array.from(selectedJobs)) {
+ job.classList.remove("job-selected");
+ }
+ (event.currentTarget as HTMLElement).classList.add("job-selected");
+ });
+ li.addEventListener("mouseout", (event) => {
+ (event.currentTarget as HTMLElement).classList.remove("job-selected");
+ });
+}
+
+function addOptionFuzzySearch(fz: FuzzySearch, data: string[], id: string,
+ list: HTMLElement, input: HTMLInputElement,
+ stopAutoFill?: boolean): void {
+ if (!stopAutoFill) {
+ input.value = getParameterByName(id) || '';
+ }
+ while (list.firstChild) {
+ list.removeChild(list.firstChild);
+ }
+ list.scrollTop = 0;
+ for (const datum of data) {
+ const li = document.createElement("li");
+ li.innerHTML = datum;
+ registerJobResultEventHandler(fz, li, input);
+ list.appendChild(li);
+ }
+}
+
+function addOptions(options: string[], selectID: string): string | null {
+ const sel = document.getElementById(selectID)! as HTMLSelectElement;
+ while (sel.length > 1) {
+ sel.removeChild(sel.lastChild!);
+ }
+ const param = getParameterByName(selectID);
+ for (const option of options) {
+ const o = document.createElement("option");
+ o.text = option;
+ if (param && option === param) {
+ o.selected = true;
+ }
+ sel.appendChild(o);
+ }
+ return param;
+}
+
+function selectionText(sel: HTMLSelectElement): string {
+ return sel.selectedIndex === 0 ? "" : sel.options[sel.selectedIndex].text;
+}
+
+function equalSelected(sel: string, t: string): boolean {
+ return sel === "" || sel === t;
+}
+
+function groupKey(build: Job): string {
+ const pr = (build.refs.pulls && build.refs.pulls.length === 1) ? build.refs.pulls[0].number : 0;
+ return `${build.refs.repo} ${pr} ${build.refs_key}`;
+}
+
+// escapeRegexLiteral ensures the given string is escaped so that it is treated as
+// an exact value when used within a RegExp. This is the standard substitution recommended
+// by https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions.
+function escapeRegexLiteral(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function redraw(fz: FuzzySearch): void {
+ const rerunStatus = getParameterByName("rerun");
+ const modal = document.getElementById('rerun')!;
+ const rerunCommand = document.getElementById('rerun-content')!;
+ window.onclick = (event) => {
+ if (event.target === modal) {
+ modal.style.display = "none";
+ }
+ };
+ const builds = document.getElementById("builds")!.getElementsByTagName(
+ "tbody")[0];
+ while (builds.firstChild) {
+ builds.removeChild(builds.firstChild);
+ }
+
+ const args: string[] = [];
+
+ function getSelection(name: string): string {
+ const sel = selectionText(document.getElementById(name) as HTMLSelectElement);
+ if (sel && opts && !opts[name + 's' as keyof RepoOptions][sel]) {
+ return "";
+ }
+ if (sel !== "") {
+ args.push(`${name}=${encodeURIComponent(sel)}`);
+ }
+ return sel;
+ }
+
+ function getSelectionFuzzySearch(id: string, inputId: string): RegExp {
+ const input = document.getElementById(inputId) as HTMLInputElement;
+ const inputText = input.value;
+ if (inputText === "") {
+ return new RegExp('');
+ }
+ if (inputText !== "") {
+ args.push(`${id}=${encodeURIComponent(inputText)}`);
+ }
+ if (inputText !== "" && opts && opts[id + 's' as keyof RepoOptions][inputText]) {
+ return new RegExp(`^${escapeRegexLiteral(inputText)}$`);
+ }
+ const expr = inputText.split('*').map(escapeRegexLiteral);
+ return new RegExp(`^${expr.join('.*')}$`);
+ }
+
+ const repoSel = getSelection("repo");
+ const opts = optionsForRepo(repoSel);
+
+ const typeSel = getSelection("type") as JobType;
+ if (typeSel === "batch") {
+ opts.pulls = opts.batches;
+ }
+ const pullSel = getSelection("pull");
+ const authorSel = getSelection("author");
+ const jobSel = getSelectionFuzzySearch("job", "job-input");
+ const stateSel = getSelection("state");
+
+ if (window.history && window.history.replaceState !== undefined) {
+ if (args.length > 0) {
+ history.replaceState(null, "", "/?" + args.join('&'));
+ } else {
+ history.replaceState(null, "", "/");
+ }
+ }
+ fz.setDict(Object.keys(opts.jobs));
+ redrawOptions(fz, opts);
+
+ let lastKey = '';
+ const jobCountMap = new Map() as Map;
+ const jobInterval: Array<[number, number]> = [[3600 * 3, 0], [3600 * 12, 0], [3600 * 48, 0]];
+ let currentInterval = 0;
+ const jobHistogram = new JobHistogram();
+ const now = moment().unix();
+ let totalJob = 0;
+ let displayedJob = 0;
+ for (let i = 0; i < allBuilds.length; i++) {
+ const build = allBuilds[i];
+ if (!equalSelected(typeSel, build.type)) {
+ continue;
+ }
+ if (!equalSelected(repoSel, `${build.refs.org}/${build.refs.repo}`)) {
+ continue;
+ }
+ if (!equalSelected(stateSel, build.state)) {
+ continue;
+ }
+ if (!jobSel.test(build.job)) {
+ continue;
+ }
+ if (build.type === "presubmit") {
+ if (build.refs.pulls && build.refs.pulls.length > 0) {
+ const pull = build.refs.pulls[0];
+ if (!equalSelected(pullSel, pull.number.toString())) {
+ continue;
+ }
+ if (!equalSelected(authorSel, pull.author)) {
+ continue;
+ }
+ }
+ } else if (build.type === "batch" && !authorSel) {
+ if (!equalSelected(pullSel, shortenBuildRefs(build.refs_key))) {
+ continue;
+ }
+ } else if (pullSel || authorSel) {
+ continue;
+ }
+
+ if (!jobCountMap.has(build.state)) {
+ jobCountMap.set(build.state, 0);
+ }
+ totalJob++;
+ jobCountMap.set(build.state, jobCountMap.get(build.state)! + 1);
+
+ // accumulate a count of the percentage of successful jobs over each interval
+ const started = Number(build.started);
+ if (currentInterval >= 0 && (now - started) > jobInterval[currentInterval][0]) {
+ let successCount = jobCountMap.get("success");
+ if (!successCount) {
+ successCount = 0;
+ }
+ let failureCount = jobCountMap.get("failure");
+ if (!failureCount) {
+ failureCount = 0;
+ }
+ const total = successCount + failureCount;
+ if (total > 0) {
+ jobInterval[currentInterval][1] = successCount / total;
+ } else {
+ jobInterval[currentInterval][1] = 0;
+ }
+ currentInterval++;
+ if (currentInterval >= jobInterval.length) {
+ currentInterval = -1;
+ }
+ }
+
+ if (displayedJob >= 500) {
+ jobHistogram.add(new JobSample(started, parseDuration(build.duration), build.state, -1));
+ continue;
+ } else {
+ jobHistogram.add(new JobSample(started, parseDuration(build.duration), build.state, builds.childElementCount));
+ }
+ displayedJob++;
+ const r = document.createElement("tr");
+ r.appendChild(cell.state(build.state));
+ if (build.pod_name) {
+ const logIcon = icon.create("description", "Build log");
+ logIcon.href = `log?job=${build.job}&id=${build.build_id}`;
+ const c = document.createElement("td");
+ c.classList.add("icon-cell");
+ c.appendChild(logIcon);
+ r.appendChild(c);
+ } else {
+ r.appendChild(cell.text(""));
+ }
+ r.appendChild(createRerunCell(modal, rerunCommand, build.prow_job));
+ r.appendChild(createViewJobCell(build.prow_job));
+ const key = groupKey(build);
+ if (key !== lastKey) {
+ // This is a different PR or commit than the previous row.
+ lastKey = key;
+ r.className = "changed";
+
+ if (build.type === "periodic") {
+ r.appendChild(cell.text(""));
+ } else {
+ let repoLink = build.refs.repo_link;
+ if (!repoLink) {
+ repoLink = `https://github.com/${build.refs.org}/${build.refs.repo}`;
+ }
+ r.appendChild(cell.link(`${build.refs.org}/${build.refs.repo}`, repoLink));
+ }
+ if (build.type === "presubmit") {
+ if (build.refs.pulls && build.refs.pulls.length > 0) {
+ r.appendChild(cell.prRevision(`${build.refs.org}/${build.refs.repo}`, build.refs.pulls[0]));
+ } else {
+ r.appendChild(cell.text(""));
+ }
+ } else if (build.type === "batch") {
+ r.appendChild(batchRevisionCell(build));
+ } else if (build.type === "postsubmit") {
+ r.appendChild(cell.commitRevision(`${build.refs.org}/${build.refs.repo}`, build.refs.base_ref || "",
+ build.refs.base_sha || "", build.refs.base_link || ""));
+ } else if (build.type === "periodic") {
+ r.appendChild(cell.text(""));
+ }
+ } else {
+ // Don't render identical cells for the same PR/commit.
+ r.appendChild(cell.text(""));
+ r.appendChild(cell.text(""));
+ }
+ if (spyglass) {
+ const buildIndex = build.url.indexOf('/build/');
+ if (buildIndex !== -1) {
+ const url = `${window.location.origin}/view/gcs/${build.url.substring(buildIndex + '/build/'.length)}`;
+ r.appendChild(createSpyglassCell(url));
+ } else if (build.url.includes('/view/')) {
+ r.appendChild(createSpyglassCell(build.url));
+ } else {
+ r.appendChild(cell.text(''));
+ }
+ } else {
+ r.appendChild(cell.text(''));
+ }
+ if (build.url === "") {
+ r.appendChild(cell.text(build.job));
+ } else {
+ r.appendChild(cell.link(build.job, build.url));
+ }
+
+ r.appendChild(cell.time(i.toString(), moment.unix(Number(build.started))));
+ r.appendChild(cell.text(build.duration));
+ builds.appendChild(r);
+ }
+
+ // fill out the remaining intervals if necessary
+ if (currentInterval !== -1) {
+ let successCount = jobCountMap.get("success");
+ if (!successCount) {
+ successCount = 0;
+ }
+ let failureCount = jobCountMap.get("failure");
+ if (!failureCount) {
+ failureCount = 0;
+ }
+ const total = successCount + failureCount;
+ for (let i = currentInterval; i < jobInterval.length; i++) {
+ if (total > 0) {
+ jobInterval[i][1] = successCount / total;
+ } else {
+ jobInterval[i][1] = 0;
+ }
+ }
+ }
+
+ const jobSummary = document.getElementById("job-histogram-summary")!;
+ const success = jobInterval.map((interval) => {
+ if (interval[1] < 0.5) {
+ return `${formatDuration(interval[0])}: ${Math.ceil(interval[1] * 100)}%`;
+ }
+ return `${formatDuration(interval[0])}: ${Math.ceil(interval[1] * 100)}%`;
+ }).join(", ");
+ jobSummary.innerHTML = `Success rate over time: ${success}`;
+ const jobCount = document.getElementById("job-count")!;
+ jobCount.textContent = `Showing ${displayedJob}/${totalJob} jobs`;
+ drawJobBar(totalJob, jobCountMap);
+
+ // if we aren't filtering the output, cap the histogram y axis to 2 hours because it
+ // contains the bulk of our jobs
+ let max = Number.MAX_SAFE_INTEGER;
+ if (totalJob === allBuilds.length) {
+ max = 2 * 3600;
+ }
+ drawJobHistogram(totalJob, jobHistogram, now - (12 * 3600), now, max);
+ if (rerunStatus != null) {
+ modal.style.display = "block";
+ rerunCommand.innerHTML = `Nice try! The direct rerun feature hasn't been implemented yet, so that button does nothing.`;
+ }
+
+}
+
+function createRerunCell(modal: HTMLElement, rerunElement: HTMLElement, prowjob: string): HTMLTableDataCellElement {
+ const url = `/rerun?prowjob=${prowjob}`;
+ const c = document.createElement("td");
+<<<<<<< HEAD
+ let i;
+ if (rerunCreatesJob) {
+ i = icon.create("refresh", "Rerun this job");
+ i.onclick = () => {
+ const form = document.createElement('form');
+ form.method = 'POST';
+ form.action = `${url}`;
+ c.appendChild(form);
+ form.submit();
+ };
+ } else {
+ i = icon.create("refresh", "Show instructions for rerunning this job");
+ i.onclick = () => {
+=======
+ const i = icon.create("refresh", "Show instructions for rerunning this job");
+ const login = getCookieByName("github_login");
+ i.onclick = () => {
+>>>>>>> Try to fix various git issues
+ modal.style.display = "block";
+ rerunElement.innerHTML = `kubectl create -f "${url}"`;
+ const copyButton = document.createElement('a');
+ copyButton.className = "mdl-button mdl-js-button mdl-button--icon";
+ copyButton.onclick = () => copyToClipboardWithToast(`kubectl create -f "${url}"`);
+ copyButton.innerHTML = "file_copy";
+ rerunElement.appendChild(copyButton);
+<<<<<<< HEAD
+ };
+ }
+=======
+ const runButton = document.createElement('a');
+ runButton.innerHTML = "";
+ if (login === "") {
+ runButton.href = `/github-login?dest=%2F?rerun=work_in_progress`;
+ } else {
+ runButton.href = `/?rerun=work_in_progress`;
+ }
+ rerunElement.appendChild(runButton);
+ };
+>>>>>>> Try to fix various git issues
+ c.appendChild(i);
+ c.classList.add("icon-cell");
+ return c;
+}
+
+function createViewJobCell(prowjob: string): HTMLTableDataCellElement {
+ const c = document.createElement("td");
+ const i = icon.create("pageview", "Show job YAML");
+ i.href = `https://${window.location.hostname}/prowjob?prowjob=${prowjob}`;
+ c.classList.add("icon-cell");
+ c.appendChild(i);
+ return c;
+}
+
+// copyToClipboard is from https://stackoverflow.com/a/33928558
+// Copies a string to the clipboard. Must be called from within an
+// event handler such as click. May return false if it failed, but
+// this is not always possible. Browser support for Chrome 43+,
+// Firefox 42+, Safari 10+, Edge and IE 10+.
+// IE: The clipboard feature may be disabled by an administrator. By
+// default a prompt is shown the first time the clipboard is
+// used (per session).
+function copyToClipboard(text: string) {
+ if (window.clipboardData && window.clipboardData.setData) {
+ // IE specific code path to prevent textarea being shown while dialog is visible.
+ return window.clipboardData.setData("Text", text);
+ } else if (document.queryCommandSupported && document.queryCommandSupported("copy")) {
+ const textarea = document.createElement("textarea");
+ textarea.textContent = text;
+ textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge.
+ document.body.appendChild(textarea);
+ textarea.select();
+ try {
+ return document.execCommand("copy"); // Security exception may be thrown by some browsers.
+ } catch (ex) {
+ console.warn("Copy to clipboard failed.", ex);
+ return false;
+ } finally {
+ document.body.removeChild(textarea);
+ }
+ }
+}
+
+function copyToClipboardWithToast(text: string): void {
+ copyToClipboard(text);
+
+ const toast = document.getElementById("toast") as SnackbarElement;
+ toast.MaterialSnackbar.showSnackbar({message: "Copied to clipboard"});
+}
+
+function batchRevisionCell(build: Job): HTMLTableDataCellElement {
+ const c = document.createElement("td");
+ if (!build.refs.pulls) {
+ return c;
+ }
+ for (let i = 0; i < build.refs.pulls.length; i++) {
+ if (i !== 0) {
+ c.appendChild(document.createTextNode(", "));
+ }
+ const l = document.createElement("a");
+ const link = build.refs.pulls[i].link;
+ if (link) {
+ l.href = link;
+ } else {
+ l.href = `https://github.com/${build.refs.org}/${build.refs.repo}/pull/${build.refs.pulls[i].number}`;
+ }
+ l.text = build.refs.pulls[i].number.toString();
+ c.appendChild(document.createTextNode("#"));
+ c.appendChild(l);
+ }
+ return c;
+}
+
+function drawJobBar(total: number, jobCountMap: Map): void {
+ const states: JobState[] = ["success", "pending", "triggered", "error", "failure", "aborted", ""];
+ states.sort((s1, s2) => {
+ return jobCountMap.get(s1)! - jobCountMap.get(s2)!;
+ });
+ states.forEach((state, index) => {
+ const count = jobCountMap.get(state);
+ // If state is undefined or empty, treats it as unknown state.
+ if (!state) {
+ state = "unknown";
+ }
+ const id = "job-bar-" + state;
+ const el = document.getElementById(id)!;
+ const tt = document.getElementById(state + "-tooltip")!;
+ if (!count || count === 0 || total === 0) {
+ el.textContent = "";
+ tt.textContent = "";
+ el.style.width = "0";
+ } else {
+ el.textContent = count.toString();
+ tt.textContent = `${count} ${stateToAdj(state)} jobs`;
+ if (index === states.length - 1) {
+ el.style.width = "auto";
+ } else {
+ el.style.width = Math.max((count / total * 100), 1) + "%";
+ }
+ }
+ });
+}
+
+function stateToAdj(state: JobState): string {
+ switch (state) {
+ case "success":
+ return "succeeded";
+ case "failure":
+ return "failed";
+ default:
+ return state;
+ }
+}
+
+function parseDuration(duration: string): number {
+ if (duration.length === 0) {
+ return 0;
+ }
+ let seconds = 0;
+ let multiple = 0;
+ for (let i = duration.length; i >= 0; i--) {
+ const ch = duration[i];
+ if (ch === 's') {
+ multiple = 1;
+ } else if (ch === 'm') {
+ multiple = 60;
+ } else if (ch === 'h') {
+ multiple = 60 * 60;
+ } else if (ch >= '0' && ch <= '9') {
+ seconds += Number(ch) * multiple;
+ multiple *= 10;
+ }
+ }
+ return seconds;
+}
+
+function formatDuration(seconds: number): string {
+ const parts: string[] = [];
+ if (seconds >= 3600) {
+ const hours = Math.floor(seconds / 3600);
+ parts.push(String(hours));
+ parts.push('h');
+ seconds = seconds % 3600;
+ }
+ if (seconds >= 60) {
+ const minutes = Math.floor(seconds / 60);
+ if (minutes > 0) {
+ parts.push(String(minutes));
+ parts.push('m');
+ seconds = seconds % 60;
+ }
+ }
+ if (seconds > 0) {
+ parts.push(String(seconds));
+ parts.push('s');
+ }
+ return parts.join('');
+}
+
+function drawJobHistogram(total: number, jobHistogram: JobHistogram, start: number, end: number, maximum: number): void {
+ const startEl = document.getElementById("job-histogram-start") as HTMLSpanElement;
+ if (startEl != null) {
+ startEl.textContent = `${formatDuration(end - start)} ago`;
+ }
+
+ // make sure the empty table is hidden
+ const tableEl = document.getElementById("job-histogram") as HTMLTableElement;
+ const labelsEl = document.getElementById("job-histogram-labels") as HTMLDivElement;
+ if (jobHistogram.length === 0) {
+ tableEl.style.display = "none";
+ labelsEl.style.display = "none";
+ return;
+ }
+ tableEl.style.display = "";
+ labelsEl.style.display = "";
+
+ const el = document.getElementById("job-histogram-content") as HTMLTableSectionElement;
+ el.title = `Showing ${jobHistogram.length} builds from last ${formatDuration(end - start)} by start time and duration, newest to oldest.`;
+ const rows = 10;
+ const width = 12;
+ const cols = Math.round(el.clientWidth / width);
+
+ // initialize the table if the row count changes
+ if (el.childNodes.length !== rows) {
+ el.innerHTML = "";
+ for (let i = 0; i < rows; i++) {
+ const tr = document.createElement('tr');
+ for (let j = 0; j < cols; j++) {
+ const td = document.createElement('td');
+ tr.appendChild(td);
+ }
+ el.appendChild(tr);
+ }
+ }
+
+ const buckets = jobHistogram.buckets(start, end, cols);
+ buckets.limitMaximum(maximum);
+
+ // show the max and mid y-axis labels rounded up to the nearest 10 minute mark
+ let maxY = buckets.max;
+ maxY = Math.ceil(maxY / 600);
+ const yMax = document.getElementById("job-histogram-labels-y-max") as HTMLSpanElement;
+ yMax.innerText = `${formatDuration(maxY * 600)}+`;
+ const yMid = document.getElementById("job-histogram-labels-y-mid") as HTMLSpanElement;
+ yMid.innerText = `${formatDuration(maxY / 2 * 600)}`;
+
+ // populate the buckets
+ buckets.data.forEach((bucket, colIndex) => {
+ let lastRowIndex = 0;
+ buckets.linearChunks(bucket, rows).forEach((samples, rowIndex) => {
+ lastRowIndex = rowIndex + 1;
+ const td = el.childNodes[rows - 1 - rowIndex].childNodes[cols - colIndex - 1] as HTMLTableCellElement;
+ if (samples.length === 0) {
+ td.removeAttribute('title');
+ td.className = '';
+ return;
+ }
+ td.dataset.sampleRow = String(samples[0].row);
+ const failures = samples.reduce((sum, sample) => {
+ return sample.state !== 'success' ? sum + 1 : sum;
+ }, 0);
+ if (failures === 0) {
+ td.title = `${samples.length} succeeded`;
+ } else {
+ if (failures === samples.length) {
+ td.title = `${failures} failed`;
+ } else {
+ td.title = `${failures}/${samples.length} failed`;
+ }
+ }
+ td.style.opacity = String(0.2 + samples.length / bucket.length * 0.8);
+ if (samples[0].row !== -1) {
+ td.className = `active success-${Math.floor(10 - (failures / samples.length) * 10)}`;
+ } else {
+ td.className = `success-${Math.floor(10 - (failures / samples.length) * 10)}`;
+ }
+ });
+ for (let rowIndex = lastRowIndex; rowIndex < rows; rowIndex++) {
+ const td = el.childNodes[rows - 1 - rowIndex].childNodes[cols - colIndex - 1] as HTMLTableCellElement;
+ td.removeAttribute('title');
+ td.className = '';
+ }
+ });
+}
+
+function createSpyglassCell(url: string): HTMLTableDataCellElement {
+ const i = icon.create('visibility', 'View in Spyglass');
+ i.href = url;
+ const c = document.createElement('td');
+ c.classList.add('icon-cell');
+ c.appendChild(i);
+ return c;
+}
diff --git a/prow/config/githuboauth.go b/prow/config/githuboauth.go
index d13e00888ef1c..9fc9162148357 100644
--- a/prow/config/githuboauth.go
+++ b/prow/config/githuboauth.go
@@ -31,11 +31,10 @@ type Cookie struct {
// GitHubOAuthConfig is a config for requesting users access tokens from GitHub API. It also has
// a Cookie Store that retains user credentials deriving from GitHub API.
type GitHubOAuthConfig struct {
- ClientID string `json:"client_id"`
- ClientSecret string `json:"client_secret"`
- RedirectURL string `json:"redirect_url"`
- Scopes []string `json:"scopes,omitempty"`
- FinalRedirectURL string `json:"final_redirect_url"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+ RedirectURL string `json:"redirect_url"`
+ Scopes []string `json:"scopes,omitempty"`
CookieStore *sessions.CookieStore `json:"-"`
}
diff --git a/prow/githuboauth/githuboauth.go b/prow/githuboauth/githuboauth.go
index cadee9f557a32..c67241da6c5c4 100644
--- a/prow/githuboauth/githuboauth.go
+++ b/prow/githuboauth/githuboauth.go
@@ -21,6 +21,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
+ "net/url"
"time"
"github.com/google/go-github/github"
@@ -54,6 +55,7 @@ type GitHubClientGetter interface {
// OAuthClient is an interface for a GitHub OAuth client.
type OAuthClient interface {
+ WithFinalRedirectURL(url string) (OAuthClient, error)
// Exchanges code from GitHub OAuth redirect for user access token.
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
// Returns a URL to GitHub's OAuth 2.0 consent page. The state is a token to protect the user
@@ -61,6 +63,35 @@ type OAuthClient interface {
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
}
+type client struct {
+ *oauth2.Config
+}
+
+func NewClient(config *oauth2.Config) client {
+ return client{
+ config,
+ }
+}
+
+func (cli client) WithFinalRedirectURL(path string) (OAuthClient, error) {
+ parsedURL, err := url.Parse(cli.RedirectURL)
+ if err != nil {
+ return nil, err
+ }
+ q := parsedURL.Query()
+ q.Set("dest", path)
+ parsedURL.RawQuery = q.Encode()
+ return NewClient(
+ &oauth2.Config{
+ ClientID: cli.ClientID,
+ ClientSecret: cli.ClientSecret,
+ RedirectURL: parsedURL.String(),
+ Scopes: cli.Scopes,
+ Endpoint: cli.Endpoint,
+ },
+ ), nil
+}
+
type githubClientGetter struct{}
func (gci *githubClientGetter) GetGitHubClient(accessToken string, dryRun bool) GitHubClientWrapper {
@@ -92,6 +123,7 @@ func NewAgent(config *config.GitHubOAuthConfig, logger *logrus.Entry) *Agent {
// redirect user to GitHub OAuth end-point for authentication.
func (ga *Agent) HandleLogin(client OAuthClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+ destPage := r.URL.Query().Get("dest")
stateToken := xsrftoken.Generate(ga.gc.ClientSecret, "", "")
state := hex.EncodeToString([]byte(stateToken))
oauthSession, err := ga.gc.CookieStore.New(r, oauthSessionCookie)
@@ -108,8 +140,11 @@ func (ga *Agent) HandleLogin(client OAuthClient) http.HandlerFunc {
ga.serverError(w, "Save oauth session", err)
return
}
-
- redirectURL := client.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline)
+ newClient, err := client.WithFinalRedirectURL(destPage)
+ if err != nil {
+ ga.serverError(w, "Failed to parse redirect URL", err)
+ }
+ redirectURL := newClient.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
}
@@ -135,7 +170,7 @@ func (ga *Agent) HandleLogout(client OAuthClient) http.HandlerFunc {
loginCookie.Expires = time.Now().Add(-time.Hour * 24)
http.SetCookie(w, loginCookie)
}
- http.Redirect(w, r, ga.gc.FinalRedirectURL, http.StatusFound)
+ http.Redirect(w, r, r.URL.Host, http.StatusFound)
}
}
@@ -144,6 +179,14 @@ func (ga *Agent) HandleLogout(client OAuthClient) http.HandlerFunc {
// the final destination in the config, which should be the front-end.
func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+ finalRedirectURL, err := r.URL.Parse(r.URL.Query().Get("dest"))
+ //This check prevents someone from specifying a different host to redirect to.
+ if finalRedirectURL.Host != "" {
+ ga.serverError(w, "Invalid hostname", fmt.Errorf("%s, expected prow.k8s.io", finalRedirectURL.Host))
+ }
+ if err != nil {
+ ga.serverError(w, "Failed to parse final destination from OAuth redirect payload", err)
+ }
state := r.FormValue("state")
stateTokenRaw, err := hex.DecodeString(state)
if err != nil {
@@ -163,7 +206,7 @@ func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) h
}
secretState, ok := oauthSession.Values[stateKey].(string)
if !ok {
- ga.serverError(w, "Get secret state", fmt.Errorf("empty string or cannot convert to string"))
+ ga.serverError(w, "Get secret state", fmt.Errorf("empty string or cannot convert to string. this probably means the options passed to GitHub don't match what was expected"))
return
}
// Validate the state parameter to prevent cross-site attack.
@@ -219,7 +262,7 @@ func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) h
Expires: time.Now().Add(time.Hour * 24 * 30),
Secure: true,
})
- http.Redirect(w, r, ga.gc.FinalRedirectURL, http.StatusFound)
+ http.Redirect(w, r, finalRedirectURL.String(), http.StatusFound)
}
}
diff --git a/prow/githuboauth/githuboauth_test.go b/prow/githuboauth/githuboauth_test.go
index 6a37e17209054..c8612bac1f59b 100644
--- a/prow/githuboauth/githuboauth_test.go
+++ b/prow/githuboauth/githuboauth_test.go
@@ -21,6 +21,8 @@ import (
"encoding/hex"
"net/http"
"net/http/httptest"
+ "net/url"
+ "strings"
"testing"
"github.com/google/go-github/github"
@@ -36,30 +38,42 @@ import (
const mockAccessToken = "justSomeRandomSecretToken"
-type MockOAuthClient struct{}
+type mockOAuthClient struct {
+ config *oauth2.Config
+}
+
+func (c mockOAuthClient) WithFinalRedirectURL(path string) (OAuthClient, error) {
+ parsedURL, err := url.Parse("www.something.com")
+ if err != nil {
+ return nil, err
+ }
+ q := parsedURL.Query()
+ q.Set("dest", path)
+ parsedURL.RawQuery = q.Encode()
+ return mockOAuthClient{&oauth2.Config{RedirectURL: parsedURL.String()}}, nil
+}
-func (c *MockOAuthClient) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
+func (c mockOAuthClient) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return &oauth2.Token{
AccessToken: mockAccessToken,
}, nil
}
-func (c *MockOAuthClient) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
- return "mock-auth-url"
+func (c mockOAuthClient) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
+ return c.config.AuthCodeURL(state, opts...)
}
func getMockConfig(cookie *sessions.CookieStore) *config.GitHubOAuthConfig {
clientID := "mock-client-id"
clientSecret := "mock-client-secret"
- redirectURL := "/uni-test/redirect-url"
+ redirectURL := "uni-test/redirect-url"
scopes := []string{}
return &config.GitHubOAuthConfig{
- ClientID: clientID,
- ClientSecret: clientSecret,
- RedirectURL: redirectURL,
- Scopes: scopes,
- FinalRedirectURL: "/unit-test/final-redirect-url",
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ RedirectURL: redirectURL,
+ Scopes: scopes,
CookieStore: cookie,
}
@@ -80,13 +94,15 @@ func isEqual(token1 *oauth2.Token, token2 *oauth2.Token) bool {
}
func TestHandleLogin(t *testing.T) {
+ dest := "wherever"
+ rerunStatus := "working"
cookie := sessions.NewCookieStore([]byte("secret-key"))
mockConfig := getMockConfig(cookie)
mockLogger := logrus.WithField("uni-test", "githuboauth")
mockAgent := NewAgent(mockConfig, mockLogger)
- mockOAuthClient := &MockOAuthClient{}
+ mockOAuthClient := mockOAuthClient{}
- mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login", nil)
+ mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login?dest="+dest+"?rerun="+rerunStatus, nil)
mockResponse := httptest.NewRecorder()
handleLoginFn := mockAgent.HandleLogin(mockOAuthClient)
@@ -125,6 +141,16 @@ func TestHandleLogin(t *testing.T) {
if state == "" {
t.Error("Expect state parameter is not empty, found empty")
}
+ destCount := 0
+ path := mockResponse.Header().Get("Location")
+ for _, q := range strings.Split(path, "&") {
+ if q == "redirect_uri=www.something.com%3Fdest%3Dwherever%253Frerun%253Dworking" {
+ destCount++
+ }
+ }
+ if destCount != 1 {
+ t.Errorf("Redirect URI in path does not include correct destination. path: %s, destination: %s", path, "?dest="+dest+"?rerun=working")
+ }
}
func TestHandleLogout(t *testing.T) {
@@ -132,7 +158,7 @@ func TestHandleLogout(t *testing.T) {
mockConfig := getMockConfig(cookie)
mockLogger := logrus.WithField("uni-test", "githuboauth")
mockAgent := NewAgent(mockConfig, mockLogger)
- mockOAuthClient := &MockOAuthClient{}
+ mockOAuthClient := mockOAuthClient{}
mockRequest := httptest.NewRequest(http.MethodGet, "/mock-logout", nil)
_, err := cookie.New(mockRequest, tokenSession)
@@ -172,7 +198,7 @@ func TestHandleLogoutWithLoginSession(t *testing.T) {
mockConfig := getMockConfig(cookie)
mockLogger := logrus.WithField("uni-test", "githuboauth")
mockAgent := NewAgent(mockConfig, mockLogger)
- mockOAuthClient := &MockOAuthClient{}
+ mockOAuthClient := mockOAuthClient{}
mockRequest := httptest.NewRequest(http.MethodGet, "/mock-logout", nil)
_, err := cookie.New(mockRequest, tokenSession)
@@ -232,7 +258,7 @@ func TestHandleRedirectWithInvalidState(t *testing.T) {
mockConfig := getMockConfig(cookie)
mockLogger := logrus.WithField("uni-test", "githuboauth")
mockAgent := NewAgent(mockConfig, mockLogger)
- mockOAuthClient := &MockOAuthClient{}
+ mockOAuthClient := mockOAuthClient{}
mockStateToken := createMockStateToken(mockConfig)
mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login", nil)
@@ -262,13 +288,16 @@ func TestHandleRedirectWithValidState(t *testing.T) {
mockLogger := logrus.WithField("uni-test", "githuboauth")
mockAgent := NewAgent(mockConfig, mockLogger)
mockLogin := "foo_name"
- mockOAuthClient := &MockOAuthClient{}
+ mockOAuthClient := mockOAuthClient{}
mockStateToken := createMockStateToken(mockConfig)
- mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login", nil)
+ dest := "somewhere"
+ rerunStatus := "working"
+ mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login?dest="+dest+"?rerun="+rerunStatus, nil)
mockResponse := httptest.NewRecorder()
query := mockRequest.URL.Query()
query.Add("state", mockStateToken)
+ query.Add("rerun", "working")
mockRequest.URL.RawQuery = query.Encode()
mockSession, err := sessions.GetRegistry(mockRequest).Get(cookie, oauthSessionCookie)
@@ -321,4 +350,8 @@ func TestHandleRedirectWithValidState(t *testing.T) {
if loginCookie.Value != mockLogin {
t.Errorf("Mismatch github login. Got %v, expected %v", loginCookie.Value, mockLogin)
}
+ path := mockResponse.Header().Get("Location")
+ if path != "/"+dest+"?rerun="+rerunStatus {
+ t.Errorf("Incorrect final redirect URL. Actual path: %s, Expected path: /%s", path, dest+"?rerun="+rerunStatus)
+ }
}