Files
projectbrain-Public/backend/test/database-authentication.test.ts

301 lines
21 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { loadEnv } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { createSession } from "../src/security/authStore.js";
import { hashPassword } from "../src/security/password.js";
import { createHash } from "node:crypto";
import { apiPaths } from "@projectbrain/contracts";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
const databaseUrl = process.env.TEST_DATABASE_URL;
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
const databaseDescribe = databaseUrl ? describe : describe.skip;
const ownerPassword = "database-owner-password-value";
const coreLoopIds = {
capture: [] as string[], review: [] as string[], truth: [] as string[], update: [] as string[],
handoff: [] as string[], task: [] as string[], runImport: [] as string[],
};
const testEnv = () => loadEnv({
NODE_ENV: "test",
LOG_LEVEL: "silent",
DATABASE_URL: databaseUrl,
PROJECTBRAIN_DATA_SOURCE: "database",
PROJECTBRAIN_OWNER_USERNAME: "database-owner",
PROJECTBRAIN_OWNER_PASSWORD: ownerPassword,
PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters",
PROJECTBRAIN_SESSION_TTL_HOURS: "1",
PROJECTBRAIN_TRUST_PROXY: "false",
});
const entraEnv = () => loadEnv({
NODE_ENV: "test",
LOG_LEVEL: "silent",
DATABASE_URL: databaseUrl,
PROJECTBRAIN_DATA_SOURCE: "database",
PROJECTBRAIN_OWNER_USERNAME: "database-owner",
PROJECTBRAIN_OWNER_PASSWORD: ownerPassword,
PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters",
PROJECTBRAIN_SESSION_TTL_HOURS: "1",
PROJECTBRAIN_TRUST_PROXY: "false",
PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com",
PROJECTBRAIN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111",
PROJECTBRAIN_ENTRA_CLIENT_ID: "22222222-2222-4222-8222-222222222222",
PROJECTBRAIN_ENTRA_CLIENT_SECRET: "database-entra-test-secret",
});
databaseDescribe("database authentication persistence", () => {
beforeAll(async () => {
await prisma.oidcLoginAttempt.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
await prisma.externalIdentity.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
await prisma.authSession.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
await prisma.projectMembership.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
await prisma.user.deleteMany({ where: { username: { in: ["database-owner", "database-member"] } } });
});
afterAll(async () => {
await prisma.oidcLoginAttempt.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
await prisma.externalIdentity.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
const markedTasks = await prisma.codexTask.findMany({ where: { goal: "Execute the database core-loop proof" }, select: { id: true } });
await prisma.codexRunImport.deleteMany({ where: { OR: [{ sourceRun: "core-loop-database-proof" }, { codexTaskId: { in: markedTasks.map((item) => item.id) } }] } });
await prisma.reviewQueueItem.deleteMany({ where: { OR: [{ source: "core-loop-database-proof" }, { title: { contains: "Core-loop database proof" } }] } });
await prisma.codexTask.deleteMany({ where: { goal: "Execute the database core-loop proof" } });
await prisma.handoffBundle.deleteMany({ where: { title: "Core-loop database proof handoff" } });
const markedCaptures = await prisma.captureItem.findMany({ where: { content: "Core-loop database proof source material" }, select: { linkedReviewItemId: true } });
const markedReviewIds = markedCaptures.flatMap((item) => item.linkedReviewItemId ? [item.linkedReviewItemId] : []);
const markedTruth = await prisma.acceptedTruthItem.findMany({ where: { sourceReviewItemId: { in: markedReviewIds } }, select: { id: true } });
await prisma.projectUpdate.deleteMany({ where: { sourceTruthItemId: { in: markedTruth.map((item) => item.id) } } });
await prisma.acceptedTruthItem.deleteMany({ where: { id: { in: markedTruth.map((item) => item.id) } } });
await prisma.reviewQueueItem.deleteMany({ where: { id: { in: markedReviewIds } } });
await prisma.captureItem.deleteMany({ where: { content: "Core-loop database proof source material" } });
await prisma.codexRunImport.deleteMany({ where: { id: { in: coreLoopIds.runImport } } });
await prisma.codexTask.deleteMany({ where: { id: { in: coreLoopIds.task } } });
await prisma.handoffBundle.deleteMany({ where: { id: { in: coreLoopIds.handoff } } });
await prisma.projectUpdate.deleteMany({ where: { id: { in: coreLoopIds.update } } });
await prisma.acceptedTruthItem.deleteMany({ where: { id: { in: coreLoopIds.truth } } });
await prisma.reviewQueueItem.deleteMany({ where: { id: { in: coreLoopIds.review } } });
await prisma.captureItem.deleteMany({ where: { id: { in: coreLoopIds.capture } } });
await prisma.idempotencyRecord.deleteMany({ where: { key: { contains: "core-loop-database-proof" } } });
await prisma.operatingLogEntry.deleteMany({ where: { title: "Durable idempotency restart proof" } });
await prisma.idempotencyRecord.deleteMany({ where: { key: { contains: "database-restart-replay" } } });
await prisma.authSession.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
await prisma.projectMembership.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
await prisma.user.deleteMany({ where: { username: { in: ["database-owner", "database-member"] } } });
await prisma.$disconnect();
});
it("keeps a server-side owner session valid across API restart", async () => {
const firstApp = await buildApp({ env: testEnv() });
const login = await firstApp.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } });
expect(login.statusCode).toBe(200);
const cookie = login.cookies.find((item) => item.name === "projectbrain_session");
expect(cookie).toBeDefined();
const cookieHeader = `${cookie!.name}=${cookie!.value}`;
await firstApp.close();
const restartedApp = await buildApp({ env: testEnv() });
const response = await restartedApp.inject({ method: "GET", url: "/api/projects", headers: { cookie: cookieHeader } });
expect(response.statusCode).toBe(200);
expect(response.json().data.length).toBeGreaterThan(0);
await restartedApp.close();
}, 15_000);
it("creates a one-time PKCE Entra link attempt without persisting raw state", async () => {
const app = await buildApp({ env: entraEnv() });
const login = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "database-owner", password: ownerPassword } });
const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session");
const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`;
const csrfToken = login.json().data.csrfToken as string;
const response = await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart, headers: { cookie, "x-csrf-token": csrfToken } });
expect(response.statusCode).toBe(200);
const authorizationUrl = new URL(response.json().data.authorizationUrl);
expect(authorizationUrl.hostname).toBe("login.microsoftonline.com");
expect(authorizationUrl.pathname).toContain("11111111-1111-4111-8111-111111111111");
expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe("S256");
expect(authorizationUrl.searchParams.get("nonce")?.length).toBeGreaterThan(30);
const state = authorizationUrl.searchParams.get("state")!;
const attempt = await prisma.oidcLoginAttempt.findUnique({ where: { stateHash: createHash("sha256").update(state).digest("hex") } });
expect(attempt?.purpose).toBe("link");
expect(attempt?.returnTo).toBe("/settings");
expect(attempt?.stateHash).not.toBe(state);
await app.close();
});
it("validates the Entra ID token and turns a linked identity into a ProjectBrain session", async () => {
const app = await buildApp({ env: entraEnv() });
const login = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "database-owner", password: ownerPassword } });
const localCookie = login.cookies.find((item) => item.name === "projectbrain_session")!;
const linkStart = await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart, headers: { cookie: `${localCookie.name}=${localCookie.value}`, "x-csrf-token": login.json().data.csrfToken } });
const authorizationUrl = new URL(linkStart.json().data.authorizationUrl);
const state = authorizationUrl.searchParams.get("state")!;
const nonce = authorizationUrl.searchParams.get("nonce")!;
const tenantId = "11111111-1111-4111-8111-111111111111";
const clientId = "22222222-2222-4222-8222-222222222222";
const objectId = "33333333-3333-4333-8333-333333333333";
const { publicKey, privateKey } = await generateKeyPair("RS256");
const publicJwk = { ...await exportJWK(publicKey), kid: "entra-test-key", alg: "RS256", use: "sig" };
const idToken = await new SignJWT({ tid: tenantId, oid: objectId, nonce, name: "Entra Owner", preferred_username: "owner@example.com" })
.setProtectedHeader({ alg: "RS256", kid: "entra-test-key" })
.setIssuer(`https://login.microsoftonline.com/${tenantId}/v2.0`)
.setAudience(clientId)
.setIssuedAt()
.setExpirationTime("5m")
.sign(privateKey);
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
const url = String(input instanceof Request ? input.url : input);
if (url.endsWith("/oauth2/v2.0/token")) return new Response(JSON.stringify({ id_token: idToken }), { status: 200, headers: { "content-type": "application/json" } });
if (url.endsWith("/discovery/v2.0/keys")) return new Response(JSON.stringify({ keys: [publicJwk] }), { status: 200, headers: { "content-type": "application/json" } });
return new Response("not found", { status: 404 });
}));
try {
const callback = await app.inject({ method: "GET", url: `${apiPaths.auth.entraCallback}?code=one-time-code&state=${encodeURIComponent(state)}` });
expect(callback.statusCode).toBe(302);
expect(callback.headers.location).toBe("https://projectbrain.example.com/settings");
const entraCookie = callback.cookies.find((item) => item.name === "projectbrain_session");
expect(entraCookie).toBeDefined();
const identity = await prisma.externalIdentity.findUnique({ where: { provider_tenantId_subject: { provider: "microsoft-entra", tenantId, subject: objectId } } });
expect(identity?.preferredUsername).toBe("owner@example.com");
expect((await app.inject({ method: "GET", url: apiPaths.auth.session, headers: { cookie: `${entraCookie!.name}=${entraCookie!.value}` } })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: `${apiPaths.auth.entraCallback}?code=replay&state=${encodeURIComponent(state)}` })).headers.location).toContain("expired_attempt");
} finally {
vi.unstubAllGlobals();
await app.close();
}
}, 15_000);
it("returns 403 until a member receives project access", async () => {
const app = await buildApp({ env: testEnv() });
const member = await prisma.user.create({ data: { username: "database-member", passwordHash: await hashPassword("database-member-password"), role: "member" } });
const session = await createSession(testEnv(), { id: member.id, username: member.username, role: "member", credentialVersion: member.credentialVersion });
const cookie = `projectbrain_session=${app.signCookie(session.token)}`;
const projectCreateDenied = await app.inject({
method: "POST",
url: "/api/projects",
headers: { cookie, "x-csrf-token": session.csrfToken },
payload: { name: "Unauthorized project", description: "A member must not provision a Gitea repository through project creation." },
});
expect(projectCreateDenied.statusCode).toBe(403);
expect(projectCreateDenied.json().error.code).toBe("PROJECT_CREATION_FORBIDDEN");
const denied = await app.inject({ method: "GET", url: "/api/projects/widefrog", headers: { cookie } });
expect(denied.statusCode).toBe(403);
expect(denied.json().error.code).toBe("PROJECT_ACCESS_DENIED");
const project = await prisma.project.findUniqueOrThrow({ where: { slug: "widefrog" } });
await prisma.projectMembership.create({ data: { projectId: project.id, userId: member.id, role: "reader" } });
const allowed = await app.inject({ method: "GET", url: "/api/projects/widefrog", headers: { cookie } });
expect(allowed.statusCode).toBe(200);
const writeDenied = await app.inject({ method: "PATCH", url: "/api/projects/widefrog", headers: { cookie, "x-csrf-token": session.csrfToken }, payload: { name: "Forbidden update" } });
expect(writeDenied.statusCode).toBe(403);
await app.close();
});
it("replays a core-loop mutation durably after API restart", async () => {
const firstApp = await buildApp({ env: testEnv() });
const login = await firstApp.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } });
const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session");
const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`;
const csrfToken = login.json().data.csrfToken as string;
const headers = { cookie, "x-csrf-token": csrfToken, "idempotency-key": "database-restart-replay" };
const payload = { type: "project update", title: "Durable idempotency restart proof", summary: "This entry must be created once across API restarts.", source: "database-test" };
const created = await firstApp.inject({ method: "POST", url: "/api/projects/widefrog/operating-log", headers, payload });
expect(created.statusCode).toBe(201);
await firstApp.close();
const restartedApp = await buildApp({ env: testEnv() });
const replayed = await restartedApp.inject({ method: "POST", url: "/api/projects/widefrog/operating-log", headers, payload });
expect(replayed.statusCode).toBe(201);
expect(replayed.json().data.id).toBe(created.json().data.id);
expect(await prisma.operatingLogEntry.count({ where: { title: payload.title } })).toBe(1);
expect(await prisma.idempotencyRecord.count({ where: { key: { contains: "database-restart-replay" } } })).toBe(1);
await restartedApp.close();
}, 15_000);
it("executes the complete governed core loop in PostgreSQL mode", async () => {
const app = await buildApp({ env: testEnv() });
const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } });
const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session");
const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`;
const csrfToken = login.json().data.csrfToken as string;
let operation = 0;
const mutate = (method: "POST" | "PATCH", url: string, payload: Record<string, unknown> = {}) => app.inject({
method, url, payload,
headers: { cookie, "x-csrf-token": csrfToken, "idempotency-key": `core-loop-database-proof-${++operation}` },
});
const capture = await mutate("POST", "/api/projects/widefrog/capture-items", { type: "note", content: "Core-loop database proof source material", source: "database-integration-test" });
expect(capture.statusCode).toBe(201);
coreLoopIds.capture.push(capture.json().data.id);
expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${capture.json().data.id}/classify`)).statusCode).toBe(200);
const queued = await mutate("POST", `/api/projects/widefrog/capture-items/${capture.json().data.id}/send-to-review`);
expect(queued.statusCode).toBe(201);
const reviewId = queued.json().data.createdEntityId as string;
coreLoopIds.review.push(reviewId);
const concurrentReviewDecisions = await Promise.all([
mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`, { reason: "Accepted by database core-loop proof A" }),
mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`, { reason: "Accepted by database core-loop proof B" }),
]);
expect(concurrentReviewDecisions.map((response) => response.statusCode).sort()).toEqual([200, 409]);
expect(concurrentReviewDecisions.find((response) => response.statusCode === 409)!.json().error.code).toBe("REVIEW_ALREADY_DECIDED");
const materialized = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/materialize-truth`);
expect(materialized.statusCode).toBe(200);
const truthId = materialized.json().data.createdEntityId as string;
coreLoopIds.truth.push(truthId);
const conversionCapture = await mutate("POST", "/api/projects/widefrog/capture-items", { type: "note", content: "Convert this reviewed decision into a durable truth artifact", source: "database-conversion-test" });
expect(conversionCapture.statusCode).toBe(201);
coreLoopIds.capture.push(conversionCapture.json().data.id);
expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${conversionCapture.json().data.id}/classify`)).statusCode).toBe(200);
const conversionQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${conversionCapture.json().data.id}/send-to-review`);
expect(conversionQueue.statusCode).toBe(201);
const conversionReviewId = conversionQueue.json().data.createdEntityId as string;
coreLoopIds.review.push(conversionReviewId);
const converted = await mutate("POST", `/api/projects/widefrog/review-queue/${conversionReviewId}/convert`, { target: "decision", reason: "Database artifact proof" });
expect(converted.statusCode).toBe(200);
const convertedTruthId = converted.json().data.createdEntityId as string;
const convertedTruth = await prisma.acceptedTruthItem.findFirst({ where: { slug: convertedTruthId } });
expect(convertedTruth).toMatchObject({ kind: "decision", status: "converted" });
coreLoopIds.truth.push(convertedTruth!.id);
const duplicateConversion = await mutate("POST", `/api/projects/widefrog/review-queue/${conversionReviewId}/convert`, { target: "decision" });
expect(duplicateConversion.statusCode).toBe(409);
expect(duplicateConversion.json().error.code).toBe("REVIEW_ALREADY_DECIDED");
const prepared = await mutate("POST", `/api/projects/widefrog/accepted-truth/${truthId}/project-updates`);
expect(prepared.statusCode).toBe(200);
const updateId = prepared.json().data.createdEntityId as string;
coreLoopIds.update.push(updateId);
const concurrentUpdateApplications = await Promise.all([
mutate("PATCH", `/api/projects/widefrog/project-updates/${updateId}/apply`),
mutate("PATCH", `/api/projects/widefrog/project-updates/${updateId}/apply`),
]);
expect(concurrentUpdateApplications.map((response) => response.statusCode).sort()).toEqual([200, 409]);
expect(concurrentUpdateApplications.find((response) => response.statusCode === 409)!.json().error.code).toBe("PROJECT_UPDATE_ALREADY_APPLIED");
const handoff = await mutate("POST", "/api/projects/widefrog/handoff-bundles", { target: "codex-task", title: "Core-loop database proof handoff", includeAcceptedTruth: true, includeProjectUpdates: true, includeOperatingLog: true, autoSelectContext: true });
expect(handoff.statusCode).toBe(201);
coreLoopIds.handoff.push(handoff.json().data.id);
expect(handoff.json().data.sourceTruthItemIds).toContain(truthId);
const task = await mutate("POST", `/api/projects/widefrog/handoff-bundles/${handoff.json().data.id}/codex-task`, { goal: "Execute the database core-loop proof", validationCommands: ["pnpm validate:all"] });
expect(task.statusCode).toBe(201);
coreLoopIds.task.push(task.json().data.id);
const imported = await mutate("POST", `/api/projects/widefrog/codex-tasks/${task.json().data.id}/import-output`, { sourceRun: "core-loop-database-proof", summary: "Execution feedback returned to governance.", outputText: "Changed files: none. Validation passed. Review this evidence." });
expect(imported.statusCode).toBe(201);
coreLoopIds.runImport.push(imported.json().data.importDto.id);
coreLoopIds.review.push(imported.json().data.mutation.createdEntityId);
const publicId = (value: string) => ({ OR: [{ id: value }, { slug: value }, { readableId: value }] });
expect(await prisma.captureItem.count({ where: publicId(capture.json().data.id) })).toBe(1);
expect(await prisma.acceptedTruthItem.count({ where: { AND: [publicId(truthId), { status: "accepted" }] } })).toBe(1);
expect(await prisma.projectUpdate.count({ where: { AND: [publicId(updateId), { status: "applied" }] } })).toBe(1);
expect(await prisma.codexTask.count({ where: { AND: [publicId(task.json().data.id), { status: "imported" }] } })).toBe(1);
expect(await prisma.reviewQueueItem.count({ where: publicId(imported.json().data.mutation.createdEntityId) })).toBe(1);
await app.close();
}, 15_000);
});