Files
ThothII/backend/test/auth.test.ts
T
Codex 82e2c91f42
Publish documentation / publish (push) Successful in 1m27s
feat: implement memory and evidence administration with guided repairs
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation.

Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
2026-09-10 10:31:34 +02:00

300 lines
11 KiB
TypeScript

import { test, expect, vi } from "vitest";
import Fastify from "fastify";
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";
import { expandLocalHome, localPrincipal, upstreamPrincipal } from "../src/auth/principal.js";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { rolesToPermissions } from "../src/auth/config.js";
import type { Permission, Role } from "../src/auth/types.js";
test("server smoke rejects retired trusted claims under OIDC authentication", () => {
const smoke = readFileSync("../scripts/unified-deployment-smoke.sh", "utf8");
for (const header of [
"x-thoth-trusted-principal-issuer",
"x-thoth-trusted-principal-subject",
"x-thoth-trusted-principal-display-name",
"x-thoth-trusted-is-admin",
]) {
expect(smoke).toContain(`-H '${header}:`);
}
expect(smoke).toContain('[[ "$trusted_header_status" == 401 ]]');
expect(smoke).toContain("server accepted retired trusted identity headers");
});
test("local mode resolves a stable local principal", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("none"));
app.get("/me", async (req) => getPrincipal(req));
expect((await app.inject({ method: "GET", url: "/me" })).json()).toMatchObject({
issuer: "local",
subject: expect.any(String),
roles: ["admin"],
permissions: [
"session.use", "session.read_all", "session.manage_all", "settings.manage",
"workspace.manage", "workspace.secrets.manage", "database.manage", "memory.manage", "evidence.manage", "pi.manage", "auth.diagnostics.read",
],
isAdmin: true,
});
});
test("mock mode makes a principal from the test header", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("mock"));
app.get("/me", async (req) => getPrincipal(req));
const res = await app.inject({
method: "GET",
url: "/me",
headers: { "x-mock-user": "alice" },
});
expect(res.json()).toEqual({
issuer: "mock", subject: "alice", displayName: "alice",
roles: ["user"], permissions: ["session.use"], isAdmin: false,
});
});
test("upstream mode accepts only normalized proxy principal headers", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("upstream"));
app.get("/me", async (req) => getPrincipal(req));
expect((await app.inject({ method: "GET", url: "/me" })).statusCode).toBe(401);
const authenticated = await app.inject({
method: "GET",
url: "/me",
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "42",
"x-thoth-principal-display-name": "Alice",
"x-thoth-is-admin": "1",
"x-authenticated-user": "must-not-be-used",
},
});
expect(authenticated.json()).toEqual({
issuer: "portal", subject: "42", displayName: "Alice", roles: ["user", "admin"],
permissions: [
"session.use", "session.read_all", "session.manage_all", "settings.manage",
"workspace.manage", "workspace.secrets.manage", "database.manage", "memory.manage", "evidence.manage", "pi.manage", "auth.diagnostics.read",
],
isAdmin: true,
});
});
test("upstream mode rejects legacy client identity headers without proxy principal fields", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("upstream"));
app.get("/me", async (req) => getPrincipal(req));
for (const headers of [
{ "x-authenticated-user": "mallory" },
{ "x-mock-user": "mallory" },
{ "x-authenticated-user": "mallory", "x-mock-user": "mallory" },
]) {
expect((await app.inject({ method: "GET", url: "/me", headers })).statusCode).toBe(401);
}
});
test("buildApp exposes safe nullable session metadata for authenticated legacy modes", async () => {
const configurations = [
{
name: "none",
config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "none", THT_HARNESS_DIR: "/tmp/h" }),
headers: {},
expected: { issuer: "local", roles: ["admin"] },
},
{
name: "mock",
config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "mock", THT_HARNESS_DIR: "/tmp/h" }),
headers: { "x-mock-user": "legacy-mock" },
expected: { issuer: "mock", subject: "legacy-mock", roles: ["user"] },
},
{
name: "upstream",
config: loadConfig({ NODE_ENV: "production", AUTH_MODE: "upstream", THT_HARNESS_DIR: "/tmp/h" }),
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "legacy-upstream",
"x-thoth-is-admin": "0",
},
expected: { issuer: "portal", subject: "legacy-upstream", roles: ["user"] },
},
];
for (const legacy of configurations) {
const app = buildApp(legacy.config, { thtRunner: {} as any, listModels: async () => [] });
try {
const response = await app.inject({ method: "GET", url: "/me", headers: legacy.headers });
expect(response.statusCode, legacy.name).toBe(200);
expect(response.json()).toMatchObject({
...legacy.expected,
csrfToken: null,
session: null,
});
} finally {
await app.close();
}
}
});
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("loopback maintenance headers can never mint an administrator in configured auth modes", async () => {
const app = Fastify();
app.addHook("preHandler", authenticateSession({ mode: "local" }));
app.get("/private", async (request) => getPrincipal(request));
app.post("/private", async (request) => getPrincipal(request));
const headers = {
"x-thoth-principal-issuer": "tht",
"x-thoth-principal-subject": "tht-maintenance",
"x-thoth-principal-display-name": "Tht maintenance",
"x-thoth-is-admin": "1",
};
for (const method of ["GET", "POST"] as const) {
const response = await app.inject({ method, url: "/private", headers, remoteAddress: "127.0.0.1" });
expect(response.statusCode).toBe(503);
expect(response.body).not.toContain("tht-maintenance");
}
});
test("the session boundary rejects tht maintenance headers outside exact loopback provenance", async () => {
const app = Fastify();
app.addHook("preHandler", authenticateSession({ mode: "local" }));
app.get("/private", async (request) => getPrincipal(request));
const exact = {
"x-thoth-principal-issuer": "tht",
"x-thoth-principal-subject": "tht-maintenance",
"x-thoth-principal-display-name": "Tht maintenance",
"x-thoth-is-admin": "1",
};
expect((await app.inject({ method: "GET", url: "/private", headers: exact, remoteAddress: "172.30.0.9" })).statusCode).toBe(503);
expect((await app.inject({
method: "GET", url: "/private", remoteAddress: "127.0.0.1",
headers: { ...exact, "x-thoth-principal-subject": "not-maintenance" },
})).statusCode).toBe(503);
});
test.each<{
name: string;
roles: Role[];
storedPermissions: Permission[];
}>([
{ name: "current user", roles: ["user"], storedPermissions: ["session.use"] },
{
name: "administrator signed in before archive permissions existed",
roles: ["admin"],
storedPermissions: rolesToPermissions(["admin"]).filter(
(permission) => permission !== "memory.manage" && permission !== "evidence.manage",
),
},
{
name: "user with obsolete administrative permissions",
roles: ["user"],
storedPermissions: ["session.use", "memory.manage", "evidence.manage"],
},
])("the session boundary derives current permissions and touches a valid cookie: $name", async ({ roles, storedPermissions }) => {
const sessions = {
resolve: vi.fn(async () => ({
version: 1,
issuer: "local",
subject: "user-1",
method: "local",
roles,
permissions: storedPermissions,
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);
const response = await app.inject({ method: "GET", url: "/private", headers: { cookie: `thothii_session=${token}` } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
roles,
permissions: rolesToPermissions(roles),
isAdmin: roles.includes("admin"),
});
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");
const home = mkdtempSync(join(tmpdir(), "thoth-principal-"));
chmodSync(home, 0o755);
const previous = process.env.THT_HOME;
process.env.THT_HOME = home;
try {
localPrincipal();
if (process.platform !== "win32") {
expect(statSync(home).mode & 0o777).toBe(0o700);
expect(statSync(join(home, "identity.json")).mode & 0o777).toBe(0o600);
}
} finally {
if (previous === undefined) delete process.env.THT_HOME; else process.env.THT_HOME = previous;
rmSync(home, { recursive: true, force: true });
}
});