diff --git a/services/console/src/components/console/deck/DeckPanel.tsx b/services/console/src/components/console/deck/DeckPanel.tsx index ffd925e56..82c00b8ee 100644 --- a/services/console/src/components/console/deck/DeckPanel.tsx +++ b/services/console/src/components/console/deck/DeckPanel.tsx @@ -16,7 +16,7 @@ import { authUser } from "../../../util/auth"; import { httpGet } from "../../../util/http"; import { NotifyKind, pageNotify } from "../../../util/notify"; import { pathname, useSearchParams } from "../../../util/url"; -import { type InitValid, init_valid, validJwt } from "../../../util/valid"; +import { init_valid, jwtRequiredInvalid } from "../../../util/valid"; import Deck, { type DeckConfig } from "./hand/Deck"; import DeckHeader, { type DeckHeaderConfig } from "./header/DeckHeader"; @@ -45,17 +45,16 @@ const DeckPanel = (props: Props) => { const fetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), token: authUser()?.token, }; }); const getData = async (fetcher: { - bencher_valid: InitValid; token: string; }) => { const EMPTY_OBJECT = {}; - if (!fetcher.bencher_valid || !validJwt(fetcher.token)) { + // Fire in parallel with WASM init; the server validates the token regardless. + if (jwtRequiredInvalid(bencher_valid(), fetcher.token)) { return EMPTY_OBJECT; } return await httpGet(props.apiUrl, path(), fetcher.token) diff --git a/services/console/src/components/console/perf/PerfFrame.tsx b/services/console/src/components/console/perf/PerfFrame.tsx index 9ace49ae3..eb7b8ad77 100644 --- a/services/console/src/components/console/perf/PerfFrame.tsx +++ b/services/console/src/components/console/perf/PerfFrame.tsx @@ -20,7 +20,7 @@ import { NotifyKind, pageNotify, } from "../../../util/notify"; -import { type InitValid, init_valid, validJwt } from "../../../util/valid"; +import { init_valid, jwtRequiredInvalid } from "../../../util/valid"; import type { Theme } from "../../navbar/theme/theme"; import PerfPlot from "./plot/PerfPlot"; @@ -73,7 +73,6 @@ const PerfFrame = (props: Props) => { const perfFetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: props.project_slug(), perfQuery: props.perfQuery(), refresh: props.refresh(), @@ -81,20 +80,18 @@ const PerfFrame = (props: Props) => { }; }); const getPerf = async (fetcher: { - bencher_valid: InitValid; project_slug: string; perfQuery: JsonPerfQuery; refresh: number; token: string; }) => { const EMPTY_OBJECT = {}; - if (!fetcher.bencher_valid) { - return EMPTY_OBJECT; - } - // Don't even send query if there isn't at least one: branch, testbed, and benchmark + // Fire in parallel with WASM init; the server validates the token regardless. + // On the console still require a token to be present. Also don't send a query + // without at least one branch, testbed, and benchmark. if ( - (props.isConsole && !validJwt(fetcher.token)) || + (props.isConsole && jwtRequiredInvalid(bencher_valid(), fetcher.token)) || props.isPlotInit() || !fetcher.project_slug || fetcher.project_slug === "undefined" diff --git a/services/console/src/components/console/perf/PerfPanel.tsx b/services/console/src/components/console/perf/PerfPanel.tsx index 29e498a31..450ee1662 100644 --- a/services/console/src/components/console/perf/PerfPanel.tsx +++ b/services/console/src/components/console/perf/PerfPanel.tsx @@ -39,9 +39,8 @@ import { X_TOTAL_COUNT, httpGet } from "../../../util/http"; import { useSearchParams } from "../../../util/url"; import { DEBOUNCE_DELAY, - type InitValid, init_valid, - validJwt, + jwtRequiredInvalid, validU32, } from "../../../util/valid"; import { theme } from "../../navbar/theme/util"; @@ -595,19 +594,16 @@ const PerfPanel = (props: Props) => { perfTab: PerfTab, memo: T[], fetcher: { - bencher_valid: InitValid; project_slug: undefined | string; param_uuids: string[]; token: string; }, ) { const EMPTY_ARRAY: T[] = []; - if (!fetcher.bencher_valid) { - return EMPTY_ARRAY; - } - + // Fire in parallel with WASM init; the server validates the token regardless. + // On the console still require a token to be present. if ( - (props.isConsole && !validJwt(fetcher.token)) || + (props.isConsole && jwtRequiredInvalid(bencher_valid(), fetcher.token)) || !fetcher.project_slug || fetcher.project_slug === "undefined" || props.isEmbed === true @@ -637,7 +633,6 @@ const PerfPanel = (props: Props) => { const [branches_memo, setBranchesMemo] = createStore([]); const selectedBranchesFetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), param_uuids: branches(), token: user?.token, @@ -669,7 +664,6 @@ const PerfPanel = (props: Props) => { const [testbeds_memo, setTestbedsMemo] = createStore([]); const selectedTestbedFetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), param_uuids: testbeds(), token: user?.token, @@ -701,7 +695,6 @@ const PerfPanel = (props: Props) => { const [benchmarks_memo, setBenchmarksMemo] = createStore([]); const selectedBenchmarkFetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), param_uuids: benchmarks(), token: user?.token, @@ -729,7 +722,6 @@ const PerfPanel = (props: Props) => { async function getPerfTab( perfTab: PerfTab, fetcher: { - bencher_valid: InitValid; project_slug: undefined | string; per_page: number; page: number; @@ -742,12 +734,10 @@ const PerfPanel = (props: Props) => { totalCount: (headers: { [X_TOTAL_COUNT]: string }) => void, ) { const EMPTY_ARRAY: T[] = []; - if (!fetcher.bencher_valid) { - return EMPTY_ARRAY; - } - + // Fire in parallel with WASM init; the server validates the token regardless. + // On the console still require a token to be present. if ( - (props.isConsole && !validJwt(fetcher.token)) || + (props.isConsole && jwtRequiredInvalid(bencher_valid(), fetcher.token)) || !fetcher.project_slug || fetcher.project_slug === "undefined" || props.isEmbed === true || @@ -786,7 +776,6 @@ const PerfPanel = (props: Props) => { const reports_fetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), per_page: reports_per_page(), page: reports_page(), @@ -853,7 +842,6 @@ const PerfPanel = (props: Props) => { const branches_fetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), per_page: branches_per_page(), page: branches_page(), @@ -876,7 +864,6 @@ const PerfPanel = (props: Props) => { const testbeds_fetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), per_page: testbeds_per_page(), page: testbeds_page(), @@ -899,7 +886,6 @@ const PerfPanel = (props: Props) => { const benchmarks_fetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), per_page: benchmarks_per_page(), page: benchmarks_page(), @@ -924,7 +910,6 @@ const PerfPanel = (props: Props) => { const plots_fetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), per_page: plots_per_page(), page: plots_page(), diff --git a/services/console/src/components/console/plots/PinnedPlot.tsx b/services/console/src/components/console/plots/PinnedPlot.tsx index 8b3f7e2ba..7dd5fe9ba 100644 --- a/services/console/src/components/console/plots/PinnedPlot.tsx +++ b/services/console/src/components/console/plots/PinnedPlot.tsx @@ -9,7 +9,7 @@ import { } from "../../../util/auth"; import { httpGet } from "../../../util/http"; import { NotifyKind, navigateNotify } from "../../../util/notify"; -import { type InitValid, init_valid, validJwt } from "../../../util/valid"; +import { init_valid, jwtOptionalInvalid } from "../../../util/valid"; import Pinned from "./Pinned"; export interface Props { @@ -26,19 +26,15 @@ const PinnedPlot = (props: Props) => { const project_slug = createMemo(() => props.params?.project); const plotFetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), token: user?.token, }; }); const getPlot = async (fetcher: { - bencher_valid: InitValid; token: string; }) => { const EMPTY_OBJECT = {}; - if (!fetcher.bencher_valid) { - return EMPTY_OBJECT; - } - if (fetcher.token && !validJwt(fetcher.token)) { + // Fire in parallel with WASM init; the server validates the token regardless. + if (jwtOptionalInvalid(bencher_valid(), fetcher.token)) { return EMPTY_OBJECT; } const path = `/v0/projects/${props.params?.project}/plots/${props.params?.plot}`; diff --git a/services/console/src/components/console/plots/PlotsPanel.tsx b/services/console/src/components/console/plots/PlotsPanel.tsx index 6bb888922..02f14913e 100644 --- a/services/console/src/components/console/plots/PlotsPanel.tsx +++ b/services/console/src/components/console/plots/PlotsPanel.tsx @@ -24,9 +24,8 @@ import { setPageTitle } from "../../../util/resource"; import { useSearchParams } from "../../../util/url"; import { DEBOUNCE_DELAY, - type InitValid, init_valid, - validJwt, + jwtOptionalInvalid, } from "../../../util/valid"; import FallbackPlots from "./FallbackPlots"; import Pinned from "./Pinned"; @@ -70,21 +69,17 @@ const PlotsPanel = (props: Props) => { const project_slug = createMemo(() => params().project); const projectFetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), token: user?.token, }; }); const getProject = async (fetcher: { - bencher_valid: InitValid; project_slug: string; token: string; }) => { const EMPTY_OBJECT = {}; - if (!fetcher.bencher_valid) { - return EMPTY_OBJECT; - } - if (fetcher.token && !validJwt(fetcher.token)) { + // Fire in parallel with WASM init; the server validates the token regardless. + if (jwtOptionalInvalid(bencher_valid(), fetcher.token)) { return EMPTY_OBJECT; } const path = `/v0/projects/${fetcher.project_slug}`; @@ -108,14 +103,12 @@ const PlotsPanel = (props: Props) => { }); const plotsFetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), project_slug: project_slug(), searchQuery: searchQuery(), token: user?.token, }; }); const getPlots = async (fetcher: { - bencher_valid: InitValid; project_slug: string; searchQuery: { per_page: number; @@ -124,10 +117,8 @@ const PlotsPanel = (props: Props) => { token: string; }) => { const EMPTY_ARRAY: JsonPlot[] = []; - if (!fetcher.bencher_valid) { - return EMPTY_ARRAY; - } - if (fetcher.token && !validJwt(fetcher.token)) { + // Fire in parallel with WASM init; the server validates the token regardless. + if (jwtOptionalInvalid(bencher_valid(), fetcher.token)) { return EMPTY_ARRAY; } const searchParams = new URLSearchParams(); diff --git a/services/console/src/components/console/table/TablePanel.tsx b/services/console/src/components/console/table/TablePanel.tsx index 80e9ab770..977a9898e 100644 --- a/services/console/src/components/console/table/TablePanel.tsx +++ b/services/console/src/components/console/table/TablePanel.tsx @@ -25,9 +25,8 @@ import { NotifyKind, pageNotify } from "../../../util/notify"; import { useSearchParams } from "../../../util/url"; import { DEBOUNCE_DELAY, - type InitValid, init_valid, - validJwt, + jwtRequiredInvalid, validU32, } from "../../../util/valid"; import Pagination, { PaginationSize } from "../../site/Pagination"; @@ -137,7 +136,6 @@ const TablePanel = (props: Props) => { const fetcher = createMemo(() => { return { - bencher_valid: bencher_valid(), searchQuery: searchQuery(), token: authUser()?.token, }; @@ -146,7 +144,6 @@ const TablePanel = (props: Props) => { const [state, setState] = createSignal(TableState.LOADING); const [totalCount, setTotalCount] = createSignal(0); const getData = async (fetcher: { - bencher_valid: InitValid; searchQuery: { per_page: number; page: number; @@ -159,7 +156,8 @@ const TablePanel = (props: Props) => { token: string; }) => { const EMPTY_ARRAY: object[] = []; - if (!fetcher.bencher_valid || !validJwt(fetcher.token)) { + // Fire in parallel with WASM init; the server validates the token regardless. + if (jwtRequiredInvalid(bencher_valid(), fetcher.token)) { return EMPTY_ARRAY; } const searchParams = new URLSearchParams(); diff --git a/services/console/src/util/valid.ts b/services/console/src/util/valid.ts index b824c7886..534624715 100644 --- a/services/console/src/util/valid.ts +++ b/services/console/src/util/valid.ts @@ -89,6 +89,25 @@ export const validJwt = (token: undefined | null | string): boolean => export const validOptionJwt = (token: undefined | null | string): boolean => validOptionString(token, (i) => i.length === 0 || is_valid_jwt(i)); +// Best-effort JWT pre-checks used to gate data fetches without blocking on WASM +// init. The `validJwt` format check needs the `bencher_valid` WASM validator, so +// it is only run once that validator has loaded (`valid` truthy); until then the +// request is allowed through and the server validates the token regardless. + +// Required token: returns true when the fetch should be skipped, i.e. there is no +// token, or the validator is loaded and the token is malformed. +export const jwtRequiredInvalid = ( + valid: InitValid, + token: undefined | null | string, +): boolean => !token || (!!valid && !validJwt(token)); + +// Optional token: returns true only when a token is present, the validator is +// loaded, and the token is malformed. A missing token is allowed (public access). +export const jwtOptionalInvalid = ( + valid: InitValid, + token: undefined | null | string, +): boolean => !!token && !!valid && !validJwt(token); + export const validOptionUrl = (url: undefined | null | string): boolean => validOptionString(url, (i) => i.length === 0 || is_valid_url(i)); diff --git a/services/console/src/util/valid.wasm.test.ts b/services/console/src/util/valid.wasm.test.ts index a5a566254..a0ab112d9 100644 --- a/services/console/src/util/valid.wasm.test.ts +++ b/services/console/src/util/valid.wasm.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { init_valid, + jwtOptionalInvalid, + jwtRequiredInvalid, validBenchmarkName, validBoundary, validBranchName, @@ -349,4 +351,49 @@ describe.skipIf(!wasmReady)("WASM validators", () => { expect(validPlanLevel(undefined)).toBe(false); }); }); + + describe("jwtRequiredInvalid (best-effort, required token)", () => { + test("skips when token is missing, regardless of validator", () => { + expect(jwtRequiredInvalid(undefined, undefined)).toBe(true); + expect(jwtRequiredInvalid(undefined, "")).toBe(true); + expect(jwtRequiredInvalid(wasmReady, undefined)).toBe(true); + expect(jwtRequiredInvalid(wasmReady, "")).toBe(true); + }); + + test("does not reject a malformed token before the validator loads", () => { + // best-effort: let the request through; the server validates it + expect(jwtRequiredInvalid(undefined, "not.a.jwt")).toBe(false); + expect(jwtRequiredInvalid(undefined, fakeJwt)).toBe(false); + }); + + test("rejects a malformed token once the validator has loaded", () => { + expect(jwtRequiredInvalid(wasmReady, "not.a.jwt")).toBe(true); + }); + + test("allows a well-formed token once the validator has loaded", () => { + expect(jwtRequiredInvalid(wasmReady, fakeJwt)).toBe(false); + }); + }); + + describe("jwtOptionalInvalid (best-effort, optional token)", () => { + test("allows a missing token (public access)", () => { + expect(jwtOptionalInvalid(undefined, undefined)).toBe(false); + expect(jwtOptionalInvalid(undefined, "")).toBe(false); + expect(jwtOptionalInvalid(wasmReady, undefined)).toBe(false); + expect(jwtOptionalInvalid(wasmReady, "")).toBe(false); + }); + + test("does not reject a present token before the validator loads", () => { + expect(jwtOptionalInvalid(undefined, "not.a.jwt")).toBe(false); + expect(jwtOptionalInvalid(undefined, fakeJwt)).toBe(false); + }); + + test("rejects a present malformed token once the validator has loaded", () => { + expect(jwtOptionalInvalid(wasmReady, "not.a.jwt")).toBe(true); + }); + + test("allows a present well-formed token once the validator has loaded", () => { + expect(jwtOptionalInvalid(wasmReady, fakeJwt)).toBe(false); + }); + }); });