284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
import { createRequire } from "node:module";
|
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { RegistrationError, RegistrationService } from "../../apps/api/src/registration.js";
|
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
|
|
|
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
|
const Database = requireFromApi("better-sqlite3");
|
|
const roots: string[] = [];
|
|
const services: RegistrationService[] = [];
|
|
|
|
function createHarness() {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-02-"));
|
|
roots.push(root);
|
|
const databasePath = join(root, "dada.sqlite3");
|
|
const resend = new MockResendAdapter();
|
|
let now = Date.parse("2026-07-28T08:00:00.000Z");
|
|
let inviteSequence = 0;
|
|
let codeSequence = 100_000;
|
|
const service = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x51),
|
|
clock: () => now,
|
|
codeGenerator: () => String(codeSequence++),
|
|
currentPrivacyNoticeVersion: "p0a-notice-v1",
|
|
databasePath,
|
|
inviteCodeGenerator: () => `DADA-WP1-02-${String(inviteSequence++).padStart(3, "0")}`,
|
|
invitePepper: Buffer.alloc(32, 0x52),
|
|
resend,
|
|
sessionPepper: Buffer.alloc(32, 0x53),
|
|
});
|
|
services.push(service);
|
|
return {
|
|
advance(milliseconds: number) { now += milliseconds; },
|
|
databasePath,
|
|
now: () => now,
|
|
resend,
|
|
service,
|
|
};
|
|
}
|
|
|
|
function withDatabase<T>(databasePath: string, operation: (database: any) => T): T {
|
|
const database = new Database(databasePath);
|
|
try { return operation(database); } finally { database.close(); }
|
|
}
|
|
|
|
function seedUser(
|
|
databasePath: string,
|
|
input: { email: string; role?: "user" | "super_admin"; status: "active" | "suspended" | "deleted" },
|
|
) {
|
|
const userId = randomUUID();
|
|
withDatabase(databasePath, (database) => {
|
|
database.prepare(`
|
|
INSERT INTO users (
|
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
|
registration_id, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(userId, input.email, input.role ?? "user", input.status, input.role === "super_admin" ? 0 : 1, randomUUID(), Date.now());
|
|
database.prepare(`
|
|
INSERT INTO user_profiles (
|
|
user_id, creator_name, social_id, private_content_notice_version,
|
|
private_content_notice_acknowledged_at
|
|
) VALUES (?, ?, ?, NULL, NULL)
|
|
`).run(userId, "Fixture User", "@fixture_user");
|
|
database.prepare(`
|
|
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
|
|
VALUES (?, 10, 0, ?)
|
|
`).run(userId, Date.now());
|
|
if (input.role === "super_admin") {
|
|
database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
|
|
}
|
|
});
|
|
return userId;
|
|
}
|
|
|
|
function writeEvidence(environmentName: string, file: string, value: unknown) {
|
|
const directory = process.env[environmentName];
|
|
if (!directory) return;
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const service of services.splice(0)) service.close();
|
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
|
});
|
|
|
|
describe("TDD-WP1-AUTH-003-entry-state-matrix", () => {
|
|
it("keeps register and login entry behavior separate for every account state", async () => {
|
|
const harness = createHarness();
|
|
const invite = harness.service.createInvite({ expiresAt: harness.now() + 86_400_000, maxUses: 4 });
|
|
seedUser(harness.databasePath, { email: "active@example.invalid", status: "active" });
|
|
seedUser(harness.databasePath, { email: "deleted@example.invalid", status: "deleted" });
|
|
seedUser(harness.databasePath, { email: "suspended@example.invalid", status: "suspended" });
|
|
|
|
const registrationResults: Record<string, string> = {};
|
|
for (const state of ["unregistered", "active", "deleted", "suspended"] as const) {
|
|
const email = state === "unregistered" ? "new@example.invalid" : `${state}@example.invalid`;
|
|
try {
|
|
const sent = await harness.service.sendRegistrationCode({ email, inviteCode: invite.code });
|
|
registrationResults[state] = sent.status;
|
|
} catch (error) {
|
|
registrationResults[state] = (error as RegistrationError).reason;
|
|
}
|
|
harness.advance(61_000);
|
|
}
|
|
expect(registrationResults).toEqual({
|
|
active: "registration_login_required",
|
|
deleted: "verification_sent",
|
|
suspended: "account_suspended",
|
|
unregistered: "verification_sent",
|
|
});
|
|
|
|
const loginResults: Record<string, string> = {};
|
|
for (const state of ["unregistered", "active", "deleted", "suspended"] as const) {
|
|
const email = state === "unregistered" ? "missing@example.invalid" : `${state}@example.invalid`;
|
|
try {
|
|
const sent = await harness.service.sendLoginCode({ clientKey: `matrix-${state}`, email });
|
|
loginResults[state] = sent.status;
|
|
if (state === "active") {
|
|
const completed = harness.service.completeLogin({
|
|
clientKey: `matrix-${state}`,
|
|
code: harness.resend.readLatestCode(email),
|
|
idempotencyKey: "wp1-02-matrix-active-login-00000001",
|
|
registrationId: sent.registrationId,
|
|
});
|
|
expect(harness.service.readUserSession(completed.sessionToken)?.audience).toBe("user");
|
|
}
|
|
} catch (error) {
|
|
loginResults[state] = (error as RegistrationError).reason;
|
|
}
|
|
harness.advance(61_000);
|
|
}
|
|
expect(loginResults).toEqual({
|
|
active: "verification_sent",
|
|
deleted: "login_registration_required",
|
|
suspended: "account_suspended",
|
|
unregistered: "login_registration_required",
|
|
});
|
|
|
|
const deletedRows = withDatabase(harness.databasePath, (database) => database.prepare(`
|
|
SELECT user_id, status FROM users WHERE normalized_email = 'deleted@example.invalid' ORDER BY created_at
|
|
`).all());
|
|
expect(deletedRows).toHaveLength(1);
|
|
expect(deletedRows[0].status).toBe("deleted");
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "response.json", { login: loginResults, registration: registrationResults });
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "db-diff.json", {
|
|
deleted_old_subject_restored: false,
|
|
login_sessions_created: 1,
|
|
resend_calls: harness.resend.calls.length,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("TDD-WP1-AUTH-004-challenge-guards", () => {
|
|
it("enforces one use, ten minutes, sixty seconds and consecutive-failure limiting", async () => {
|
|
const harness = createHarness();
|
|
seedUser(harness.databasePath, { email: "guard@example.invalid", status: "active" });
|
|
|
|
const first = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" });
|
|
const firstCode = harness.resend.readLatestCode("guard@example.invalid");
|
|
const loggedIn = harness.service.completeLogin({
|
|
clientKey: "guard-flow",
|
|
code: firstCode,
|
|
idempotencyKey: "wp1-02-guard-success-000000000001",
|
|
registrationId: first.registrationId,
|
|
});
|
|
expect(harness.service.readUserSession(loggedIn.sessionToken)).toBeDefined();
|
|
expect(() => harness.service.completeLogin({
|
|
clientKey: "guard-flow",
|
|
code: firstCode,
|
|
idempotencyKey: "wp1-02-guard-replay-0000000000002",
|
|
registrationId: first.registrationId,
|
|
})).toThrowError(expect.objectContaining({ reason: "challenge_invalid" }));
|
|
|
|
harness.advance(61_000);
|
|
const expiring = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" });
|
|
const expiringCode = harness.resend.readLatestCode("guard@example.invalid");
|
|
harness.advance(600_001);
|
|
expect(() => harness.service.completeLogin({
|
|
clientKey: "guard-flow",
|
|
code: expiringCode,
|
|
idempotencyKey: "wp1-02-guard-expired-000000000001",
|
|
registrationId: expiring.registrationId,
|
|
})).toThrowError(expect.objectContaining({ reason: "challenge_expired" }));
|
|
|
|
const resendBlocked = await harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" });
|
|
await expect(harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" }))
|
|
.rejects.toMatchObject({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "resend_too_soon" });
|
|
|
|
harness.advance(61_000);
|
|
const guarded = await harness.service.sendLoginCode({ clientKey: "failure-flow", email: "guard@example.invalid" });
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
expect(() => harness.service.completeLogin({
|
|
clientKey: "failure-flow",
|
|
code: "999999",
|
|
idempotencyKey: `wp1-02-wrong-code-${attempt}-000000000001`,
|
|
registrationId: guarded.registrationId,
|
|
})).toThrowError(expect.objectContaining({ reason: "challenge_invalid" }));
|
|
}
|
|
expect(() => harness.service.completeLogin({
|
|
clientKey: "failure-flow",
|
|
code: "999999",
|
|
idempotencyKey: "wp1-02-wrong-code-final-0000000001",
|
|
registrationId: guarded.registrationId,
|
|
})).toThrowError(expect.objectContaining({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "too_many_attempts" }));
|
|
|
|
const databaseState = withDatabase(harness.databasePath, (database) => ({
|
|
consumed: database.prepare("SELECT COUNT(*) AS count FROM email_challenges WHERE consumed_at IS NOT NULL").get().count,
|
|
failed: database.prepare("SELECT MAX(failure_count) AS count FROM email_challenges").get().count,
|
|
sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
|
|
}));
|
|
expect(databaseState).toEqual({ consumed: 1, failed: 5, sessions: 1 });
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "response.json", {
|
|
expired: "challenge_expired",
|
|
replay: "challenge_invalid",
|
|
resend: "resend_too_soon",
|
|
rate_limit: "too_many_attempts",
|
|
});
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "db-diff.json", databaseState);
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "external-calls.json", {
|
|
resend_calls: harness.resend.calls.length,
|
|
resend_calls_after_block: harness.resend.calls.length,
|
|
blocked_challenge_id: resendBlocked.registrationId,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("TDD-WP1-AUTH-004-session-revocation", () => {
|
|
it.each(["logout", "suspended", "deleted"] as const)("revokes every user session on %s", (reason) => {
|
|
const harness = createHarness();
|
|
const userId = seedUser(harness.databasePath, { email: `${reason}@example.invalid`, status: "active" });
|
|
const first = harness.service.issueAuthenticatedSession(userId, "user");
|
|
const second = harness.service.issueAuthenticatedSession(userId, "user");
|
|
expect(harness.service.readUserSession(first.sessionToken)).toBeDefined();
|
|
expect(harness.service.readUserSession(second.sessionToken)).toBeDefined();
|
|
|
|
if (reason === "logout") {
|
|
const csrf = harness.service.issueUserCsrfToken(first.sessionToken);
|
|
harness.service.logoutUser({ csrfToken: csrf, sessionToken: first.sessionToken });
|
|
} else {
|
|
harness.service.changeUserStatus(userId, reason);
|
|
}
|
|
|
|
expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined();
|
|
expect(harness.service.readUserSession(second.sessionToken)).toBeUndefined();
|
|
const state = withDatabase(harness.databasePath, (database) => ({
|
|
revoked: database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ? AND revoked_at IS NOT NULL").get(userId).count,
|
|
status: database.prepare("SELECT status FROM users WHERE user_id = ?").get(userId).status,
|
|
}));
|
|
expect(state.revoked).toBe(2);
|
|
expect(state.status).toBe(reason === "logout" ? "active" : reason);
|
|
});
|
|
|
|
it.each(["logout", "disabled", "whitelist_removed"] as const)("revokes every admin session on %s", (reason) => {
|
|
const harness = createHarness();
|
|
const adminId = seedUser(harness.databasePath, { email: `admin-${reason}@example.invalid`, role: "super_admin", status: "active" });
|
|
const first = harness.service.issueAuthenticatedSession(adminId, "admin");
|
|
const second = harness.service.issueAuthenticatedSession(adminId, "admin");
|
|
expect(harness.service.readAdminSession(first.sessionToken)).toBeDefined();
|
|
expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined();
|
|
harness.service.revokeAdminSessions(adminId, reason);
|
|
expect(harness.service.readAdminSession(first.sessionToken)).toBeUndefined();
|
|
expect(harness.service.readAdminSession(second.sessionToken)).toBeUndefined();
|
|
expect(harness.service.readAdminSession(harness.service.issueAuthenticatedSession(
|
|
seedUser(harness.databasePath, { email: `audience-${reason}@example.invalid`, role: "super_admin", status: "active" }),
|
|
"admin",
|
|
).sessionToken)).toBeDefined();
|
|
});
|
|
|
|
it("writes aggregate revocation evidence", () => {
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "response.json", {
|
|
admin_reasons: ["logout", "disabled", "whitelist_removed"],
|
|
audience_separation: true,
|
|
user_reasons: ["logout", "suspended", "deleted"],
|
|
});
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "db-diff.json", { sessions_revoked_per_subject: 2 });
|
|
});
|
|
});
|