test(auth): gate local and OIDC authentication release
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
// The only HTTPS navigation in this file is the test-scoped loopback provider.
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
stack = await createAuthenticationStack();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stack?.close();
|
||||
});
|
||||
|
||||
test("the loopback fixture exposes signed OIDC discovery, device authorization, and AuthentiK group lookup", async () => {
|
||||
await expect(stack.providerSurface()).resolves.toEqual({
|
||||
discovery: true,
|
||||
jwks: true,
|
||||
deviceAuthorization: true,
|
||||
deviceToken: true,
|
||||
groupList: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function expectShell(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function signInLocally(page: Page, account: "ordinary" | "admin", remember = false): Promise<void> {
|
||||
await page.getByLabel("Username").fill(stack.localAccount(account).username);
|
||||
await page.getByLabel("Password").fill(stack.localAccount(account).password);
|
||||
const rememberControl = page.getByRole("checkbox", { name: /remember me/i });
|
||||
if (remember) await rememberControl.check();
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expectShell(page);
|
||||
}
|
||||
|
||||
async function browserSession(page: Page): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
return page.evaluate(async () => {
|
||||
const response = await fetch("/api/me", { credentials: "same-origin" });
|
||||
return { status: response.status, body: await response.json() as Record<string, unknown> };
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoWebStorageTokens(page: Page): Promise<void> {
|
||||
const entries = await page.evaluate(() => {
|
||||
const values = (storage: Storage) => Array.from({ length: storage.length }, (_unused, index) => {
|
||||
const key = storage.key(index) ?? "";
|
||||
return [key, storage.getItem(key) ?? ""];
|
||||
});
|
||||
return [...values(localStorage), ...values(sessionStorage)];
|
||||
});
|
||||
expect(entries.filter(([key, value]) => /(?:access|refresh|id)?[_-]?token|bearer|jwt/i.test(`${key}\n${value}`))).toEqual([]);
|
||||
}
|
||||
|
||||
async function signInWithOidc(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /continue with single sign-on/i }).click();
|
||||
}
|
||||
|
||||
async function expectOidcCallbackDenied(page: Page): Promise<void> {
|
||||
await expect(page.locator("body")).toContainText("OIDC sign-in could not be completed", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("app-shell")).toHaveCount(0);
|
||||
await expectNoWebStorageTokens(page);
|
||||
}
|
||||
|
||||
test("local ordinary and remembered sessions survive restart, logout, and keep tokens out of Web Storage", async ({ page }) => {
|
||||
await stack.useLocalMode();
|
||||
await page.goto(stack.publicUrl);
|
||||
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
|
||||
|
||||
await signInLocally(page, "ordinary");
|
||||
expect((await browserSession(page)).body.roles).toEqual(["user"]);
|
||||
await expectNoWebStorageTokens(page);
|
||||
|
||||
await stack.restartBackend();
|
||||
await page.reload();
|
||||
await expectShell(page);
|
||||
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
|
||||
|
||||
await signInLocally(page, "ordinary", true);
|
||||
const remembered = (await page.context().cookies(stack.publicUrl)).find((cookie) => cookie.name === "thothii_session");
|
||||
expect(remembered?.httpOnly).toBe(true);
|
||||
expect(remembered?.expires ?? -1).toBeGreaterThan(Date.now() / 1_000);
|
||||
|
||||
await stack.restartBackend();
|
||||
await page.reload();
|
||||
await expectShell(page);
|
||||
await expectNoWebStorageTokens(page);
|
||||
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
|
||||
expect((await page.context().cookies(stack.publicUrl)).some((cookie) => cookie.name === "thothii_session")).toBe(false);
|
||||
});
|
||||
|
||||
test("local administrator receives the administrator role", async ({ page }) => {
|
||||
await stack.useLocalMode();
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInLocally(page, "admin");
|
||||
expect((await browserSession(page)).body.roles).toEqual(["admin"]);
|
||||
await expectNoWebStorageTokens(page);
|
||||
});
|
||||
|
||||
test("OIDC Authorization Code plus PKCE redirects back and maps ordinary and administrator groups", async ({ page }) => {
|
||||
await stack.useOidcMode("ordinary");
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInWithOidc(page);
|
||||
await expectShell(page);
|
||||
expect((await browserSession(page)).body.roles).toEqual(["user"]);
|
||||
expect(stack.lastAuthorization()).toMatchObject({ codeChallengeMethod: "S256", pkceVerified: true });
|
||||
await expectNoWebStorageTokens(page);
|
||||
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
|
||||
stack.setOidcIdentity("admin");
|
||||
await signInWithOidc(page);
|
||||
await expectShell(page);
|
||||
expect((await browserSession(page)).body.roles).toEqual(["admin"]);
|
||||
await expectNoWebStorageTokens(page);
|
||||
});
|
||||
|
||||
test("OIDC unmapped, missing, and malformed groups fail closed; an expired token can recover", async ({ page }) => {
|
||||
await stack.useOidcMode("unmapped");
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInWithOidc(page);
|
||||
await expect(page.getByRole("heading", { name: "Access not permitted" })).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoWebStorageTokens(page);
|
||||
|
||||
await page.context().clearCookies();
|
||||
stack.setOidcIdentity("missing-groups");
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInWithOidc(page);
|
||||
await expectOidcCallbackDenied(page);
|
||||
|
||||
stack.setOidcIdentity("malformed-groups");
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInWithOidc(page);
|
||||
await expectOidcCallbackDenied(page);
|
||||
|
||||
stack.setOidcIdentity("expired");
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInWithOidc(page);
|
||||
await expectOidcCallbackDenied(page);
|
||||
|
||||
stack.setOidcIdentity("ordinary");
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInWithOidc(page);
|
||||
await expectShell(page);
|
||||
expect((await browserSession(page)).body.roles).toEqual(["user"]);
|
||||
await expectNoWebStorageTokens(page);
|
||||
});
|
||||
+32
-9
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
|
||||
|
||||
/**
|
||||
* E2E F1 loop — hermetic (no VPN, no real Pi, no real Python).
|
||||
@@ -9,20 +10,42 @@ import { test, expect } from "@playwright/test";
|
||||
* which emits the f1_disambiguation.json scenario (select widget with
|
||||
* "interpretazione A" / "interpretazione B").
|
||||
*
|
||||
* Flow: open app → open NewSessionDialog → fill question → submit (Crea)
|
||||
* Flow: authenticate locally → focus the new-session composer → fill question → submit (Send)
|
||||
* → wait for F1 select widget → click an option → assert no error shown.
|
||||
*/
|
||||
test("F1 loop: new question → F1 widget → respond", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// Open the new-session dialog.
|
||||
await page.getByRole("button", { name: /nuova/i }).click();
|
||||
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
stack = await createAuthenticationStack({ withF1Workspace: true });
|
||||
await stack.useLocalMode();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stack?.close();
|
||||
});
|
||||
|
||||
async function signInLocally(page: Page): Promise<void> {
|
||||
const account = stack.localAccount("ordinary");
|
||||
await page.getByLabel("Username").fill(account.username);
|
||||
await page.getByLabel("Password").fill(account.password);
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test("F1 loop: new question → F1 widget → respond", async ({ page }) => {
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInLocally(page);
|
||||
|
||||
// Focus the composer for a new session.
|
||||
await page.getByRole("button", { name: "New session", exact: true }).click();
|
||||
|
||||
// Fill in the question.
|
||||
await page.getByLabel(/domanda/i).fill("quante cardioversioni nel 2024");
|
||||
await page.getByLabel("New question").fill("quante cardioversioni nel 2024");
|
||||
|
||||
// Submit — the backend creates the session (fake-tht) and spawns Pi (fake-pi).
|
||||
await page.getByRole("button", { name: /^crea$/i }).click();
|
||||
await page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
|
||||
// Wait for the F1 disambiguation select widget to appear.
|
||||
// The fake-pi emits extension_ui_request → backend bridges to SSE → frontend
|
||||
@@ -41,5 +64,5 @@ test("F1 loop: new question → F1 widget → respond", async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole("button", { name: /interpretazione A/i }),
|
||||
).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator("body")).not.toContainText(/errore/i);
|
||||
await expect(page.locator("body")).not.toContainText(/error/i);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { argon2 } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { startFakeOidcProvider } from "../../../backend/test/fixtures/oidc-provider.mjs";
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(__dir, "../../..");
|
||||
const frontendRoot = join(repositoryRoot, "frontend");
|
||||
const backendRoot = join(repositoryRoot, "backend");
|
||||
const harnessRoot = join(repositoryRoot, "harness");
|
||||
const thtRoot = join(repositoryRoot, "tools", "tht");
|
||||
const fakePi = join(frontendRoot, "e2e", "fixtures", "fake-pi.mjs");
|
||||
const fakeTht = join(frontendRoot, "e2e", "fixtures", "fake-tht.mjs");
|
||||
|
||||
function safeError(code) {
|
||||
return new Error(code);
|
||||
}
|
||||
|
||||
function buildAuthenticationStorageBridge(output) {
|
||||
const result = spawn("go", ["build", "-o", output, "./cmd/tht"], {
|
||||
cwd: thtRoot,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return new Promise((resolveBuild, rejectBuild) => {
|
||||
result.once("error", () => rejectBuild(safeError("e2e_auth_storage_build_failed")));
|
||||
result.once("exit", (code) => code === 0 ? resolveBuild() : rejectBuild(safeError("e2e_auth_storage_build_failed")));
|
||||
});
|
||||
}
|
||||
|
||||
function secureDirectory(path) {
|
||||
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||
chmodSync(path, 0o700);
|
||||
}
|
||||
|
||||
function runFixtureCommand(command, args, cwd) {
|
||||
const child = spawn(command, args, { cwd, stdio: "ignore" });
|
||||
return new Promise((resolveCommand, rejectCommand) => {
|
||||
child.once("error", () => rejectCommand(safeError("e2e_workspace_fixture_command_failed")));
|
||||
child.once("exit", (code) => code === 0
|
||||
? resolveCommand()
|
||||
: rejectCommand(safeError("e2e_workspace_fixture_command_failed")));
|
||||
});
|
||||
}
|
||||
|
||||
const F1_WORKSPACE_ID = "fixture-workspace";
|
||||
const F1_WORKSPACE_DESCRIPTOR = `workspace:
|
||||
schema_version: 3
|
||||
id: fixture-workspace
|
||||
name: Fixture workspace
|
||||
language: en
|
||||
dwh:
|
||||
engine: postgres
|
||||
database: fixture
|
||||
schema: fixture
|
||||
supported_transports: [postgres_direct]
|
||||
semantic_index:
|
||||
vector_store:
|
||||
engine: qdrant
|
||||
collection: fixture-workspace
|
||||
dimensions: 1024
|
||||
distance: cosine
|
||||
embedding:
|
||||
provider: ollama_internal
|
||||
model: qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
llm_policy:
|
||||
allowed: [zai/glm-5.2]
|
||||
default: zai/glm-5.2
|
||||
`;
|
||||
|
||||
async function prepareF1Workspace(root) {
|
||||
const source = join(root, "workspace-source");
|
||||
const remote = join(root, "workspace-remote.git");
|
||||
secureDirectory(source);
|
||||
secureDirectory(join(source, F1_WORKSPACE_ID));
|
||||
writeSecure(join(source, "thoth-workspaces.yaml"), [
|
||||
"schema_version: 1",
|
||||
"workspaces:",
|
||||
` - id: ${F1_WORKSPACE_ID}`,
|
||||
" name: Fixture workspace",
|
||||
"",
|
||||
].join("\n"));
|
||||
writeSecure(join(source, F1_WORKSPACE_ID, "workspace.yaml"), F1_WORKSPACE_DESCRIPTOR);
|
||||
|
||||
await runFixtureCommand("git", ["init", "--bare", "--initial-branch=main", remote], root);
|
||||
chmodSync(remote, 0o700);
|
||||
await runFixtureCommand("git", ["init", "--initial-branch=main"], source);
|
||||
await runFixtureCommand("git", ["config", "user.name", "ThothII E2E Fixture"], source);
|
||||
await runFixtureCommand("git", ["config", "user.email", "thothii-e2e@example.invalid"], source);
|
||||
await runFixtureCommand("git", ["add", "-A"], source);
|
||||
await runFixtureCommand("git", ["commit", "-m", "Create deterministic fixture workspace"], source);
|
||||
await runFixtureCommand("git", ["remote", "add", "origin", remote], source);
|
||||
await runFixtureCommand("git", ["push", "origin", "main"], source);
|
||||
return { id: F1_WORKSPACE_ID, remote };
|
||||
}
|
||||
|
||||
function writeSecure(path, value) {
|
||||
writeFileSync(path, value, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
async function testPasswordHash(password) {
|
||||
const salt = Buffer.from("thothii-e2e-salt");
|
||||
const message = Buffer.from(password, "utf8");
|
||||
let digest;
|
||||
try {
|
||||
digest = await new Promise((resolveDigest, rejectDigest) => {
|
||||
argon2("argon2id", {
|
||||
message,
|
||||
nonce: salt,
|
||||
memory: 65_536,
|
||||
passes: 3,
|
||||
parallelism: 1,
|
||||
tagLength: 32,
|
||||
}, (error, derived) => error || !derived ? rejectDigest(error ?? safeError("e2e_password_hash_failed")) : resolveDigest(derived));
|
||||
});
|
||||
return `$argon2id$v=19$m=65536,t=3,p=1$${salt.toString("base64").replaceAll("=", "")}$${digest.toString("base64").replaceAll("=", "")}`;
|
||||
} finally {
|
||||
message.fill(0);
|
||||
salt.fill(0);
|
||||
digest?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function pause(milliseconds) {
|
||||
return new Promise((resolvePause) => setTimeout(resolvePause, milliseconds));
|
||||
}
|
||||
|
||||
function providerJson(url, caFile, options = {}) {
|
||||
return new Promise((resolveResponse, rejectResponse) => {
|
||||
const body = options.body ?? "";
|
||||
const request = httpsRequest(url, {
|
||||
method: options.method ?? "GET",
|
||||
ca: readFileSync(caFile),
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(body.length === 0 ? {} : {
|
||||
"content-length": String(Buffer.byteLength(body)),
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
}),
|
||||
...options.headers,
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
response.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > 64 * 1024) request.destroy(safeError("e2e_provider_response_too_large"));
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
response.once("error", () => rejectResponse(safeError("e2e_provider_response_failed")));
|
||||
response.once("end", () => {
|
||||
try {
|
||||
resolveResponse({ status: response.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
|
||||
} catch {
|
||||
rejectResponse(safeError("e2e_provider_response_invalid"));
|
||||
}
|
||||
});
|
||||
});
|
||||
request.once("error", () => rejectResponse(safeError("e2e_provider_request_failed")));
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
async function freeLoopbackPort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen({ host: "127.0.0.1", port: 0 }, resolveListen);
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise((resolveClose) => server.close(resolveClose));
|
||||
if (!address || typeof address === "string") throw safeError("e2e_loopback_port_unavailable");
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const F1_QDRANT_INDEXES = Object.freeze([
|
||||
"content_hash", "document_id", "kind", "record_key",
|
||||
"record_kind", "vector_generation", "workspace_id", "workspace_revision",
|
||||
]);
|
||||
|
||||
async function startFakeQdrant() {
|
||||
const payloadSchema = Object.fromEntries(F1_QDRANT_INDEXES.map((field) => [field, { data_type: "keyword" }]));
|
||||
const server = createHttpServer((request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://loopback.invalid").pathname;
|
||||
if (request.method !== "GET" || path !== `/collections/${F1_WORKSPACE_ID}`) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
||||
result: {
|
||||
config: { params: { vectors: { size: 1024, distance: "Cosine" } } },
|
||||
payload_schema: payloadSchema,
|
||||
},
|
||||
}));
|
||||
});
|
||||
await new Promise((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen({ host: "127.0.0.1", port: 0 }, resolveListen);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise((resolveClose) => server.close(resolveClose));
|
||||
throw safeError("e2e_qdrant_loopback_port_unavailable");
|
||||
}
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/`,
|
||||
close: () => new Promise((resolveClose) => server.close(resolveClose)),
|
||||
};
|
||||
}
|
||||
|
||||
function managedProcess(command, args, options) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let exited = false;
|
||||
let diagnostic = "";
|
||||
const captureDiagnostic = (chunk) => {
|
||||
const sanitized = String(chunk)
|
||||
.replace(/https?:\/\/[^\s)]+/g, "[url]")
|
||||
.replace(/(?:THT_[A-Z_]+|PI_[A-Z_]+|AUTH_MODE)=\S+/g, "$1=[redacted]")
|
||||
.replace(/[A-Za-z0-9_-]{43,}/g, "[redacted]");
|
||||
diagnostic = `${diagnostic}${sanitized}`.slice(-800);
|
||||
};
|
||||
child.once("exit", () => { exited = true; });
|
||||
child.stdout?.on("data", captureDiagnostic);
|
||||
child.stderr?.on("data", captureDiagnostic);
|
||||
return {
|
||||
child,
|
||||
exited: () => exited,
|
||||
diagnostic: () => diagnostic.replace(/\s+/g, " ").trim(),
|
||||
async close() {
|
||||
if (exited) return;
|
||||
child.kill("SIGTERM");
|
||||
for (let attempt = 0; attempt < 50 && !exited; attempt += 1) await pause(20);
|
||||
if (!exited) child.kill("SIGKILL");
|
||||
for (let attempt = 0; attempt < 50 && !exited; attempt += 1) await pause(20);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForOk(url, processHandle) {
|
||||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||||
if (processHandle.exited()) {
|
||||
const detail = processHandle.diagnostic();
|
||||
throw safeError(detail ? `e2e_service_stopped_during_startup:${detail}` : "e2e_service_stopped_during_startup");
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "error" });
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// The process is expected to race its listener setup.
|
||||
}
|
||||
await pause(100);
|
||||
}
|
||||
throw safeError("e2e_service_startup_timeout");
|
||||
}
|
||||
|
||||
function cleanBackendEnvironment(overrides) {
|
||||
const env = { ...process.env };
|
||||
for (const name of [
|
||||
"AUTH_MODE", "THT_AUTH_CONFIG_FILE", "THT_AUTH_STATE_ROOT", "THT_SECRETS_FILE", "NODE_EXTRA_CA_CERTS",
|
||||
"SETTINGS_FILE", "THT_MAINTENANCE_FILE", "THT_WORKSPACE_REGISTRY_ROOT", "THT_WORKSPACE_SECRET_STORE_ROOT",
|
||||
"THT_WORKSPACE_SECRET_RUNTIME_ROOT", "THT_WORKSPACE_GIT_REMOTE", "THT_WORKSPACE_GIT_BRANCH",
|
||||
"THT_WORKSPACE_SECRET_ROOTS", "THT_WORKSPACE_INSTALLATION_ID", "THT_LEGACY_WORKSPACE_MODE",
|
||||
"THT_DWH_PRECHECK", "THT_INTERNAL_QDRANT_URL", "THT_CONFIG",
|
||||
]) delete env[name];
|
||||
for (const name of Object.keys(env)) {
|
||||
if (name.startsWith("THT_WS_")) delete env[name];
|
||||
}
|
||||
return { ...env, ...overrides };
|
||||
}
|
||||
|
||||
export async function createAuthenticationStack({ withF1Workspace = false } = {}) {
|
||||
const root = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-e2e-"));
|
||||
secureDirectory(root);
|
||||
const stateRoot = join(root, "auth-state");
|
||||
const registryRoot = join(root, "workspace-registry");
|
||||
const workspaceSecretRoot = join(root, "workspace-secrets");
|
||||
const workspaceRuntimeRoot = join(root, "workspace-runtime");
|
||||
const fixtureSecretRoot = join(root, "fixture-runtime-secrets");
|
||||
const providerRoot = join(root, "provider");
|
||||
const authConfigFile = join(root, "auth.yaml");
|
||||
const usersFile = join(root, "users.yaml");
|
||||
const secretsFile = join(root, "test.secrets");
|
||||
const settingsFile = join(root, "settings.json");
|
||||
const maintenanceFile = join(root, "maintenance.json");
|
||||
const authStorageBinary = join(root, "tht-auth-storage");
|
||||
const fixtureDwhPasswordFile = join(fixtureSecretRoot, "fixture-dwh-password");
|
||||
const fixtureDwhCaFile = join(fixtureSecretRoot, "fixture-dwh-ca.pem");
|
||||
for (const path of [stateRoot, registryRoot, workspaceSecretRoot, workspaceRuntimeRoot, fixtureSecretRoot, providerRoot]) secureDirectory(path);
|
||||
for (const child of ["sessions", "oidc"]) secureDirectory(join(stateRoot, child));
|
||||
|
||||
const [frontendPort, backendPort] = await Promise.all([freeLoopbackPort(), freeLoopbackPort()]);
|
||||
const publicUrl = `http://127.0.0.1:${frontendPort}`;
|
||||
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
||||
const workspace = withF1Workspace ? await prepareF1Workspace(root) : undefined;
|
||||
const provider = await startFakeOidcProvider({ directory: providerRoot });
|
||||
await buildAuthenticationStorageBridge(authStorageBinary);
|
||||
const localPassword = "e2e-local-password";
|
||||
const passwordHash = await testPasswordHash(localPassword);
|
||||
const accounts = Object.freeze({
|
||||
ordinary: Object.freeze({ username: "ordinary", password: localPassword }),
|
||||
admin: Object.freeze({ username: "administrator", password: localPassword }),
|
||||
});
|
||||
writeSecure(usersFile, JSON.stringify({
|
||||
version: 1,
|
||||
users: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
username: accounts.ordinary.username,
|
||||
displayName: "Fixture ordinary",
|
||||
passwordHash,
|
||||
roles: ["user"],
|
||||
enabled: true,
|
||||
authRevision: 1,
|
||||
},
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
username: accounts.admin.username,
|
||||
displayName: "Fixture administrator",
|
||||
passwordHash,
|
||||
roles: ["admin"],
|
||||
enabled: true,
|
||||
authRevision: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
writeSecure(secretsFile, [
|
||||
"THT_OIDC_CLIENT_SECRET=e2e-client-secret-not-a-production-secret",
|
||||
"THT_AUTHENTIK_API_TOKEN=e2e-group-catalog-token-not-a-production-secret",
|
||||
"",
|
||||
].join("\n"));
|
||||
if (workspace) {
|
||||
writeSecure(fixtureDwhPasswordFile, "fixture-password-not-a-secret\n");
|
||||
writeSecure(fixtureDwhCaFile, "fixture-ca-not-a-production-certificate\n");
|
||||
writeSecure(settingsFile, JSON.stringify({
|
||||
workspace: workspace.id,
|
||||
provider: "zai",
|
||||
model: "glm-5.2",
|
||||
thinking: "medium",
|
||||
}));
|
||||
}
|
||||
|
||||
let mode = undefined;
|
||||
let backend = undefined;
|
||||
let qdrant = undefined;
|
||||
const frontend = managedProcess(join(frontendRoot, "node_modules", ".bin", "vite"), [
|
||||
"--host", "127.0.0.1", "--port", String(frontendPort), "--strictPort",
|
||||
], {
|
||||
cwd: frontendRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
THT_FRONTEND_API_UPSTREAM: backendUrl,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await waitForOk(publicUrl, frontend);
|
||||
qdrant = workspace ? await startFakeQdrant() : undefined;
|
||||
|
||||
const localConfig = () => ({
|
||||
version: 1,
|
||||
mode: "local",
|
||||
publicUrl,
|
||||
session: {
|
||||
regularTtlSeconds: 600,
|
||||
regularIdleSeconds: 600,
|
||||
rememberTtlSeconds: 2_592_000,
|
||||
rememberIdleSeconds: 604_800,
|
||||
},
|
||||
local: { usersFile: "users.yaml" },
|
||||
});
|
||||
const oidcConfig = () => ({
|
||||
version: 1,
|
||||
mode: "oidc",
|
||||
publicUrl,
|
||||
session: {
|
||||
regularTtlSeconds: 600,
|
||||
regularIdleSeconds: 600,
|
||||
oidcTtlSeconds: 60,
|
||||
},
|
||||
oidc: {
|
||||
issuer: provider.issuer,
|
||||
clientId: "thothii-e2e-client",
|
||||
clientSecretRef: "THT_OIDC_CLIENT_SECRET",
|
||||
scopes: ["openid", "profile", "groups"],
|
||||
groupsClaim: "groups",
|
||||
},
|
||||
groupCatalog: {
|
||||
driver: "authentik",
|
||||
baseUrl: provider.baseUrl,
|
||||
apiTokenRef: "THT_AUTHENTIK_API_TOKEN",
|
||||
},
|
||||
authorization: {
|
||||
groupRoles: {
|
||||
"fixture-users": ["user"],
|
||||
"fixture-admin": ["admin"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function startBackend() {
|
||||
if (mode === undefined) throw safeError("e2e_auth_mode_not_configured");
|
||||
backend = managedProcess(join(backendRoot, "node_modules", ".bin", "tsx"), ["src/server.ts"], {
|
||||
cwd: backendRoot,
|
||||
env: cleanBackendEnvironment({
|
||||
NODE_ENV: "test",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(backendPort),
|
||||
PI_BIN: fakePi,
|
||||
THT_BIN: fakeTht,
|
||||
THT_AUTH_STORAGE_BIN: authStorageBinary,
|
||||
THT_HARNESS_DIR: harnessRoot,
|
||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||
THT_AUTH_STATE_ROOT: stateRoot,
|
||||
THT_SECRETS_FILE: secretsFile,
|
||||
NODE_EXTRA_CA_CERTS: provider.caFile,
|
||||
SETTINGS_FILE: settingsFile,
|
||||
THT_MAINTENANCE_FILE: maintenanceFile,
|
||||
THT_WORKSPACE_REGISTRY_ROOT: registryRoot,
|
||||
THT_WORKSPACE_SECRET_STORE_ROOT: workspaceSecretRoot,
|
||||
THT_WORKSPACE_SECRET_RUNTIME_ROOT: workspaceRuntimeRoot,
|
||||
THT_WORKSPACE_INSTALLATION_ID: "e2e",
|
||||
THT_DATA_ROOT: join(root, "data"),
|
||||
...(qdrant ? { THT_INTERNAL_QDRANT_URL: qdrant.baseUrl } : {}),
|
||||
...(workspace ? {
|
||||
THT_WORKSPACE_GIT_REMOTE: workspace.remote,
|
||||
THT_WORKSPACE_GIT_BRANCH: "main",
|
||||
THT_WORKSPACE_SECRET_ROOTS: fixtureSecretRoot,
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_TRANSPORT: "postgres_direct",
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_HOST: "127.0.0.1",
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_PORT: "5432",
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_USER: "fixture",
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_PASSWORD_FILE: fixtureDwhPasswordFile,
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_TLS_CA_FILE: fixtureDwhCaFile,
|
||||
} : {}),
|
||||
}),
|
||||
});
|
||||
await waitForOk(`${backendUrl}/health`, backend);
|
||||
}
|
||||
|
||||
async function restartBackend() {
|
||||
await backend?.close();
|
||||
backend = undefined;
|
||||
await startBackend();
|
||||
}
|
||||
|
||||
return {
|
||||
publicUrl,
|
||||
localAccount(account) {
|
||||
const found = accounts[account];
|
||||
if (!found) throw safeError("e2e_local_account_unknown");
|
||||
return found;
|
||||
},
|
||||
lastAuthorization() {
|
||||
return provider.lastAuthorization();
|
||||
},
|
||||
async providerSurface() {
|
||||
const discovery = await providerJson(`${provider.issuer}.well-known/openid-configuration`, provider.caFile);
|
||||
const jwks = await providerJson(`${provider.issuer}jwks`, provider.caFile);
|
||||
const device = await providerJson(`${provider.issuer}device_authorization`, provider.caFile, {
|
||||
method: "POST",
|
||||
body: "client_id=thothii-e2e-client",
|
||||
});
|
||||
const deviceCode = typeof device.body?.device_code === "string" ? device.body.device_code : "";
|
||||
const deviceToken = deviceCode.length === 0 ? { status: 0, body: {} } : await providerJson(`${provider.issuer}token`, provider.caFile, {
|
||||
method: "POST",
|
||||
body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=${encodeURIComponent(deviceCode)}`,
|
||||
});
|
||||
const groups = await providerJson(`${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`, provider.caFile, {
|
||||
headers: { authorization: "Bearer e2e-fixture" },
|
||||
});
|
||||
return {
|
||||
discovery: discovery.status === 200 && discovery.body?.issuer === provider.issuer,
|
||||
jwks: jwks.status === 200 && jwks.body?.keys?.[0]?.alg === "RS256" && jwks.body?.keys?.[0]?.use === "sig",
|
||||
deviceAuthorization: device.status === 200 && typeof device.body?.device_code === "string"
|
||||
&& typeof device.body?.verification_uri === "string",
|
||||
deviceToken: deviceToken.status === 200 && typeof deviceToken.body?.id_token === "string",
|
||||
groupList: groups.status === 200 && groups.body?.results?.[0]?.name === "fixture-users",
|
||||
};
|
||||
},
|
||||
setOidcIdentity(identity) {
|
||||
provider.setIdentity(identity);
|
||||
},
|
||||
async useLocalMode() {
|
||||
writeSecure(authConfigFile, JSON.stringify(localConfig()));
|
||||
mode = "local";
|
||||
await restartBackend();
|
||||
},
|
||||
async useOidcMode(identity) {
|
||||
provider.setIdentity(identity);
|
||||
writeSecure(authConfigFile, JSON.stringify(oidcConfig()));
|
||||
mode = "oidc";
|
||||
await restartBackend();
|
||||
},
|
||||
restartBackend,
|
||||
async close() {
|
||||
await backend?.close();
|
||||
await frontend.close();
|
||||
await provider.close();
|
||||
await qdrant?.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await backend?.close();
|
||||
await frontend.close();
|
||||
await provider.close();
|
||||
await qdrant?.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user