test(auth): gate local and OIDC authentication release

This commit is contained in:
2026-08-18 06:02:25 +02:00
parent 7cf7d9db6b
commit 8a3fa5031d
23 changed files with 1711 additions and 168 deletions
+32
View File
@@ -74,6 +74,38 @@ jobs:
npx vitest run
npx tsc -b
authentication-browser:
name: Hermetic authentication browser gate
needs: deterministic
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.16.0"
package-manager-cache: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.5"
cache-dependency-path: tools/tht/go.sum
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Install Chromium for Playwright
working-directory: frontend
run: npx playwright install --with-deps chromium
- name: Run authentication browser smoke
run: bash scripts/authentication-smoke.sh
linux-docker:
name: Linux Docker deployment and rollback
runs-on: ubuntu-24.04
+25 -17
View File
@@ -7,26 +7,34 @@
> ThothII per il repository (app + CLI `thothctl`), (3) come usare l'applicazione ThothII di base
> (sessioni, domande, gate). Il documento userà parole semplici ed esempi; i dettagli tecnici
> resteranno nei contratti esistenti. Esempio pratico completo: Policlinico San Donato.
> Last updated: 2026-08-18 (Task 14 documentation implemented; strict documentation and
> authentication release gates remain blocking or pending as listed below).
> Last updated: 2026-08-18 (Task 15 automated evidence recorded; the authentication feature is
> not complete or release-accepted while the required FAIL/PENDING gates listed below remain).
> Point a fresh session here ("read PROJECT_STATE.md") before substantial work.
### Task 14 authentication documentation — implementation status (2026-08-18)
### Task 15 authentication release — automated evidence, release incomplete (2026-08-18)
- Documentation now describes local Argon2id users, ordinary and remembered session expiry,
revision invalidation, generic OIDC direct groups claims, exact group-role mapping, Authentik
group-view-only catalog checks, tht auth/tht doctor ordering, diagnostics, CSRF, and restore
reauthentication.
- The Task 14 authentication-documentation smoke passes. The MkDocs `--strict` baseline remains a
blocking release gate: it exits 1 with 69 warnings, the same warning count before and after Task
14. It is not a PASS or release evidence.
- Browser OIDC callback E2E, native Windows behavioral execution, PSD/manual test identities, and
external L2 remain pending Task 15/release gates. Task 14 documentation is implemented, not
release-accepted.
- Task 13 has two parked restore-lock preconditions that remain mandatory before certification:
lock before target-dependent preflight with archive bytes/hashes staged and revalidated inside the
lock immediately before extraction; and an opaque installation-bound transaction capability or
closure replacing convention-only lock-held helpers.
- Task 13 carry-ins are closed with focused TDD: restore acquires the lifecycle lock before
target-dependent preflight, stages/revalidates archive bytes and hashes under that lock, and uses
an opaque installation-bound transaction capability. Focused mutation/concurrency/lock-leak
tests and the full Go race/build gate pass.
- PASS on the pinned Node `v24.16.0`: backend 75 files / 1081 tests plus typecheck/build; frontend
61 files / 444 tests plus typecheck/build and 6 Playwright tests; hermetic loopback OIDC browser
smoke; harness 921 passed / 4 L2 deselected; authentication-documentation smoke; unified Docker
smoke with scoped cleanup; installer shell test; relevant Compose/security contracts. The default
host Node is `v25.6.1`; it is not the release contract and no tracked `v24.19.0` pin exists.
- Task15 fixture regressions in preprocess, server Pi-state, and Pi-auth Compose contracts were
repaired and their focused checks pass. Static Windows amd64 cross-build and test compilation
pass for 18 Go packages.
- FAIL baseline evidence: Ruff reports 192 errors; MkDocs `--strict` exits 1 with 69 warnings;
the canonical-install and workspace-install-doc checks report existing manual wording mismatches;
the Pi user-auth Compose check reaches an existing model allow-list mismatch; and the broad
deployment-coupling scanner sees preserved ignored private deployment material.
- PENDING evidence: native Windows execution (no KVM, `sshpass`, Windows container, or PowerShell);
real PSD/manual acceptance (no real test identity/access); L2 (no configured local secret layout);
and isolated provider-readiness Docker smoke (a host port is already occupied by an unrelated
resource). These are not PASS claims.
- **Release state: NOT COMPLETE.** Do not mark authentication release-complete until every required
gate is rerun in an eligible environment and is PASS.
### P3 effective configuration and `.tht-dwh` — implementation complete, automated PASS, manual PASS (2026-08-13)
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env node
/**
* Hermetic loopback OIDC/AuthentiK-shaped provider for browser smoke tests.
*
* It deliberately has no network dependency and binds only to 127.0.0.1. Its
* ephemeral TLS and signing keys are test-scoped; callers receive the CA path
* needed to trust the provider from a spawned backend process.
*/
import { spawnSync } from "node:child_process";
import { createHash, generateKeyPairSync, randomBytes, sign as signRsa } from "node:crypto";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:https";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const LOOPBACK_HOST = "127.0.0.1";
const ISSUER_PATH = "/application/o/thothii";
const MAX_BODY_BYTES = 32 * 1024;
const VALID_IDENTITIES = new Set([
"ordinary",
"admin",
"missing-groups",
"malformed-groups",
"unmapped",
"expired",
]);
const identityClaims = Object.freeze({
ordinary: { subject: "fixture-ordinary", displayName: "Fixture ordinary", groups: ["fixture-users"] },
admin: { subject: "fixture-admin", displayName: "Fixture administrator", groups: ["fixture-admin"] },
"missing-groups": { subject: "fixture-missing-groups", displayName: "Fixture missing groups" },
"malformed-groups": { subject: "fixture-malformed-groups", displayName: "Fixture malformed groups", groups: ["fixture-users", 7] },
unmapped: { subject: "fixture-unmapped", displayName: "Fixture unmapped", groups: ["fixture-unmapped"] },
expired: { subject: "fixture-expired", displayName: "Fixture expired", groups: ["fixture-users"], expired: true },
});
function safeError(code) {
return new Error(code);
}
function ensurePrivateDirectory(directory) {
mkdirSync(directory, { recursive: true, mode: 0o700 });
chmodSync(directory, 0o700);
}
function createCertificate(directory) {
const keyFile = join(directory, "provider-key.pem");
const certificateFile = join(directory, "provider-ca.pem");
const result = spawnSync("openssl", [
"req", "-x509", "-newkey", "rsa:2048", "-sha256", "-nodes",
"-keyout", keyFile,
"-out", certificateFile,
"-subj", "/CN=127.0.0.1",
"-addext", "subjectAltName=IP:127.0.0.1",
"-days", "1",
], { stdio: "ignore" });
if (result.status !== 0) throw safeError("oidc_fixture_certificate_generation_failed");
chmodSync(keyFile, 0o600);
chmodSync(certificateFile, 0o600);
return { key: readFileSync(keyFile), cert: readFileSync(certificateFile), certificateFile };
}
function sendJson(reply, status, value) {
reply.writeHead(status, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
});
reply.end(JSON.stringify(value));
}
function redirect(reply, location) {
reply.writeHead(302, { "cache-control": "no-store", location });
reply.end();
}
async function requestBody(request) {
let size = 0;
const chunks = [];
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw safeError("oidc_fixture_request_too_large");
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
function jwt(privateKey, issuer, audience, nonce, identity) {
const claims = identityClaims[identity];
const now = Math.floor(Date.now() / 1_000);
const payload = {
iss: issuer,
sub: claims.subject,
aud: audience,
exp: now + (claims.expired ? -30 : 60),
iat: now - 1,
nonce,
name: claims.displayName,
...(Object.hasOwn(claims, "groups") ? { groups: claims.groups } : {}),
};
const header = { alg: "RS256", typ: "JWT", kid: "fixture-rs256" };
const protectedPart = Buffer.from(JSON.stringify(header)).toString("base64url");
const payloadPart = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${protectedPart}.${payloadPart}`;
const signature = signRsa("RSA-SHA256", Buffer.from(signingInput), privateKey).toString("base64url");
return `${signingInput}.${signature}`;
}
function authorizationIdentity(identity) {
if (!VALID_IDENTITIES.has(identity)) throw safeError("oidc_fixture_identity_invalid");
return identity;
}
/**
* Start an HTTPS test provider. The returned telemetry intentionally excludes
* transient authorization codes, browser state, nonces, and token material.
*/
export async function startFakeOidcProvider(options = {}) {
const host = options.host ?? LOOPBACK_HOST;
if (host !== LOOPBACK_HOST) throw safeError("oidc_fixture_loopback_required");
const ownsDirectory = options.directory === undefined;
const directory = options.directory ?? mkdtempSync(join(tmpdir(), "thothii-oidc-fixture-"));
ensurePrivateDirectory(directory);
const certificate = createCertificate(directory);
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const publicJwk = publicKey.export({ format: "jwk" });
const jwks = {
keys: [{ ...publicJwk, alg: "RS256", kid: "fixture-rs256", use: "sig" }],
};
const authorizations = new Map();
const deviceCodes = new Map();
let activeIdentity = authorizationIdentity(options.identity ?? "ordinary");
let lastAuthorization = undefined;
let issuer = undefined;
let baseUrl = undefined;
const server = createServer({ key: certificate.key, cert: certificate.cert }, async (request, reply) => {
try {
if (!issuer || !baseUrl || !request.url) {
sendJson(reply, 503, { error: "temporarily_unavailable" });
return;
}
const url = new URL(request.url, baseUrl);
const path = url.pathname;
const discoveryPath = `${ISSUER_PATH}/.well-known/openid-configuration`;
const rfc8414Path = `/.well-known/openid-configuration${ISSUER_PATH}`;
if (request.method === "GET" && (path === discoveryPath || path === rfc8414Path)) {
sendJson(reply, 200, {
issuer,
authorization_endpoint: `${issuer}authorize`,
token_endpoint: `${issuer}token`,
jwks_uri: `${issuer}jwks`,
device_authorization_endpoint: `${issuer}device_authorization`,
response_types_supported: ["code"],
subject_types_supported: ["public"],
grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
code_challenge_methods_supported: ["S256"],
id_token_signing_alg_values_supported: ["RS256"],
});
return;
}
if (request.method === "GET" && path === `${ISSUER_PATH}/jwks`) {
sendJson(reply, 200, jwks);
return;
}
if (request.method === "GET" && path === `${ISSUER_PATH}/authorize`) {
const redirectUri = url.searchParams.get("redirect_uri");
const clientId = url.searchParams.get("client_id");
const state = url.searchParams.get("state");
const nonce = url.searchParams.get("nonce");
const challenge = url.searchParams.get("code_challenge");
if (url.searchParams.get("response_type") !== "code" || !redirectUri || !clientId || !state || !nonce
|| !challenge || url.searchParams.get("code_challenge_method") !== "S256") {
sendJson(reply, 400, { error: "invalid_request" });
return;
}
let callback;
try {
callback = new URL(redirectUri);
if (callback.protocol !== "http:" || callback.hostname !== LOOPBACK_HOST || callback.username || callback.password) {
throw safeError("oidc_fixture_redirect_invalid");
}
} catch {
sendJson(reply, 400, { error: "invalid_request" });
return;
}
const code = randomBytes(32).toString("base64url");
authorizations.set(code, {
challenge,
clientId,
identity: activeIdentity,
nonce,
used: false,
});
lastAuthorization = { identity: activeIdentity, codeChallengeMethod: "S256", pkceVerified: false };
callback.searchParams.set("code", code);
callback.searchParams.set("state", state);
redirect(reply, callback.href);
return;
}
if (request.method === "POST" && path === `${ISSUER_PATH}/device_authorization`) {
const values = new URLSearchParams(await requestBody(request));
const clientId = values.get("client_id");
if (!clientId) {
sendJson(reply, 400, { error: "invalid_request" });
return;
}
const deviceCode = randomBytes(32).toString("base64url");
const userCode = "FIXTURE-CODE";
deviceCodes.set(deviceCode, { clientId, identity: activeIdentity, nonce: "device", used: false });
sendJson(reply, 200, {
device_code: deviceCode,
user_code: userCode,
verification_uri: `${issuer}device`,
verification_uri_complete: `${issuer}device?user_code=${userCode}`,
expires_in: 60,
interval: 1,
});
return;
}
if (request.method === "POST" && path === `${ISSUER_PATH}/token`) {
const values = new URLSearchParams(await requestBody(request));
const grantType = values.get("grant_type");
let record;
if (grantType === "authorization_code") {
const code = values.get("code") ?? "";
record = authorizations.get(code);
const verifier = values.get("code_verifier") ?? "";
const verified = record !== undefined && !record.used
&& createHash("sha256").update(verifier).digest("base64url") === record.challenge;
if (!verified) {
sendJson(reply, 400, { error: "invalid_grant" });
return;
}
record.used = true;
if (lastAuthorization) lastAuthorization = { ...lastAuthorization, pkceVerified: true };
} else if (grantType === "urn:ietf:params:oauth:grant-type:device_code") {
const deviceCode = values.get("device_code") ?? "";
record = deviceCodes.get(deviceCode);
if (record === undefined || record.used) {
sendJson(reply, 400, { error: "invalid_grant" });
return;
}
record.used = true;
} else {
sendJson(reply, 400, { error: "unsupported_grant_type" });
return;
}
sendJson(reply, 200, {
access_token: randomBytes(32).toString("base64url"),
token_type: "Bearer",
expires_in: 60,
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity),
});
return;
}
if (request.method === "GET" && path === "/api/v3/core/groups/") {
if (!request.headers.authorization?.startsWith("Bearer ")) {
sendJson(reply, 401, { detail: "authentication required" });
return;
}
const name = url.searchParams.get("name") ?? "";
const present = name === "fixture-users" || name === "fixture-admin";
sendJson(reply, 200, {
pagination: { next: null },
results: present ? [{ name }] : [],
});
return;
}
sendJson(reply, 404, { error: "not_found" });
} catch {
if (!reply.headersSent) sendJson(reply, 400, { error: "invalid_request" });
else reply.end();
}
});
try {
await new Promise((resolveListen, rejectListen) => {
const onError = (error) => rejectListen(error);
server.once("error", onError);
server.listen({ host, port: options.port ?? 0 }, () => {
server.off("error", onError);
resolveListen();
});
});
} catch (error) {
server.close();
if (ownsDirectory) rmSync(directory, { recursive: true, force: true });
throw error;
}
const address = server.address();
if (!address || typeof address === "string") {
await new Promise((resolveClose) => server.close(resolveClose));
if (ownsDirectory) rmSync(directory, { recursive: true, force: true });
throw safeError("oidc_fixture_listen_failed");
}
baseUrl = `https://${LOOPBACK_HOST}:${address.port}`;
issuer = `${baseUrl}${ISSUER_PATH}/`;
return {
baseUrl,
issuer,
caFile: certificate.certificateFile,
setIdentity(identity) {
activeIdentity = authorizationIdentity(identity);
},
lastAuthorization() {
return lastAuthorization === undefined ? undefined : { ...lastAuthorization };
},
async close() {
await new Promise((resolveClose) => server.close(resolveClose));
if (ownsDirectory) rmSync(directory, { recursive: true, force: true });
},
};
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && resolve(process.argv[1]) === currentFile) {
const provider = await startFakeOidcProvider();
process.stdout.write('{"status":"ready"}\n');
const close = async () => {
await provider.close();
process.exit(0);
};
process.once("SIGINT", () => { void close(); });
process.once("SIGTERM", () => { void close(); });
}
@@ -4,14 +4,18 @@ This is a release-gate checklist, not evidence. Use one ordinary PSD test identi
PSD test identity supplied through the approved test-identity process. Record only sanitized
pass/fail results, timestamps, build identity, and diagnostic codes. Do not record names, internal
URLs, directory/LDAP details, tokens, passwords, hashes, cookies, or realistic secret examples.
Keep the retained result under `.artifacts/manual-acceptance/authentication/<run-id>/` with a
sanitized digest. Do not retain raw browser traces, Compose environments, provider exports, or
unbounded logs. If the approved identities or access are unavailable, record **PENDING** rather
than inferring a PASS.
## Preconditions and ordering
1. Resolve the two parked Task 13 restore preconditions before certification: acquire the lifecycle
lock before any target-dependent preflight and stage/revalidate archive bytes and hashes inside
that lock immediately before extraction; replace convention-only
`createWithDependenciesLockHeld` with an opaque installation-bound transaction capability or
closure so lock-held primitives cannot be called without the capability.
1. Confirm retained Task 13 evidence for the restore prerequisites before certification: the
lifecycle lock is acquired before target-dependent preflight, archive bytes and hashes are
staged/revalidated inside that lock immediately before extraction, and checkpointing requires
an opaque installation-bound transaction capability. Manual acceptance never substitutes for
those automated concurrency and mutation tests.
2. Run Workspace Validate first; it is the static authentication gate. Run `tht auth check` for
live non-interactive diagnosis, then `tht auth check --interactive` where Device Authorization
is available, then Workspace Test for aggregate live validation.
@@ -42,9 +46,13 @@ URLs, directory/LDAP details, tokens, passwords, hashes, cookies, or realistic s
| Provider outage | Live check reports `oidc_discovery_unreachable`; browser login fails closed without exposing credentials. |
| Restore is completed | Sessions and OIDC state are absent; all users must reauthenticate. |
## Status at Task 14
## Status at Task 15
Documentation and deterministic contract checks are the Task 14 scope. Browser OIDC callback E2E,
native Windows behavioral execution, the two parked restore-lock preconditions above, PSD/manual
identities, and any external L2 execution remain pending Task 15/release gates. Do not mark this
matrix PASS until those gates have actual retained evidence.
The hermetic browser suite now covers the loopback provider discovery/JWKS/device/group-list
surface and the complete OIDC Authorization Code + PKCE callback, including direct `groups`
fail-closed cases. It also covers local ordinary, remembered/restart, logout, and administrator
flows. This deterministic evidence does not replace the manual PSD/AuthentiK acceptance.
Native Windows behavioral execution, approved PSD/AuthentiK identities and access, interactive
device acceptance, and external L2 remain **PENDING** until actual retained evidence exists. Do
not mark the feature or this matrix release-complete while any required gate remains pending.
+155
View File
@@ -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
View File
@@ -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);
});
+521
View File
@@ -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;
}
}
+1 -37
View File
@@ -1,50 +1,14 @@
import { defineConfig, devices } from "@playwright/test";
import { resolve } from "node:path";
const __dir = new URL(".", import.meta.url).pathname;
const fakePi = resolve(__dir, "e2e/fixtures/fake-pi.mjs");
const fakeTht = resolve(__dir, "e2e/fixtures/fake-tht.mjs");
const harnessDir = resolve(__dir, "..", "harness");
export default defineConfig({
testDir: "./e2e",
timeout: 60_000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: "http://localhost:5199",
},
workers: 1,
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: [
{
// Backend — wired to both fakes; no VPN/Pi/Python required.
command: "npm run dev",
cwd: resolve(__dir, "..", "backend"),
url: "http://localhost:8799/health",
timeout: 30_000,
reuseExistingServer: !process.env.CI,
env: {
PI_BIN: fakePi,
THT_BIN: fakeTht,
THT_HARNESS_DIR: harnessDir,
PORT: "8799",
AUTH_MODE: "none",
},
},
{
// Frontend dev server pointing at the hermetic backend.
command: "npm run dev -- --port 5199",
cwd: __dir,
url: "http://localhost:5199",
timeout: 30_000,
reuseExistingServer: !process.env.CI,
env: {
VITE_BACKEND_URL: "http://localhost:8799",
},
},
],
});
+4 -1
View File
@@ -13,11 +13,14 @@ def test_local_compose_uses_the_generic_external_endpoint_contract():
}
# workspace-maintenance is profile-gated: it must not be part of the default local startup.
assert compose["services"]["workspace-maintenance"].get("profiles") == ["workspace-maintenance"]
assert local["services"]["core"]["environment"]["AUTH_MODE"] == "none"
# Authentication is controlled by the mounted configuration; the local profile
# must not silently re-enable the legacy unauthenticated development mode.
assert "AUTH_MODE" not in local["services"]["core"]["environment"]
assert local["services"]["core"]["ports"] == ["127.0.0.1:${THOTH_CORE_HTTP_PORT:-8787}:8787"]
assert local["services"]["frontend"]["ports"] == ["127.0.0.1:${THOTH_HTTP_PORT:-8080}:8080"]
environment = compose["services"]["core"]["environment"]
assert {"THT_AUTH_CONFIG_FILE", "THT_AUTH_STATE_ROOT"} <= set(environment)
for name in ("THT_DWH_REST_URL", "THT_LLM_URL"):
assert name in environment
assert environment["THT_INTERNAL_QDRANT_URL"] == "http://qdrant:6333"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
expected_node=${THT_EXPECTED_NODE_VERSION:-v24.16.0}
actual_node=$(node --version)
if [[ "$actual_node" != "$expected_node" ]]; then
printf 'authentication smoke requires Node %s; found %s\n' "$expected_node" "$actual_node" >&2
exit 2
fi
for command in go openssl; do
command -v "$command" >/dev/null 2>&1 || {
printf 'authentication smoke requires %s\n' "$command" >&2
exit 2
}
done
playwright="$root/frontend/node_modules/.bin/playwright"
[[ -x "$playwright" ]] || {
echo "authentication smoke requires frontend dependencies (run npm ci in frontend)" >&2
exit 2
}
temporary_root=$(mktemp -d "${TMPDIR:-/tmp}/thothii-authentication-smoke.XXXXXX")
trap 'rm -rf "$temporary_root"' EXIT HUP INT TERM
log="$temporary_root/playwright.log"
output="$temporary_root/playwright"
mkdir -p "$output"
sentinel="task15-sentinel-$(openssl rand -hex 24)"
export THT_TASK15_SENTINEL="$sentinel"
sanitize_failure_log() {
sed -E \
-e "s/${sentinel}/[redacted]/g" \
-e 's#https?://[^[:space:])]+#[url]#g' \
-e 's/(THT_[A-Z_]+|PI_[A-Z_]+|AUTH_MODE)=[^[:space:]]+/\1=[redacted]/g' \
"$log" | tail -n 100 >&2
}
if ! (
cd "$root/frontend"
THT_E2E_AUTH_STACK=1 "$playwright" test e2e/auth.spec.ts --workers=1 --output="$output"
) >"$log" 2>&1; then
echo "authentication smoke: hermetic browser suite failed" >&2
sanitize_failure_log
exit 1
fi
if rg -a -Fq -- "$sentinel" "$log" "$output"; then
echo "authentication smoke: sentinel appeared in retained test output" >&2
exit 1
fi
printf 'authentication smoke: hermetic OIDC/browser suite passed on Node %s\n' "$actual_node"
@@ -18,10 +18,56 @@ trap cleanup EXIT HUP INT TERM
printf '%s\n' '{"zai":{"type":"api_key","key":"fixture-native-auth-key"}}' >"$tmp/pi-auth.json"
printf '%s\n' 'THT_MODEL_API_KEY=fixture-model-api-key' >"$tmp/thothii.secrets"
chmod 0600 "$tmp/pi-auth.json" "$tmp/thothii.secrets"
auth_config="$tmp/auth"
mkdir "$auth_config"
chmod 0700 "$auth_config"
printf '%s\n' \
'version: 1' \
'mode: local' \
'publicUrl: http://127.0.0.1:8080' \
'local:' \
' usersFile: users.yaml' \
>"$auth_config/auth.yaml"
node - "$auth_config/users.yaml" <<'NODE'
const { argon2 } = require("node:crypto");
const { writeFileSync } = require("node:fs");
const message = Buffer.from("fixture-local-password", "utf8");
const nonce = Buffer.from([...Array(16).keys()]);
argon2("argon2id", {
message,
nonce,
memory: 65_536,
parallelism: 1,
tagLength: 32,
passes: 3,
}, (error, digest) => {
message.fill(0);
nonce.fill(0);
if (error || !digest) throw error ?? new Error("fixture password hash failed");
const salt = Buffer.from([...Array(16).keys()]).toString("base64").replaceAll("=", "");
const hash = digest.toString("base64").replaceAll("=", "");
writeFileSync(process.argv[2], [
"version: 1",
"users:",
" - id: 00000000-0000-4000-8000-000000000001",
" username: fixture-user",
" displayName: Fixture user",
` passwordHash: $argon2id$v=19$m=65536,t=3,p=1$${salt}$${hash}`,
" roles:",
" - user",
" enabled: true",
" authRevision: 1",
"",
].join("\\n"), { mode: 0o600 });
});
NODE
chmod 0600 "$auth_config/auth.yaml" "$auth_config/users.yaml"
printf '%s\n' \
'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \
"PI_AUTH_FILE=$tmp/pi-auth.json" \
"THT_SECRETS_FILE=$tmp/thothii.secrets" \
"THT_AUTH_CONFIG_ROOT=$auth_config" \
'THOTH_CORE_HTTP_PORT=0' \
'THOTH_HTTP_PORT=0' \
>"$tmp/local.env"
+8 -1
View File
@@ -8,12 +8,18 @@ trap 'rm -rf "$tmp"' EXIT HUP INT TERM
auth_file="$tmp/auth.json"
printf '%s\n' '{}' >"$auth_file"
chmod 0600 "$auth_file"
auth_config="$tmp/auth"
mkdir "$auth_config"
chmod 0700 "$auth_config"
printf '%s\n' 'mode: local' >"$auth_config/auth.yaml"
chmod 0600 "$auth_config/auth.yaml"
secrets_file="$tmp/thothii.secrets"
printf '%s\n' 'THT_MODEL_API_KEY=fixture-model-api-key' >"$secrets_file"
chmod 0600 "$secrets_file"
rendered=$(THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git \
PI_AUTH_FILE="$auth_file" THT_SECRETS_FILE="$secrets_file" docker compose config)
PI_AUTH_FILE="$auth_file" THT_SECRETS_FILE="$secrets_file" \
THT_AUTH_CONFIG_ROOT="$auth_config" docker compose config)
printf '%s\n' "$rendered" | grep -q "source: $auth_file"
printf '%s\n' "$rendered" | grep -q 'target: /home/thoth/.pi/agent/auth.json'
printf '%s\n' "$rendered" | grep -A4 'target: /home/thoth/.pi/agent/auth.json' \
@@ -33,6 +39,7 @@ fi
dev_rendered=$(THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git \
PI_AUTH_FILE="$auth_file" THT_SECRETS_FILE="$secrets_file" \
THT_AUTH_CONFIG_ROOT="$auth_config" \
docker compose --env-file deploy/env/local.env.example -f docker-compose.dev.yml config)
printf '%s\n' "$dev_rendered" | grep -q "source: $auth_file"
printf '%s\n' "$dev_rendered" | grep -q 'target: /home/thoth/.pi/agent/auth.json'
+6 -1
View File
@@ -5,13 +5,18 @@ cd "$(dirname "$0")/.."
tmp_bundle=$(mktemp)
tmp_auth=$(mktemp)
trap 'rm -f "$tmp_bundle" "$tmp_auth"' EXIT HUP INT TERM
tmp_auth_config=$(mktemp -d)
trap 'rm -f "$tmp_bundle" "$tmp_auth"; rm -rf "$tmp_auth_config"' EXIT HUP INT TERM
printf '%s\n' 'THT_MODEL_API_KEY=test-model' >"$tmp_bundle"
chmod 0600 "$tmp_bundle"
printf '%s\n' '{}' >"$tmp_auth"
chmod 0600 "$tmp_auth"
chmod 0700 "$tmp_auth_config"
printf '%s\n' 'mode: local' >"$tmp_auth_config/auth.yaml"
chmod 0600 "$tmp_auth_config/auth.yaml"
export THT_SECRETS_FILE="$tmp_bundle"
export PI_AUTH_FILE="$tmp_auth"
export THT_AUTH_CONFIG_ROOT="$tmp_auth_config"
export THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git
config_json=$(docker compose -f compose.yaml -f deploy/compose.preprocess.yaml --profile preprocess config --format json)
+6
View File
@@ -9,7 +9,12 @@ fixture="$(mktemp -d "$tmp_parent/thoth-server-pi-state.XXXXXX")"
trap 'rm -rf "$fixture"' EXIT HUP INT TERM
pi_state="$fixture/empty pi state"
auth_config="$fixture/auth"
mkdir -p "$pi_state"
mkdir -p "$auth_config"
chmod 0700 "$auth_config"
printf 'mode: local\n' >"$auth_config/auth.yaml"
chmod 0600 "$auth_config/auth.yaml"
"$root/scripts/prepare-server-pi-state.sh" "$pi_state" "$(id -u)" "$(id -g)"
for target in auth.json models.json settings.json; do
@@ -40,6 +45,7 @@ THOTH_SERVER_BIND=127.0.0.1
THOTH_HTTP_PORT=0
PI_AUTH_FILE=$fixture/pi-auth.json
THT_SECRETS_FILE=$fixture/thothii.secrets
THT_AUTH_CONFIG_ROOT=$auth_config
THT_DATA_ROOT=$fixture/data
THT_PI_STATE_ROOT=$pi_state
THT_WORKSPACE_REGISTRY_ROOT=$fixture/workspace-registry
+16 -7
View File
@@ -127,23 +127,32 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
if request.IncludeSecrets && !request.Confirm {
return Result{}, ErrConfirmationRequired
}
lock, err := lifecycle.Acquire(installation)
transaction, err := lifecycle.AcquireTransaction(installation)
if err != nil {
return Result{}, err
}
defer func() {
if releaseErr := lock.Release(); releaseErr != nil {
if releaseErr := transaction.Release(); releaseErr != nil {
result = Result{}
resultErr = errors.Join(resultErr, fmt.Errorf("release backup lifecycle lock: %w", releaseErr))
}
}()
return createWithDependenciesLockHeld(ctx, installation, request, dependencies)
return createWithDependenciesTransaction(ctx, transaction, installation, request, dependencies)
}
// createWithDependenciesLockHeld performs backup creation while the caller owns the installation
// lifecycle lock. It must never acquire a lifecycle lock itself: Restore uses this primitive to
// create its recovery checkpoint inside its already-locked transaction.
func createWithDependenciesLockHeld(ctx context.Context, installation config.Installation, request CreateRequest, dependencies dependencies) (result Result, resultErr error) {
// createWithDependenciesTransaction performs backup creation only with an active, opaque
// installation-bound lifecycle capability. Restore passes the capability it acquired for the
// enclosing transaction, so a recovery checkpoint cannot run without the same lifecycle lock.
func createWithDependenciesTransaction(ctx context.Context, transaction *lifecycle.Transaction, installation config.Installation, request CreateRequest, dependencies dependencies) (result Result, resultErr error) {
if err := transaction.Verify(installation); err != nil {
return Result{}, err
}
if dependencies.runner == nil || dependencies.now == nil || dependencies.homeDir == nil || dependencies.revision == nil || dependencies.sleep == nil || dependencies.reserveOutput == nil || dependencies.publishReserved == nil {
return Result{}, errors.New("backup dependencies are incomplete")
}
if request.IncludeSecrets && !request.Confirm {
return Result{}, ErrConfirmationRequired
}
if err := rejectInlineSecretValues(installation.EnvFile); err != nil {
return Result{}, err
}
+27
View File
@@ -537,6 +537,33 @@ func TestCreateHonorsTheSharedInstallationLifecycleLock(t *testing.T) {
}
}
func TestCreateTransactionCapabilityRefusesUnlockedOrForeignInstallation(t *testing.T) {
fixture := newBackupFixture(t, "local")
runner := newBackupRunner(fixture.installation, false)
request := CreateRequest{Output: filepath.Join(t.TempDir(), "transaction.zip")}
if _, err := createWithDependenciesTransaction(context.Background(), nil, fixture.installation, request, testDependencies(t, runner)); !errors.Is(err, lifecycle.ErrTransactionInactive) {
t.Fatalf("nil transaction error = %v, want ErrTransactionInactive", err)
}
if len(runner.calls) != 0 {
t.Fatalf("Docker runner was called without a transaction capability: %v", runner.calls)
}
otherRoot := t.TempDir()
other := config.Installation{ProjectDirectory: otherRoot, Path: filepath.Join(otherRoot, "thothii-installation.yaml")}
transaction, err := lifecycle.AcquireTransaction(other)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = transaction.Release() })
if _, err := createWithDependenciesTransaction(context.Background(), transaction, fixture.installation, request, testDependencies(t, runner)); !errors.Is(err, lifecycle.ErrTransactionInstallation) {
t.Fatalf("foreign transaction error = %v, want ErrTransactionInstallation", err)
}
if len(runner.calls) != 0 {
t.Fatalf("Docker runner was called with a foreign transaction capability: %v", runner.calls)
}
}
func TestCreateRefusesMutableNonRunningServiceStates(t *testing.T) {
for _, state := range []string{"paused", "restarting", "created", "removing"} {
t.Run(state, func(t *testing.T) {
+122 -3
View File
@@ -91,6 +91,14 @@ type verifiedArchive struct {
limits PreflightLimits
}
// stagedArchive holds an installation-private, immutable copy of the exact bytes accepted by
// Preflight. The original archive remains retained only for provenance revalidation.
type stagedArchive struct {
file *os.File
path string
directory string
}
type inspectedArchiveEntry struct {
metadata ArchiveEntryMetadata
member *zip.File
@@ -176,12 +184,17 @@ func Preflight(ctx context.Context, installation config.Installation, request Pr
if err != nil {
return PreflightResult{}, err
}
stagingBytes := uint64(openedInfo.Size())
if stagingBytes > ^uint64(0)-requiredBytes {
return PreflightResult{}, errors.New("restore staging requirement exceeds supported size")
}
requiredWithStaging := requiredBytes + stagingBytes
freeBytes, err := dependencies.FreeBytes(installation.ProjectDirectory)
if err != nil {
return PreflightResult{}, fmt.Errorf("check free disk space: %w", err)
}
if freeBytes < requiredBytes {
return PreflightResult{}, fmt.Errorf("insufficient free disk space for restore: need %d bytes, have %d", requiredBytes, freeBytes)
if freeBytes < requiredWithStaging {
return PreflightResult{}, fmt.Errorf("insufficient free disk space for restore: need %d bytes, have %d", requiredWithStaging, freeBytes)
}
if err := contextError(ctx); err != nil {
return PreflightResult{}, err
@@ -214,6 +227,10 @@ func Preflight(ctx context.Context, installation config.Installation, request Pr
// it immediately before a restore transaction and use the returned retained handle, never reopen
// ArchivePath. It refuses a path replacement or in-place content change.
func (result PreflightResult) RevalidateArchive() (*os.File, error) {
return result.revalidateArchive(context.Background())
}
func (result PreflightResult) revalidateArchive(ctx context.Context) (*os.File, error) {
if result.archive == nil || result.archive.file == nil {
return nil, errors.New("backup archive has not been retained by preflight")
}
@@ -228,13 +245,115 @@ func (result PreflightResult) RevalidateArchive() (*os.File, error) {
if err != nil || !os.SameFile(result.archive.info, heldInfo) || heldInfo.Size() != result.ArchiveSize {
return nil, errors.New("backup archive changed after preflight")
}
digest, err := digestArchive(context.Background(), result.archive.file, result.archive.limits.MaxArchiveBytes)
digest, err := digestArchive(ctx, result.archive.file, result.archive.limits.MaxArchiveBytes)
if err != nil || digest != result.archive.digest {
return nil, errors.New("backup archive changed after preflight")
}
return result.archive.file, nil
}
// StageArchive revalidates the retained archive and copies its exact bytes into a private file
// immediately before extraction. Later writes to the source archive cannot affect extraction.
func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchive, resultErr error) {
source, err := result.revalidateArchive(ctx)
if err != nil {
return nil, err
}
directory, err := os.MkdirTemp("", "tht-restore-stage-")
if err != nil {
return nil, errors.New("create private restore staging directory")
}
if err := os.Chmod(directory, 0o700); err != nil {
_ = os.Remove(directory)
return nil, errors.New("protect private restore staging directory")
}
path := filepath.Join(directory, "archive.zip")
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
_ = os.Remove(directory)
return nil, errors.New("create private restore staging archive")
}
staged := &stagedArchive{file: file, path: path, directory: directory}
completed := false
defer func() {
if !completed {
_ = staged.Close()
}
}()
if _, err := source.Seek(0, io.SeekStart); err != nil {
return nil, errors.New("seek verified backup archive for staging")
}
digest := sha256.New()
buffer := make([]byte, 128*1024)
var total int64
for {
if err := contextError(ctx); err != nil {
return nil, err
}
count, readErr := source.Read(buffer)
if count > 0 {
if int64(count) > result.ArchiveSize-total {
return nil, errors.New("backup archive changed after preflight")
}
written, writeErr := file.Write(buffer[:count])
if writeErr != nil || written != count {
return nil, errors.New("write private restore staging archive")
}
if _, writeErr := digest.Write(buffer[:count]); writeErr != nil {
return nil, errors.New("hash private restore staging archive")
}
total += int64(count)
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return nil, errors.New("read verified backup archive for staging")
}
}
if total != result.ArchiveSize || digestForHash(digest) != result.archive.digest {
return nil, errors.New("backup archive changed after preflight")
}
if err := file.Sync(); err != nil {
return nil, errors.New("sync private restore staging archive")
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, errors.New("rewind private restore staging archive")
}
completed = true
return staged, nil
}
// Close removes only the staging file and directory created by StageArchive.
func (staged *stagedArchive) Close() error {
if staged == nil {
return nil
}
var failed bool
if staged.file != nil {
if err := staged.file.Close(); err != nil {
failed = true
}
staged.file = nil
}
if staged.path != "" {
if err := os.Remove(staged.path); err != nil && !errors.Is(err, os.ErrNotExist) {
failed = true
}
staged.path = ""
}
if staged.directory != "" {
if err := os.Remove(staged.directory); err != nil && !errors.Is(err, os.ErrNotExist) {
failed = true
}
staged.directory = ""
}
if failed {
return errors.New("destroy private restore staging archive")
}
return nil
}
// CloseArchive releases the retained read-only archive handle after the caller finishes the
// restore transaction or decides not to proceed.
func (result PreflightResult) CloseArchive() error {
@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strings"
@@ -356,6 +357,79 @@ func TestPreflightRevalidationRefusesAnArchivePathThatWasReplaced(t *testing.T)
}
}
func TestPreflightStagesArchiveIntoImmutablePrivateBytes(t *testing.T) {
installation := preflightTestInstallation(t)
archive := filepath.Join(t.TempDir(), "checked.zip")
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("before")}},
})
result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies())
if err != nil {
t.Fatal(err)
}
defer result.CloseArchive()
staged, err := result.StageArchive(context.Background())
if err != nil {
t.Fatal(err)
}
defer staged.Close()
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("after!")}},
})
reader, err := zip.NewReader(staged.file, result.ArchiveSize)
if err != nil {
t.Fatal(err)
}
if len(reader.File) < 2 {
t.Fatalf("staged archive members = %d, want manifest and payload", len(reader.File))
}
var payload *zip.File
for _, member := range reader.File {
if member.Name == "configuration/operator.env" {
payload = member
break
}
}
if payload == nil {
t.Fatal("staged archive is missing the configured payload")
}
stream, err := payload.Open()
if err != nil {
t.Fatal(err)
}
defer stream.Close()
body, err := io.ReadAll(stream)
if err != nil {
t.Fatal(err)
}
if string(body) != "before" {
t.Fatalf("staged payload = %q, want preflighted bytes", body)
}
}
func TestPreflightStagingRejectsInPlaceArchiveHashMutation(t *testing.T) {
installation := preflightTestInstallation(t)
archive := filepath.Join(t.TempDir(), "checked.zip")
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("before")}},
})
result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies())
if err != nil {
t.Fatal(err)
}
defer result.CloseArchive()
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("after!")}},
})
if _, err := result.StageArchive(context.Background()); err == nil || !strings.Contains(err.Error(), "changed") {
t.Fatalf("StageArchive() error = %v, want changed archive refusal", err)
}
}
func TestPreflightRejectsArchiveEntryWithModeDifferentFromManifest(t *testing.T) {
installation := preflightTestInstallation(t)
archive := filepath.Join(t.TempDir(), "mode-mismatch.zip")
+26 -21
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
)
var ErrRestoreConfirmationRequired = errors.New("restore requires --yes")
@@ -29,19 +30,17 @@ type RestoreResult struct {
Verified bool
}
type restoreLock interface{ Release() error }
type restoreVerify func(context.Context, config.Installation, archiveRunner) error
type restoreDependencies struct {
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
// checkpointLocked creates the secret-aware recovery archive while the caller already owns
// the installation lifecycle lock. It must not call public Create, which would re-acquire the
// non-reentrant lock and deadlock the restore transaction.
checkpointLocked func(context.Context, config.Installation, CreateRequest) (Result, error)
// checkpoint requires the opaque capability created by lifecycle acquisition. It must not call
// public Create, which would re-acquire the non-reentrant lock and deadlock the transaction.
checkpoint func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error)
prepareRecovery func(context.Context, config.Installation, string) (PreflightResult, error)
recover func(context.Context, config.Installation, PreflightResult, bool) error
cleanupCheckpoint func(string) error
acquireLock func(config.Installation) (restoreLock, error)
acquireTransaction func(config.Installation) (*lifecycle.Transaction, error)
runner archiveRunner
sleep func(duration time.Duration)
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
@@ -84,36 +83,32 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
if request.Archive == "" {
return RestoreResult{}, errors.New("restore archive is required")
}
if deps.preflight == nil || deps.checkpointLocked == nil || deps.prepareRecovery == nil || deps.recover == nil || deps.cleanupCheckpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil {
if deps.preflight == nil || deps.checkpoint == nil || deps.prepareRecovery == nil || deps.recover == nil || deps.cleanupCheckpoint == nil || deps.acquireTransaction == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil {
return RestoreResult{}, errors.New("restore dependencies are incomplete")
}
preflight, err := deps.preflight(ctx, installation, PreflightRequest{Archive: request.Archive, Confirm: true, AllowExternalSecrets: true})
if err != nil {
return RestoreResult{}, err
}
archive, err := preflight.RevalidateArchive()
if err != nil {
_ = preflight.CloseArchive()
return RestoreResult{}, err
}
// Lock ordering is lifecycle lock -> Compose/operator maintenance barrier. The core operator
// command never acquires the host lifecycle lock, so this order cannot form a lock cycle with
// Docker Compose or the durable maintenance marker.
lock, err := deps.acquireLock(installation)
transaction, err := deps.acquireTransaction(installation)
if err != nil {
_ = preflight.CloseArchive()
return result, err
}
defer func() {
if releaseErr := lock.Release(); releaseErr != nil {
if releaseErr := transaction.Release(); releaseErr != nil {
result = RestoreResult{}
resultErr = errors.Join(resultErr, fmt.Errorf("release restore lifecycle lock: %w", releaseErr))
}
}()
// Target-dependent preflight is intentionally inside the lifecycle transaction. This binds
// target ownership, volume, image, and free-space checks to the later mutation.
preflight, err := deps.preflight(ctx, installation, PreflightRequest{Archive: request.Archive, Confirm: true, AllowExternalSecrets: true})
if err != nil {
return RestoreResult{}, err
}
defer preflight.CloseArchive()
checkpoint, err := deps.checkpointLocked(ctx, installation, CreateRequest{IncludeSecrets: true, Confirm: true})
checkpoint, err := deps.checkpoint(ctx, transaction, installation, CreateRequest{IncludeSecrets: true, Confirm: true})
if err != nil {
return result, fmt.Errorf("create recovery checkpoint: %w", err)
}
@@ -217,8 +212,18 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
return result, err
}
}
staged, err := preflight.StageArchive(ctx)
if err != nil {
return result, err
}
defer func() {
if closeErr := staged.Close(); closeErr != nil {
result = RestoreResult{}
resultErr = errors.Join(resultErr, closeErr)
}
}()
state.mutated = true
if err := restoreVerifiedEntries(ctx, installation, preflight, archive, deps.restoreFile, deps.restoreVolume); err != nil {
if err := restoreVerifiedEntries(ctx, installation, preflight, staged.file, deps.restoreFile, deps.restoreVolume); err != nil {
return result, err
}
if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil {
+15 -13
View File
@@ -41,21 +41,19 @@ func productionRestoreDependencies(installation config.Installation) restoreDepe
},
})
},
checkpointLocked: func(ctx context.Context, target config.Installation, request CreateRequest) (Result, error) {
checkpoint: func(ctx context.Context, transaction *lifecycle.Transaction, target config.Installation, request CreateRequest) (Result, error) {
path, err := restoreCheckpointPath(target, time.Now().UTC())
if err != nil {
return Result{}, err
}
request.Output = path
return createWithDependenciesLockHeld(ctx, target, request, productionCreateDependencies(target))
return createWithDependenciesTransaction(ctx, transaction, target, request, productionCreateDependencies(target))
},
cleanupCheckpoint: cleanupRecoveryCheckpoint,
acquireLock: func(target config.Installation) (restoreLock, error) {
return lifecycle.Acquire(target)
},
runner: runner,
sleep: time.Sleep,
restoreFile: restoreFilePayload,
cleanupCheckpoint: cleanupRecoveryCheckpoint,
acquireTransaction: lifecycle.AcquireTransaction,
runner: runner,
sleep: time.Sleep,
restoreFile: restoreFilePayload,
restoreVolume: func(ctx context.Context, _ config.Installation, volume VolumeMetadata, input io.Reader) error {
result, err := runner.Stream(ctx, volumeRestoreCommand(volume.Name), input, io.Discard)
if err != nil || result.ExitCode != 0 {
@@ -90,8 +88,7 @@ func cleanupRecoveryCheckpoint(path string) error {
return nil
}
func recoverRestoreTransaction(ctx context.Context, installation config.Installation, recovery PreflightResult, wasRunning bool, deps restoreDependencies) error {
var resultErr error
func recoverRestoreTransaction(ctx context.Context, installation config.Installation, recovery PreflightResult, wasRunning bool, deps restoreDependencies) (resultErr error) {
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil {
resultErr = errors.Join(resultErr, err)
// The first stop may already have taken effect before Docker lost its response. Retry the
@@ -100,11 +97,16 @@ func recoverRestoreTransaction(ctx context.Context, installation config.Installa
return errors.Join(resultErr, retryErr)
}
}
archive, err := recovery.RevalidateArchive()
staged, err := recovery.StageArchive(ctx)
if err != nil {
return errors.Join(resultErr, err)
}
if err := restoreVerifiedEntries(ctx, installation, recovery, archive, deps.restoreFile, deps.restoreVolume); err != nil {
defer func() {
if closeErr := staged.Close(); closeErr != nil {
resultErr = errors.Join(resultErr, closeErr)
}
}()
if err := restoreVerifiedEntries(ctx, installation, recovery, staged.file, deps.restoreFile, deps.restoreVolume); err != nil {
return errors.Join(resultErr, err)
}
// Recovery restores only configuration/secret files and durable application volumes. Runtime
+101 -46
View File
@@ -120,7 +120,7 @@ func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testi
var events []string
var checkpointRequest CreateRequest
deps := restoreTestDependencies(t, runner)
deps.checkpointLocked = func(_ context.Context, _ config.Installation, request CreateRequest) (Result, error) {
deps.checkpoint = func(_ context.Context, _ *lifecycle.Transaction, _ config.Installation, request CreateRequest) (Result, error) {
events = append(events, "checkpoint")
checkpointRequest = request
return Result{Path: "/tmp/checkpoint.zip"}, nil
@@ -133,9 +133,9 @@ func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testi
events = append(events, "cleanup-checkpoint")
return nil
}
deps.acquireLock = func(config.Installation) (restoreLock, error) {
deps.acquireTransaction = func(target config.Installation) (*lifecycle.Transaction, error) {
events = append(events, "lock")
return fakeRestoreLock{release: func() { events = append(events, "unlock") }}, nil
return lifecycle.AcquireTransaction(target)
}
deps.restoreFile = func(_ context.Context, _ config.Installation, entry ArchiveEntryMetadata, _ io.Reader) error {
events = append(events, "file:"+entry.Path)
@@ -159,9 +159,12 @@ func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testi
if !checkpointRequest.IncludeSecrets || !checkpointRequest.Confirm {
t.Fatalf("checkpoint request = %#v, want private confirmed secret-aware checkpoint", checkpointRequest)
}
if got, want := events, []string{"lock", "checkpoint", "prepare-recovery", "file:configuration/operator.env", "health", "doctor", "pi", "workspace", "cleanup-checkpoint", "unlock"}; !equalStrings(got, want) {
if got, want := events, []string{"lock", "checkpoint", "prepare-recovery", "file:configuration/operator.env", "health", "doctor", "pi", "workspace", "cleanup-checkpoint"}; !equalStrings(got, want) {
t.Fatalf("restore events = %v, want %v", got, want)
}
if err := lifecycleLockFreeAfterTerminalRestore(installation); err != nil {
t.Fatal(err)
}
}
func TestRestoreClosesTargetArchiveBeforeReleasingLifecycleLock(t *testing.T) {
@@ -179,22 +182,85 @@ func TestRestoreClosesTargetArchiveBeforeReleasingLifecycleLock(t *testing.T) {
target = result
return result, err
}
unlockBeforeTargetClose := false
deps.acquireLock = func(config.Installation) (restoreLock, error) {
return fakeRestoreLock{release: func() {
if target.archive != nil && target.archive.file != nil {
if _, err := target.archive.file.Stat(); err == nil {
unlockBeforeTargetClose = true
}
}
}}, nil
}
deps.acquireTransaction = lifecycle.AcquireTransaction
if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err != nil {
t.Fatal(err)
}
if unlockBeforeTargetClose {
t.Fatal("restore released its lifecycle lock before closing the target archive")
if target.archive != nil && target.archive.file != nil {
if _, err := target.archive.file.Stat(); err == nil {
t.Fatal("restore did not close the target archive")
}
}
if err := lifecycleLockFreeAfterTerminalRestore(installation); err != nil {
t.Fatal(err)
}
}
func TestRestoreAcquiresLifecycleLockBeforeTargetDependentPreflight(t *testing.T) {
installation := preflightTestInstallation(t)
archive := filepath.Join(t.TempDir(), "restore.zip")
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}},
})
runner := newBackupRunner(installation, false)
deps := restoreTestDependencies(t, runner)
deps.acquireTransaction = lifecycle.AcquireTransaction
deps.preflight = func(ctx context.Context, target config.Installation, request PreflightRequest) (PreflightResult, error) {
probe, err := lifecycle.Acquire(target)
if err == nil {
_ = probe.Release()
return PreflightResult{}, errors.New("target-dependent preflight ran before lifecycle lock acquisition")
}
if !errors.Is(err, lifecycle.ErrLocked) {
return PreflightResult{}, fmt.Errorf("probe lifecycle lock: %w", err)
}
return Preflight(ctx, target, request, permissivePreflightDependencies())
}
if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err != nil {
t.Fatalf("Restore() error = %v, want preflight protected by lifecycle lock", err)
}
if err := lifecycleLockFreeAfterTerminalRestore(installation); err != nil {
t.Fatal(err)
}
}
func TestRestoreStagesArchiveAfterCheckpointAndRejectsMutation(t *testing.T) {
installation := preflightTestInstallation(t)
archive := filepath.Join(t.TempDir(), "restore.zip")
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("before")}},
})
runner := newBackupRunner(installation, false)
deps := restoreTestDependencies(t, runner)
deps.acquireTransaction = lifecycle.AcquireTransaction
deps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("after!")}},
})
return Result{Path: filepath.Join(t.TempDir(), "checkpoint.zip")}, nil
}
var restored [][]byte
deps.restoreFile = func(_ context.Context, _ config.Installation, _ ArchiveEntryMetadata, stream io.Reader) error {
body, err := io.ReadAll(stream)
if err != nil {
return err
}
restored = append(restored, body)
return nil
}
if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err == nil || !strings.Contains(err.Error(), "changed") {
t.Fatalf("Restore() error = %v, want archive mutation refusal", err)
}
if len(restored) != 0 {
t.Fatalf("restore applied mutated archive payloads: %q", restored)
}
if err := lifecycleLockFreeAfterTerminalRestore(installation); err != nil {
t.Fatal(err)
}
}
@@ -297,8 +363,8 @@ func TestRestoreLifecycleLockExcludesCompetingTransactionsUntilTerminalCleanup(t
caller, cancel := context.WithCancel(context.Background())
defer cancel()
deps := restoreTestDependencies(t, runner)
deps.acquireLock = func(target config.Installation) (restoreLock, error) { return lifecycle.Acquire(target) }
deps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
deps.acquireTransaction = lifecycle.AcquireTransaction
deps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
gate("checkpoint")
return Result{Path: filepath.Join(t.TempDir(), "checkpoint.zip")}, nil
}
@@ -369,8 +435,8 @@ func competingRestoreAndBackupEntry(installation config.Installation, archive st
checkpointCalled := false
restoreDeps := restoreTestDependencies(t, newBackupRunner(installation, false))
restoreDeps.acquireLock = func(target config.Installation) (restoreLock, error) { return lifecycle.Acquire(target) }
restoreDeps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
restoreDeps.acquireTransaction = lifecycle.AcquireTransaction
restoreDeps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
checkpointCalled = true
return Result{Path: filepath.Join(t.TempDir(), "competing-checkpoint.zip")}, nil
}
@@ -408,8 +474,8 @@ func TestRestoreCannotApplyAStaleCheckpointOverAnInterleavedRestore(t *testing.T
continueCheckpoint := make(chan struct{})
firstRunner := newBackupRunner(installation, true)
firstDeps := restoreTestDependencies(t, firstRunner)
firstDeps.acquireLock = func(target config.Installation) (restoreLock, error) { return lifecycle.Acquire(target) }
firstDeps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
firstDeps.acquireTransaction = lifecycle.AcquireTransaction
firstDeps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
checkpointState = targetState
close(checkpointEntered)
<-continueCheckpoint
@@ -439,8 +505,8 @@ func TestRestoreCannotApplyAStaleCheckpointOverAnInterleavedRestore(t *testing.T
interleavedCheckpoint := false
interleavedMutation := false
interleavedDeps := restoreTestDependencies(t, newBackupRunner(installation, false))
interleavedDeps.acquireLock = func(target config.Installation) (restoreLock, error) { return lifecycle.Acquire(target) }
interleavedDeps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
interleavedDeps.acquireTransaction = lifecycle.AcquireTransaction
interleavedDeps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
interleavedCheckpoint = true
return Result{Path: filepath.Join(t.TempDir(), "interleaved-checkpoint.zip")}, nil
}
@@ -542,7 +608,7 @@ func TestRestorePreflightFailureDoesNotMutateTarget(t *testing.T) {
return PreflightResult{}, preflightErr
}
checkpointCalls := 0
deps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
deps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
checkpointCalls++
return Result{}, nil
}
@@ -567,7 +633,7 @@ func TestRestoreCheckpointFailureDoesNotMutateTarget(t *testing.T) {
runner := newBackupRunner(installation, true)
deps := restoreTestDependencies(t, runner)
checkpointErr := errors.New("checkpoint unavailable")
deps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
deps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
return Result{}, checkpointErr
}
restoredFiles := 0
@@ -591,7 +657,7 @@ func TestRestoreFileFailureRollsBackSecretAwareCheckpointBeforeCleanup(t *testin
runner := newBackupRunner(installation, true)
deps := restoreTestDependencies(t, runner)
fileErr := errors.New("cannot restore operator configuration")
deps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
deps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
return Result{Path: "/tmp/recovery.zip"}, nil
}
var events []string
@@ -1095,7 +1161,7 @@ func TestRestoreReleasesBarrierOnlyAfterVerifiedRecoveryFromLostResponse(t *test
}
runner := &commandFailureRunner{fakeBackupRunner: backing, failures: []*commandFailure{&failure}}
deps := restoreTestDependencies(t, runner)
deps.checkpointLocked = func(context.Context, config.Installation, CreateRequest) (Result, error) {
deps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
return Result{Path: "/tmp/recovery.zip"}, nil
}
deps.prepareRecovery = func(context.Context, config.Installation, string) (PreflightResult, error) {
@@ -1322,10 +1388,6 @@ func (runner *lifecycleGateRunner) SessionInventoryScope() string {
return runner.fakeBackupRunner.SessionInventoryScope()
}
type fakeRestoreLock struct {
release func()
}
type authenticationStateResetRunner struct {
args []string
result compose.Result
@@ -1431,13 +1493,6 @@ func TestVerifyRestoreWorkspaceRejectsInvalidOperatorResults(t *testing.T) {
}
}
func (lock fakeRestoreLock) Release() error {
if lock.release != nil {
lock.release()
}
return nil
}
func equalStrings(got, want []string) bool {
if len(got) != len(want) {
return false
@@ -1456,18 +1511,18 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen
preflight: func(ctx context.Context, installation config.Installation, request PreflightRequest) (PreflightResult, error) {
return Preflight(ctx, installation, request, permissivePreflightDependencies())
},
checkpointLocked: func(context.Context, config.Installation, CreateRequest) (Result, error) {
checkpoint: func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) {
return Result{Path: "/tmp/default-checkpoint.zip"}, nil
},
prepareRecovery: func(context.Context, config.Installation, string) (PreflightResult, error) {
return PreflightResult{}, nil
},
recover: func(context.Context, config.Installation, PreflightResult, bool) error { return nil },
cleanupCheckpoint: func(string) error { return nil },
acquireLock: func(config.Installation) (restoreLock, error) { return fakeRestoreLock{}, nil },
runner: runner,
sleep: func(time.Duration) {},
restoreFile: func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return nil },
recover: func(context.Context, config.Installation, PreflightResult, bool) error { return nil },
cleanupCheckpoint: func(string) error { return nil },
acquireTransaction: lifecycle.AcquireTransaction,
runner: runner,
sleep: func(time.Duration) {},
restoreFile: func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return nil },
restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error {
return nil
},
+68 -2
View File
@@ -16,8 +16,10 @@ import (
)
var (
ErrLocked = errors.New("another lifecycle operation is already running for this installation")
ErrOwnership = errors.New("lifecycle lock ownership changed; refusing to remove it")
ErrLocked = errors.New("another lifecycle operation is already running for this installation")
ErrOwnership = errors.New("lifecycle lock ownership changed; refusing to remove it")
ErrTransactionInactive = errors.New("lifecycle transaction capability is not active")
ErrTransactionInstallation = errors.New("lifecycle transaction capability belongs to another installation")
)
const lockFileName = "lifecycle.lock.owner.json"
@@ -36,6 +38,14 @@ type Lock struct {
released bool
}
// Transaction is an opaque, installation-bound capability for work that must run while a
// lifecycle lock remains owned. Its fields are deliberately private so callers can obtain one
// only through AcquireTransaction.
type Transaction struct {
lock *Lock
controlDirectory string
}
// Acquire obtains the shared lock used by backup, restore, Pi lifecycle and product updates.
func Acquire(installation config.Installation) (*Lock, error) {
directory := installation.ControlDirectory()
@@ -76,6 +86,39 @@ func Acquire(installation config.Installation) (*Lock, error) {
return &Lock{path: path, token: token}, nil
}
// AcquireTransaction obtains a lifecycle lock and returns the capability required by callers
// that perform nested work inside the same non-reentrant transaction.
func AcquireTransaction(installation config.Installation) (*Transaction, error) {
lock, err := Acquire(installation)
if err != nil {
return nil, err
}
return &Transaction{
lock: lock,
controlDirectory: filepath.Clean(installation.ControlDirectory()),
}, nil
}
// Verify refuses a nil, released, replaced, or foreign-installation capability before a nested
// lifecycle operation can begin.
func (transaction *Transaction) Verify(installation config.Installation) error {
if transaction == nil || transaction.lock == nil {
return ErrTransactionInactive
}
if transaction.controlDirectory != filepath.Clean(installation.ControlDirectory()) {
return ErrTransactionInstallation
}
return transaction.lock.verifyHeld()
}
// Release relinquishes the lifecycle lock associated with this transaction capability.
func (transaction *Transaction) Release() error {
if transaction == nil || transaction.lock == nil {
return nil
}
return transaction.lock.Release()
}
// Path returns the installation-private owner-file path for diagnostics and tests.
func (lock *Lock) Path() string {
if lock == nil {
@@ -111,3 +154,26 @@ func (lock *Lock) Release() error {
lock.released = true
return nil
}
func (lock *Lock) verifyHeld() error {
if lock == nil {
return ErrTransactionInactive
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released {
return ErrTransactionInactive
}
contents, err := os.ReadFile(lock.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrOwnership
}
return fmt.Errorf("read lifecycle lock owner: %w", err)
}
var current owner
if json.Unmarshal(contents, &current) != nil || current.Token == "" || current.Token != lock.token {
return ErrOwnership
}
return nil
}
+24
View File
@@ -48,3 +48,27 @@ func TestLifecycleLockReleaseDoesNotRemoveAnotherOwnersFile(t *testing.T) {
t.Fatalf("foreign lock was removed: %v", err)
}
}
func TestTransactionCapabilityIsInstallationBoundAndExpiresOnRelease(t *testing.T) {
root := t.TempDir()
installation := config.Installation{ProjectDirectory: root, Path: filepath.Join(root, "thothii-installation.yaml")}
transaction, err := AcquireTransaction(installation)
if err != nil {
t.Fatal(err)
}
if err := transaction.Verify(installation); err != nil {
t.Fatalf("Verify() active capability error = %v", err)
}
otherRoot := t.TempDir()
other := config.Installation{ProjectDirectory: otherRoot, Path: filepath.Join(otherRoot, "thothii-installation.yaml")}
if err := transaction.Verify(other); !errors.Is(err, ErrTransactionInstallation) {
t.Fatalf("Verify() for another installation error = %v, want ErrTransactionInstallation", err)
}
if err := transaction.Release(); err != nil {
t.Fatal(err)
}
if err := transaction.Verify(installation); !errors.Is(err, ErrTransactionInactive) {
t.Fatalf("Verify() after Release() error = %v, want ErrTransactionInactive", err)
}
}