367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { expect } from "vitest";
|
|
import { evalIn, signInInBrowser, waitFor } from "@openwork/behaviors";
|
|
import { chrome, localHost } from "@openwork/hosts";
|
|
import {
|
|
ensureKubeStack,
|
|
KUBE_CONTEXT,
|
|
KUBE_RELEASE_NAME,
|
|
kindServer,
|
|
needs,
|
|
test,
|
|
} from "@openwork/testkit";
|
|
|
|
const FETCH_RECORD_KEY = "openwork:eval:den-split-origin-fetches";
|
|
const INTERNAL_API_URL = `http://${KUBE_RELEASE_NAME}-den-api:8788`;
|
|
|
|
interface CapturedRequest {
|
|
url: string;
|
|
method: string;
|
|
status: number | null;
|
|
failure: string | null;
|
|
}
|
|
|
|
interface BrowserMeResult {
|
|
url: string;
|
|
status: number | null;
|
|
tokenPresent: boolean;
|
|
email: string | null;
|
|
failure: string | null;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function parseJson(text: string, label: string): unknown {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
throw new Error(`${label} was not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
function readRequiredRecord(value: unknown, key: string, label: string): Record<string, unknown> {
|
|
if (!isRecord(value) || !isRecord(value[key])) {
|
|
throw new Error(`${label}.${key} was not an object.`);
|
|
}
|
|
return value[key];
|
|
}
|
|
|
|
function readRequiredString(value: unknown, key: string, label: string): string {
|
|
if (!isRecord(value) || typeof value[key] !== "string" || !value[key].trim()) {
|
|
throw new Error(`${label}.${key} was not a non-empty string.`);
|
|
}
|
|
return value[key];
|
|
}
|
|
|
|
function readRequiredBoolean(value: unknown, key: string, label: string): boolean {
|
|
if (!isRecord(value) || typeof value[key] !== "boolean") {
|
|
throw new Error(`${label}.${key} was not a boolean.`);
|
|
}
|
|
return value[key];
|
|
}
|
|
|
|
function parseCapturedRequests(value: unknown): CapturedRequest[] {
|
|
if (!Array.isArray(value)) {
|
|
throw new Error(`Fetch recorder returned a non-array value: ${JSON.stringify(value)}`);
|
|
}
|
|
const requests: CapturedRequest[] = [];
|
|
for (const entry of value) {
|
|
if (
|
|
!isRecord(entry)
|
|
|| typeof entry.url !== "string"
|
|
|| typeof entry.method !== "string"
|
|
|| (typeof entry.status !== "number" && entry.status !== null)
|
|
|| (typeof entry.failure !== "string" && entry.failure !== null)
|
|
) {
|
|
throw new Error(`Fetch recorder returned an invalid entry: ${JSON.stringify(entry)}`);
|
|
}
|
|
requests.push({ url: entry.url, method: entry.method, status: entry.status, failure: entry.failure });
|
|
}
|
|
return requests;
|
|
}
|
|
|
|
function parseBrowserMeResult(value: unknown): BrowserMeResult {
|
|
if (
|
|
!isRecord(value)
|
|
|| typeof value.url !== "string"
|
|
|| (typeof value.status !== "number" && value.status !== null)
|
|
|| typeof value.tokenPresent !== "boolean"
|
|
|| (typeof value.email !== "string" && value.email !== null)
|
|
|| (typeof value.failure !== "string" && value.failure !== null)
|
|
) {
|
|
throw new Error(`Browser /v1/me result had an unexpected shape: ${JSON.stringify(value)}`);
|
|
}
|
|
return {
|
|
url: value.url,
|
|
status: value.status,
|
|
tokenPresent: value.tokenPresent,
|
|
email: value.email,
|
|
failure: value.failure,
|
|
};
|
|
}
|
|
|
|
function runCommand(command: string, args: string[], timeoutMs: number): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(command, args, { encoding: "utf8", timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(new Error(`${command} ${args.join(" ")} failed: ${stderr.trim() || error.message}`));
|
|
return;
|
|
}
|
|
resolve(stdout);
|
|
});
|
|
});
|
|
}
|
|
|
|
function fetchRecorderSource(): string {
|
|
return `(() => {
|
|
const storageKey = ${JSON.stringify(FETCH_RECORD_KEY)};
|
|
const originalFetch = window.fetch.bind(window);
|
|
const readRequests = () => {
|
|
try {
|
|
const parsed = JSON.parse(sessionStorage.getItem(storageKey) || "[]");
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
window.fetch = async (input, init) => {
|
|
const inputUrl = typeof input === "string"
|
|
? input
|
|
: input instanceof URL
|
|
? input.href
|
|
: input.url;
|
|
const method = String(init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
const url = new URL(inputUrl, location.href).href;
|
|
try {
|
|
const response = await originalFetch(input, init);
|
|
const requests = readRequests();
|
|
requests.push({ url, method, status: response.status, failure: null });
|
|
sessionStorage.setItem(storageKey, JSON.stringify(requests));
|
|
return response;
|
|
} catch (error) {
|
|
const failure = error instanceof Error ? error.message : String(error);
|
|
const requests = readRequests();
|
|
requests.push({ url, method, status: null, failure });
|
|
sessionStorage.setItem(storageKey, JSON.stringify(requests));
|
|
throw error;
|
|
}
|
|
};
|
|
})();`;
|
|
}
|
|
|
|
function isAuthRequest(request: CapturedRequest): boolean {
|
|
return new URL(request.url).pathname.startsWith("/api/auth/");
|
|
}
|
|
|
|
function isApiRequest(request: CapturedRequest): boolean {
|
|
return new URL(request.url).pathname.startsWith("/v1/");
|
|
}
|
|
|
|
test("Den Web sends browser auth and API traffic to the public API origin", { timeout: 30 * 60_000 }, async ({ evidence }) => {
|
|
needs({
|
|
optIn: ["OPENWORK_EVAL_KIND_E2E"],
|
|
commands: ["kind", "kubectl", "helm", "docker"],
|
|
});
|
|
|
|
await ensureKubeStack({
|
|
cdpCandidates: [],
|
|
skipApp: true,
|
|
profile: "single-org",
|
|
images: "local",
|
|
log: (message) => console.error(`[openwork/testkit] ${message}`),
|
|
});
|
|
await runCommand("kubectl", [
|
|
"--context",
|
|
KUBE_CONTEXT,
|
|
"rollout",
|
|
"restart",
|
|
`deployment/${KUBE_RELEASE_NAME}-den-web`,
|
|
], 30_000);
|
|
await runCommand("kubectl", [
|
|
"--context",
|
|
KUBE_CONTEXT,
|
|
"rollout",
|
|
"status",
|
|
`deployment/${KUBE_RELEASE_NAME}-den-web`,
|
|
"--timeout=300s",
|
|
], 360_000);
|
|
|
|
await using den = await kindServer();
|
|
|
|
const configMapText = await runCommand("kubectl", [
|
|
"--context",
|
|
KUBE_CONTEXT,
|
|
"get",
|
|
"configmap",
|
|
`${KUBE_RELEASE_NAME}-config`,
|
|
"-o",
|
|
"json",
|
|
], 30_000);
|
|
const configMap = parseJson(configMapText, "Helm ConfigMap");
|
|
const configData = readRequiredRecord(configMap, "data", "Helm ConfigMap");
|
|
const internalApiUrl = readRequiredString(configData, "DEN_API_BASE", "Helm ConfigMap.data");
|
|
const publicApiUrl = readRequiredString(configData, "DEN_API_PUBLIC_URL", "Helm ConfigMap.data");
|
|
const configMapIsSplit = internalApiUrl === INTERNAL_API_URL && publicApiUrl === den.ref.apiUrl;
|
|
evidence.recordAssertionEvidence(
|
|
"The Helm ConfigMap keeps the server-only API base separate from the browser-facing API URL",
|
|
`DEN_API_BASE=${internalApiUrl}; DEN_API_PUBLIC_URL=${publicApiUrl}.`,
|
|
configMapIsSplit,
|
|
);
|
|
expect(internalApiUrl).toBe(INTERNAL_API_URL);
|
|
expect(publicApiUrl).toBe(den.ref.apiUrl);
|
|
|
|
const readyResponse = await fetch(`${den.ref.webUrl}/api/ready`, {
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
const readyPayload = parseJson(await readyResponse.text(), "Den Web readiness response");
|
|
const readyChecks = readRequiredRecord(readyPayload, "checks", "Den Web readiness response");
|
|
const upstreamStatus = readRequiredString(readyChecks, "upstream", "Den Web readiness response.checks");
|
|
const internalApiIsReady = readyResponse.status === 200 && upstreamStatus === "ok";
|
|
evidence.recordAssertionEvidence(
|
|
"Den Web can reach Den API through the server-only in-cluster DEN_API_BASE",
|
|
`GET /api/ready returned ${readyResponse.status} with upstream=${upstreamStatus} for DEN_API_BASE=${internalApiUrl}.`,
|
|
internalApiIsReady,
|
|
);
|
|
expect(readyResponse.status).toBe(200);
|
|
expect(upstreamStatus).toBe("ok");
|
|
|
|
const runtimeResponse = await fetch(`${den.ref.webUrl}/api/runtime-config`, {
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
const runtimeText = await runtimeResponse.text();
|
|
const runtimeConfig = parseJson(runtimeText, "Den Web runtime config");
|
|
const runtimeApiUrl = readRequiredString(runtimeConfig, "denApiUrl", "Den Web runtime config");
|
|
const internalHostname = new URL(internalApiUrl).hostname;
|
|
const runtimeConfigIsPublic = runtimeResponse.status === 200
|
|
&& runtimeApiUrl === den.ref.apiUrl
|
|
&& runtimeApiUrl !== internalApiUrl
|
|
&& !runtimeText.includes(internalHostname);
|
|
evidence.recordAssertionEvidence(
|
|
"Den Web runtime config exposes only the browser-facing API URL",
|
|
`HTTP ${runtimeResponse.status}; denApiUrl=${runtimeApiUrl}; internal hostname absent=${!runtimeText.includes(internalHostname)}.`,
|
|
runtimeConfigIsPublic,
|
|
);
|
|
expect(runtimeResponse.status).toBe(200);
|
|
expect(runtimeApiUrl).toBe(den.ref.apiUrl);
|
|
expect(runtimeApiUrl).not.toBe(internalApiUrl);
|
|
expect(runtimeText).not.toContain(internalHostname);
|
|
|
|
await using browser = await chrome({
|
|
host: localHost(),
|
|
name: "den-split-origin-runtime-config",
|
|
startUrl: "about:blank",
|
|
headless: true,
|
|
timeoutMs: 60_000,
|
|
});
|
|
await browser.client.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
source: fetchRecorderSource(),
|
|
});
|
|
await signInInBrowser(browser, `${den.ref.webUrl}/dashboard`, {
|
|
email: "alex@acme.test",
|
|
password: "OpenWorkDemo123!",
|
|
});
|
|
const signInOutcome = await evalIn(browser, `({
|
|
pathname: location.pathname,
|
|
signedIn: /signed in/i.test(document.body?.innerText ?? ""),
|
|
})`, { timeoutMs: 10_000 });
|
|
const dashboardPath = readRequiredString(signInOutcome, "pathname", "Browser sign-in outcome");
|
|
const signedInText = readRequiredBoolean(signInOutcome, "signedIn", "Browser sign-in outcome");
|
|
const signedInOutcomeObserved = dashboardPath.startsWith("/dashboard") || signedInText;
|
|
expect(signedInOutcomeObserved).toBe(true);
|
|
|
|
const publicApiOrigin = new URL(den.ref.apiUrl).origin;
|
|
const meUrl = `${publicApiOrigin}/v1/me`;
|
|
const meValue = await evalIn(browser, `(async () => {
|
|
const token = localStorage.getItem("openwork:web:auth-token")?.trim() ?? "";
|
|
const headers = new Headers({ Accept: "application/json" });
|
|
if (token) headers.set("Authorization", "Bearer " + token);
|
|
try {
|
|
const response = await fetch(${JSON.stringify(meUrl)}, { headers, credentials: "include" });
|
|
const payload = await response.json().catch(() => null);
|
|
return {
|
|
url: response.url,
|
|
status: response.status,
|
|
tokenPresent: Boolean(token),
|
|
email: payload && typeof payload === "object" && payload.user && typeof payload.user === "object"
|
|
&& typeof payload.user.email === "string" ? payload.user.email : null,
|
|
failure: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
url: ${JSON.stringify(meUrl)},
|
|
status: null,
|
|
tokenPresent: Boolean(token),
|
|
email: null,
|
|
failure: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
})()`, { awaitPromise: true, timeoutMs: 20_000 });
|
|
const meResult = parseBrowserMeResult(meValue);
|
|
expect(meResult.status).toBe(200);
|
|
expect(meResult.email).toBe("alex@acme.test");
|
|
expect(new URL(meResult.url).origin).toBe(publicApiOrigin);
|
|
expect(meResult.failure).toBeNull();
|
|
|
|
await waitFor(browser, `(() => {
|
|
try {
|
|
const requests = JSON.parse(sessionStorage.getItem(${JSON.stringify(FETCH_RECORD_KEY)}) || "[]");
|
|
return Array.isArray(requests)
|
|
&& requests.some((entry) => typeof entry?.url === "string"
|
|
&& new URL(entry.url).pathname.startsWith("/api/auth/")
|
|
&& entry.method === "POST"
|
|
&& entry.status >= 200
|
|
&& entry.status < 300
|
|
&& entry.failure === null)
|
|
&& requests.some((entry) => entry?.url === ${JSON.stringify(meUrl)}
|
|
&& entry.method === "GET"
|
|
&& entry.status === 200
|
|
&& entry.failure === null);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})()`, {
|
|
timeoutMs: 30_000,
|
|
label: "browser auth and API fetches recorded",
|
|
});
|
|
|
|
const capturedValue = await evalIn(browser, `(() => {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(${JSON.stringify(FETCH_RECORD_KEY)}) || "[]");
|
|
} catch {
|
|
return [];
|
|
}
|
|
})()`, { timeoutMs: 10_000 });
|
|
const capturedRequests = parseCapturedRequests(capturedValue);
|
|
const authRequests = capturedRequests.filter(isAuthRequest);
|
|
const apiRequests = capturedRequests.filter(isApiRequest);
|
|
const backendRequests = [...authRequests, ...apiRequests];
|
|
const successfulPostAuthRequests = authRequests.filter((request) => request.method === "POST"
|
|
&& request.status !== null
|
|
&& request.status >= 200
|
|
&& request.status < 300
|
|
&& request.failure === null
|
|
&& new URL(request.url).origin === publicApiOrigin);
|
|
const successfulAuthenticatedRequest = apiRequests.find((request) => request.url === meUrl
|
|
&& request.method === "GET"
|
|
&& request.status === 200
|
|
&& request.failure === null);
|
|
const backendOrigins = new Set(backendRequests.map((request) => new URL(request.url).origin));
|
|
const internalRequests = capturedRequests.filter((request) => new URL(request.url).hostname === internalHostname);
|
|
const browserUsedOnlyPublicApi = signedInOutcomeObserved
|
|
&& successfulPostAuthRequests.length > 0
|
|
&& successfulAuthenticatedRequest !== undefined
|
|
&& meResult.status === 200
|
|
&& internalRequests.length === 0;
|
|
evidence.recordAssertionEvidence(
|
|
"Real browser sign-in and authenticated API traffic complete on the public API origin and never use the in-cluster service",
|
|
`Outcome=${dashboardPath}; successful POST auth=${successfulPostAuthRequests.length}; /v1/me=${meResult.status} for ${meResult.email}; token present=${meResult.tokenPresent}; origins=${JSON.stringify([...backendOrigins])}; internal requests=${internalRequests.length}.`,
|
|
browserUsedOnlyPublicApi,
|
|
);
|
|
expect(successfulPostAuthRequests.length).toBeGreaterThan(0);
|
|
expect(successfulAuthenticatedRequest).toBeDefined();
|
|
expect(backendOrigins.has(publicApiOrigin)).toBe(true);
|
|
expect(internalRequests).toHaveLength(0);
|
|
});
|