feat(auth): add local login and CSRF-protected sessions

This commit is contained in:
2026-08-16 23:23:55 +02:00
parent 8477a69a29
commit 29bfb41363
10 changed files with 1062 additions and 35 deletions
+11 -3
View File
@@ -6,7 +6,7 @@ import { stringify } from "yaml";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
test("configured OIDC fails app startup until an OIDC handler is installed", () => {
test("configured OIDC starts with provider-neutral protocol placeholders that fail closed", async () => {
const directory = mkdtempSync(join(tmpdir(), "thothii-app-oidc-mode-"));
const file = join(directory, "auth.yaml");
writeFileSync(file, stringify({
@@ -19,8 +19,16 @@ test("configured OIDC fails app startup until an OIDC handler is installed", ()
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
}), "utf8");
try {
expect(() => buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file })))
.toThrow("configured authentication mode is not implemented");
const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") }));
try {
expect((await app.inject({ method: "GET", url: "/auth/config" })).json())
.toEqual({ mode: "oidc", localLogin: false, oidcLogin: false });
const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" });
expect(placeholder.statusCode).toBe(501);
expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
} finally {
await app.close();
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, expect, test } from "vitest";
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stringify } from "yaml";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { deriveCsrfToken } from "../src/auth/csrf.js";
const password = "correct horse battery staple";
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
const publicUrl = "http://127.0.0.1:8787";
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});
function sessionCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
const setCookie = response.headers["set-cookie"];
const first = Array.isArray(setCookie) ? setCookie[0] : setCookie;
return first?.split(";", 1)[0] ?? "";
}
async function createApp() {
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-csrf-"));
chmodSync(directory, 0o700);
const authConfigFile = join(directory, "auth.yaml");
const usersFile = join(directory, "users.yaml");
writeFileSync(authConfigFile, stringify({
version: 1, mode: "local", publicUrl, local: { usersFile: "users.yaml" },
}), { encoding: "utf8", mode: 0o600 });
writeFileSync(usersFile, [
"version: 1", "users:", ` - id: ${adminId}`, " username: Admin",
" displayName: Local administrator", ` passwordHash: ${passwordHash}`,
" roles:", " - admin", " enabled: true", " authRevision: 1", "",
].join("\n"), { encoding: "utf8", mode: 0o600 });
chmodSync(authConfigFile, 0o600);
chmodSync(usersFile, 0o600);
const app = buildApp(loadConfig({
THT_AUTH_CONFIG_FILE: authConfigFile,
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
THT_HARNESS_DIR: "/tmp/h",
}));
cleanups.push(async () => {
await app.close();
rmSync(directory, { recursive: true, force: true });
});
const signedIn = await app.inject({
method: "POST", url: "/auth/local/login",
headers: { origin: publicUrl, "sec-fetch-site": "same-origin" },
payload: { username: "Admin", password },
});
const cookie = sessionCookie(signedIn);
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie } });
return { app, cookie, csrfToken: me.json().csrfToken as string };
}
test("derived CSRF tokens are deterministic per opaque cookie and are never the cookie token", async () => {
const { cookie, csrfToken } = await createApp();
const token = cookie.split("=", 2)[1] ?? "";
expect(deriveCsrfToken(token)).toBe(csrfToken);
expect(csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(csrfToken).not.toBe(token);
});
test("cookie-authenticated state changes require an exact Origin, Fetch Metadata when present, and CSRF token", async () => {
const { app, cookie, csrfToken } = await createApp();
const cases = [
{ headers: { cookie, origin: publicUrl, "sec-fetch-site": "same-origin" } },
{ headers: { cookie, origin: "http://127.0.0.1:8788", "sec-fetch-site": "same-origin", "x-thothii-csrf": csrfToken } },
{ headers: { cookie, origin: publicUrl, "sec-fetch-site": "cross-site", "x-thothii-csrf": csrfToken } },
{ headers: { cookie, origin: publicUrl, "x-thothii-csrf": csrfToken.slice(0, -1) } },
];
for (const request of cases) {
const response = await app.inject({ method: "POST", url: "/auth/logout", ...request });
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
}
});
test("duplicate or malformed CSRF and session-cookie headers fail closed", async () => {
const { app, cookie, csrfToken } = await createApp();
const duplicateCsrf = await app.inject({
method: "POST", url: "/auth/logout",
headers: { cookie, origin: publicUrl, "x-thothii-csrf": `${csrfToken}, ${csrfToken}` },
});
const duplicateCookie = await app.inject({
method: "POST", url: "/auth/logout",
headers: { cookie: `${cookie}; ${cookie}`, origin: publicUrl, "x-thothii-csrf": csrfToken },
});
const malformedCookie = await app.inject({
method: "POST", url: "/auth/logout",
headers: { cookie: "thothii_session=not-a-token", origin: publicUrl, "x-thothii-csrf": csrfToken },
});
expect(duplicateCsrf.statusCode).toBe(403);
expect(duplicateCookie.statusCode).toBe(401);
expect(malformedCookie.statusCode).toBe(401);
});
test("an unauthenticated state-changing application route cannot bypass the central boundary", async () => {
const { app } = await createApp();
const response = await app.inject({
method: "POST", url: "/sessions", headers: { origin: publicUrl, "x-thothii-csrf": "x".repeat(43) },
payload: { question: "must not reach a session handler" },
});
expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({ code: "authentication_required", error: "Authentication is required" });
});
+340
View File
@@ -0,0 +1,340 @@
import { afterEach, expect, test, vi } from "vitest";
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stringify } from "yaml";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
const password = "correct horse battery staple";
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
const publicUrl = "http://127.0.0.1:8787";
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});
function localConfig(url = publicUrl) {
return {
version: 1,
mode: "local",
publicUrl: url,
local: { usersFile: "users.yaml" },
};
}
function usersYaml(options: { enabled?: boolean; username?: string } = {}): string {
return [
"version: 1",
"users:",
` - id: ${adminId}`,
` username: ${options.username ?? "Admin"}`,
" displayName: Local administrator",
` passwordHash: ${passwordHash}`,
" roles:",
" - admin",
` enabled: ${options.enabled ?? true}`,
" authRevision: 1",
"",
].join("\n");
}
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
const header = response.headers["set-cookie"];
if (Array.isArray(header)) return header[0] ?? "";
return header ?? "";
}
function cookiePair(setCookie: string): string {
return setCookie.split(";", 1)[0] ?? "";
}
async function createLocalApp(options: {
publicUrl?: string;
enabled?: boolean;
stateRoot?: string;
registry?: {
findByUsername(username: string): Promise<unknown>;
findBySubject(subject: string): Promise<unknown>;
verify(user: unknown, suppliedPassword: string): Promise<boolean>;
};
} = {}) {
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-routes-"));
chmodSync(directory, 0o700);
const authConfigFile = join(directory, "auth.yaml");
const usersFile = join(directory, "users.yaml");
const authStateRoot = options.stateRoot ?? join(directory, "auth-state");
writeFileSync(authConfigFile, stringify(localConfig(options.publicUrl)), { encoding: "utf8", mode: 0o600 });
writeFileSync(usersFile, usersYaml({ enabled: options.enabled }), { encoding: "utf8", mode: 0o600 });
chmodSync(authConfigFile, 0o600);
chmodSync(usersFile, 0o600);
const app = buildApp(loadConfig({
THT_AUTH_CONFIG_FILE: authConfigFile,
THT_AUTH_STATE_ROOT: authStateRoot,
THT_HARNESS_DIR: "/tmp/h",
}), options.registry === undefined ? undefined : { localUserRegistry: options.registry } as any);
cleanups.push(async () => {
await app.close();
rmSync(directory, { recursive: true, force: true });
});
return { app, authConfigFile, usersFile, authStateRoot, directory, publicUrl: options.publicUrl ?? publicUrl };
}
async function login(app: Awaited<ReturnType<typeof createLocalApp>>["app"], body: Record<string, unknown> = {}) {
return app.inject({
method: "POST",
url: "/auth/local/login",
headers: { origin: publicUrl, "sec-fetch-site": "same-origin" },
payload: { username: "Admin", password, ...body },
});
}
test("local login sets a non-persistent opaque session cookie and exposes only a safe /me DTO", async () => {
const { app } = await createLocalApp();
const signedIn = await login(app);
expect(signedIn.statusCode).toBe(200);
const setCookie = firstSetCookie(signedIn);
expect(setCookie).toMatch(/^thothii_session=[A-Za-z0-9_-]{43}; /);
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toContain("SameSite=Lax");
expect(setCookie).toContain("Path=/");
expect(setCookie).not.toMatch(/Max-Age=/i);
expect(setCookie).not.toContain("Secure");
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
expect(me.statusCode).toBe(200);
expect(me.json()).toEqual({
issuer: "local",
subject: adminId,
displayName: "Local administrator",
roles: ["admin"],
permissions: [
"session.use", "session.read_all", "session.manage_all", "settings.manage",
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
],
isAdmin: true,
csrfToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
session: {
method: "local",
remembered: false,
idleExpiresAt: expect.any(String),
absoluteExpiresAt: expect.any(String),
},
});
expect(JSON.stringify(me.json())).not.toContain("authConfigRevision");
expect(JSON.stringify(me.json())).not.toContain("authRevision");
expect(JSON.stringify(me.json())).not.toContain(cookiePair(setCookie).split("=", 2)[1] ?? "");
});
test("remembered login uses a persistent secure cookie under an HTTPS public URL and survives app recreation", async () => {
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-remembered-"));
chmodSync(directory, 0o700);
const authConfigFile = join(directory, "auth.yaml");
const usersFile = join(directory, "users.yaml");
const authStateRoot = join(directory, "auth-state");
writeFileSync(authConfigFile, stringify(localConfig("https://thothii.example.test")), { encoding: "utf8", mode: 0o600 });
writeFileSync(usersFile, usersYaml(), { encoding: "utf8", mode: 0o600 });
chmodSync(authConfigFile, 0o600);
chmodSync(usersFile, 0o600);
const config = () => loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h" });
const first = buildApp(config());
try {
const signedIn = await first.inject({
method: "POST",
url: "/auth/local/login",
headers: { origin: "https://thothii.example.test", "sec-fetch-site": "same-origin" },
payload: { username: "Admin", password, remember: true },
});
const setCookie = firstSetCookie(signedIn);
expect(signedIn.statusCode).toBe(200);
expect(setCookie).toContain("Max-Age=2592000");
expect(setCookie).toContain("Secure");
await first.close();
const restarted = buildApp(config());
cleanups.push(async () => {
await restarted.close();
rmSync(directory, { recursive: true, force: true });
});
const me = await restarted.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
expect(me.statusCode).toBe(200);
expect(me.json()).toMatchObject({ subject: adminId, session: { remembered: true, method: "local" } });
} catch (error) {
await first.close();
rmSync(directory, { recursive: true, force: true });
throw error;
}
});
test("unknown, disabled, and wrong-password logins share one generic failure contract", async () => {
const enabled = await createLocalApp();
const disabled = await createLocalApp({ enabled: false });
const attempts = await Promise.all([
login(enabled.app, { username: "Unknown" }),
login(disabled.app),
login(enabled.app, { password: `${password}!` }),
]);
for (const response of attempts) {
expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({ code: "invalid_credentials", error: "Invalid username or password" });
expect(response.headers["set-cookie"]).toBeUndefined();
}
});
test("invalid password input still reaches the local verifier with a bounded Argon2-safe surrogate", async () => {
const user = {
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
};
const verify = vi.fn(async () => false);
const { app } = await createLocalApp({
registry: { findByUsername: async () => user, findBySubject: async () => user, verify },
});
const response = await login(app, { password: "short" });
expect(response.statusCode).toBe(401);
const verifierPassword = verify.mock.calls[0]?.[1];
expect(verifierPassword).not.toBe("short");
expect(Buffer.byteLength(verifierPassword ?? "", "utf8")).toBeGreaterThanOrEqual(12);
});
test("local login requires the exact configured Origin and same-origin Fetch Metadata", async () => {
const { app } = await createLocalApp();
const missingOrigin = await app.inject({ method: "POST", url: "/auth/local/login", payload: { username: "Admin", password } });
const wrongOrigin = await app.inject({
method: "POST", url: "/auth/local/login", headers: { origin: "http://127.0.0.1:8788" }, payload: { username: "Admin", password },
});
const crossSite = await app.inject({
method: "POST", url: "/auth/local/login", headers: { origin: publicUrl, "sec-fetch-site": "cross-site" }, payload: { username: "Admin", password },
});
for (const response of [missingOrigin, wrongOrigin, crossSite]) {
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
}
});
test("logout revokes the session and clears the cookie with the production attributes", async () => {
const { app } = await createLocalApp();
const signedIn = await login(app);
const setCookie = firstSetCookie(signedIn);
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
const loggedOut = await app.inject({
method: "POST",
url: "/auth/logout",
headers: {
cookie: cookiePair(setCookie), origin: publicUrl, "sec-fetch-site": "same-origin",
"x-thothii-csrf": me.json().csrfToken,
},
});
expect(loggedOut.statusCode).toBe(204);
const cleared = firstSetCookie(loggedOut);
expect(cleared).toMatch(/^thothii_session=;/);
expect(cleared).toContain("Max-Age=0");
expect(cleared).toContain("HttpOnly");
expect(cleared).toContain("SameSite=Lax");
expect(cleared).toContain("Path=/");
expect(cleared).toContain("Expires=");
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } })).statusCode).toBe(401);
});
test("failed logins are limited by normalized username and source address", async () => {
const user = {
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
};
const registry = {
findByUsername: async () => user,
findBySubject: async () => user,
verify: async () => false,
};
const { app } = await createLocalApp({ registry });
for (let attempt = 0; attempt < 10; attempt += 1) {
const response = await login(app, { username: "aDmIn" });
expect(response.statusCode).toBe(401);
}
const limited = await login(app, { username: "ADMIN" });
expect(limited.statusCode).toBe(429);
expect(limited.json()).toEqual({ code: "login_rate_limited", error: "Too many login attempts" });
const addressLimited = await createLocalApp({ registry });
for (let attempt = 0; attempt < 20; attempt += 1) {
const response = await login(addressLimited.app, { username: `User${attempt}` });
expect(response.statusCode).toBe(401);
}
expect((await login(addressLimited.app, { username: "A-new-username" })).statusCode).toBe(429);
});
test("only two Argon2 verifications run concurrently and excess login attempts fail immediately", async () => {
let calls = 0;
let release!: () => void;
const blocked = new Promise<void>((resolve) => { release = resolve; });
let entered!: () => void;
const twoEntered = new Promise<void>((resolve) => { entered = resolve; });
const user = {
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
};
const registry = {
findByUsername: async () => user,
findBySubject: async () => user,
verify: async () => {
calls += 1;
if (calls === 2) entered();
await blocked;
return false;
},
};
const { app } = await createLocalApp({ registry });
const first = login(app);
const second = login(app);
await twoEntered;
const excess = await login(app);
expect(excess.statusCode).toBe(429);
expect(calls).toBe(2);
release();
expect((await first).statusCode).toBe(401);
expect((await second).statusCode).toBe(401);
});
test("a verifier failure is sanitized and releases its concurrency permit", async () => {
let attempts = 0;
const user = {
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
};
const { app } = await createLocalApp({
registry: {
findByUsername: async () => user,
findBySubject: async () => user,
verify: async () => {
attempts += 1;
if (attempts === 1) throw new Error("fixture verifier failure");
return false;
},
},
});
const failed = await login(app);
expect(failed.statusCode).toBe(503);
expect(failed.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
expect((await login(app)).statusCode).toBe(401);
expect(attempts).toBe(2);
});
test("public auth configuration is safe and OIDC protocol placeholders fail closed", async () => {
const { app } = await createLocalApp();
const configuration = await app.inject({ method: "GET", url: "/auth/config" });
expect(configuration.statusCode).toBe(200);
expect(configuration.json()).toEqual({ mode: "local", localLogin: true, oidcLogin: false });
expect(JSON.stringify(configuration.json())).not.toContain("users.yaml");
const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" });
expect(placeholder.statusCode).toBe(501);
expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
});
+82 -2
View File
@@ -1,6 +1,6 @@
import { test, expect } from "vitest";
import { test, expect, vi } from "vitest";
import Fastify from "fastify";
import { authPreHandler, getPrincipal } from "../src/auth/auth.js";
import { authenticateSession, authPreHandler, getPrincipal } from "../src/auth/auth.js";
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -107,6 +107,86 @@ test("upstream mode rejects legacy client identity headers without proxy princip
}
});
test("the session boundary exposes only exact health and authentication protocol paths", async () => {
const app = Fastify();
app.addHook("preHandler", authenticateSession({
mode: "local",
authentication: {
current: () => ({
sourcePath: "/private/auth.yaml",
revision: "a".repeat(64),
value: {
version: 1,
mode: "local",
publicUrl: "http://127.0.0.1:8787",
session: {
regularTtlSeconds: 43_200, regularIdleSeconds: 7_200,
rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800,
},
local: { usersFile: "users.yaml" },
},
}),
},
sessionStore: { resolve: async () => undefined } as any,
}));
app.get("/health", async () => ({ ok: true }));
app.get("/auth/config", async () => ({ mode: "local" }));
app.get("/healthz", async () => ({ ok: true }));
app.get("/auth/configured", async () => ({ mode: "local" }));
expect((await app.inject({ method: "GET", url: "/health?probe=1" })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/auth/config?ui=1" })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/healthz" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/auth/configured" })).statusCode).toBe(401);
});
test("the session boundary touches a valid cookie session through the bounded Task 7 store operation", async () => {
const sessions = {
resolve: vi.fn(async () => ({
version: 1,
issuer: "local",
subject: "user-1",
method: "local",
roles: ["user"],
permissions: ["session.use"],
userAuthRevision: 1,
authConfigRevision: "b".repeat(64),
remembered: false,
createdAt: "2026-08-16T00:00:00.000Z",
lastSeenAt: "2026-08-16T00:00:00.000Z",
idleExpiresAt: "2026-08-16T02:00:00.000Z",
absoluteExpiresAt: "2026-08-16T12:00:00.000Z",
})),
touch: vi.fn(async () => {}),
};
const app = Fastify();
app.addHook("preHandler", authenticateSession({
mode: "local",
authentication: {
current: () => ({
sourcePath: "/private/auth.yaml",
revision: "b".repeat(64),
value: {
version: 1,
mode: "local",
publicUrl: "http://127.0.0.1:8787",
session: {
regularTtlSeconds: 43_200, regularIdleSeconds: 7_200,
rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800,
},
local: { usersFile: "users.yaml" },
},
}),
},
sessionStore: sessions as any,
}));
app.get("/private", async (request) => getPrincipal(request));
const token = "z".repeat(43);
expect((await app.inject({ method: "GET", url: "/private", headers: { cookie: `thothii_session=${token}` } })).statusCode).toBe(200);
expect(sessions.touch).toHaveBeenCalledWith(token);
});
test("local identity expands tilde homes and restores private POSIX permissions", () => {
expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test");
expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester");
+11 -9
View File
@@ -163,19 +163,20 @@ test("a durable maintenance marker initializes admission closed after backend re
}
});
test.each(["none", "upstream"] as const)(
"maintenance control is loopback-only and independent of %s authentication",
async (authMode) => {
test("maintenance control requires an upstream identity and remains loopback-only", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-control-"));
const marker = path.join(dir, "maintenance.json");
try {
const app = buildApp(loadConfig({
AUTH_MODE: authMode,
AUTH_MODE: "upstream",
THT_HARNESS_DIR: "../harness",
THT_MAINTENANCE_FILE: marker,
}), { thtRunner: {} as any });
const activated = await app.inject({ method: "POST", url: "/internal/maintenance/activate" });
expect((await app.inject({ method: "POST", url: "/internal/maintenance/activate" })).statusCode).toBe(401);
const activated = await app.inject({
method: "POST", url: "/internal/maintenance/activate", headers: aliceHeaders,
});
expect(activated.statusCode).toBe(200);
expect(activated.json()).toEqual({ active: true, admissions: 0 });
@@ -190,15 +191,16 @@ test.each(["none", "upstream"] as const)(
});
expect(spoofedProxy.statusCode).toBe(403);
const status = await app.inject({ method: "GET", url: "/internal/maintenance/status" });
const status = await app.inject({ method: "GET", url: "/internal/maintenance/status", headers: aliceHeaders });
expect(status.json()).toEqual({ active: true, admissions: 0 });
const deactivated = await app.inject({ method: "POST", url: "/internal/maintenance/deactivate" });
const deactivated = await app.inject({
method: "POST", url: "/internal/maintenance/deactivate", headers: aliceHeaders,
});
expect(deactivated.json()).toEqual({ active: false, admissions: 0 });
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
});
test("maintenance endpoints report marker-derived state after post-rename and post-remove fsync failures", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-endpoint-fsync-"));