299 lines
13 KiB
TypeScript
299 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,
|
|
type RegistrationTransactionEvent,
|
|
} 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 fixedNow = Date.parse("2026-07-28T06:00:00.000Z");
|
|
const privacyNoticeVersion = "p0a-notice-v1";
|
|
const roots: string[] = [];
|
|
const services: RegistrationService[] = [];
|
|
|
|
interface Snapshot {
|
|
challenges: number;
|
|
consents: number;
|
|
credits: number;
|
|
inviteUsedCount: number;
|
|
ledger: number;
|
|
profiles: number;
|
|
sessions: number;
|
|
users: number;
|
|
}
|
|
|
|
function createHarness(inviteCode = "DADA-WP1-VALID") {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-01-"));
|
|
roots.push(root);
|
|
const databasePath = join(root, "dada.sqlite3");
|
|
const resend = new MockResendAdapter();
|
|
const transactionEvents: RegistrationTransactionEvent[] = [];
|
|
const service = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x31),
|
|
clock: () => fixedNow,
|
|
codeGenerator: () => "482913",
|
|
currentPrivacyNoticeVersion: privacyNoticeVersion,
|
|
databasePath,
|
|
inviteCodeGenerator: () => inviteCode,
|
|
invitePepper: Buffer.alloc(32, 0x32),
|
|
onTransaction: (event) => transactionEvents.push(event),
|
|
resend,
|
|
sessionPepper: Buffer.alloc(32, 0x33),
|
|
});
|
|
services.push(service);
|
|
return { databasePath, resend, service, transactionEvents };
|
|
}
|
|
|
|
function withDatabase<T>(databasePath: string, operation: (database: any) => T): T {
|
|
const database = new Database(databasePath);
|
|
try {
|
|
return operation(database);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
function seedOccupiedSlots(databasePath: string, count: number) {
|
|
withDatabase(databasePath, (database) => {
|
|
const insert = database.prepare(`
|
|
INSERT INTO users (
|
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
|
registration_id, created_at
|
|
) VALUES (?, ?, 'user', 'active', 1, ?, ?)
|
|
`);
|
|
for (let index = 0; index < count; index += 1) {
|
|
insert.run(randomUUID(), `occupied-${index}@example.invalid`, randomUUID(), fixedNow - 1_000);
|
|
}
|
|
});
|
|
}
|
|
|
|
function snapshot(databasePath: string): Snapshot {
|
|
return withDatabase(databasePath, (database) => ({
|
|
challenges: database.prepare("SELECT COUNT(*) AS count FROM email_challenges").get().count,
|
|
consents: database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count,
|
|
credits: database.prepare("SELECT COUNT(*) AS count FROM credit_accounts").get().count,
|
|
inviteUsedCount: database.prepare("SELECT COALESCE(SUM(used_count), 0) AS count FROM invite_codes").get().count,
|
|
ledger: database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get().count,
|
|
profiles: database.prepare("SELECT COUNT(*) AS count FROM user_profiles").get().count,
|
|
sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
|
|
users: database.prepare("SELECT COUNT(*) AS count FROM users").get().count,
|
|
}));
|
|
}
|
|
|
|
function writeEvidence(relativeDirectory: string, file: string, value: unknown) {
|
|
const directory = process.env[relativeDirectory];
|
|
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-001-valid-register", () => {
|
|
it("commits the account, invite use, initial credit, consent and one 30-day session once", async () => {
|
|
const harness = createHarness();
|
|
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 3 });
|
|
seedOccupiedSlots(harness.databasePath, 9);
|
|
const before = snapshot(harness.databasePath);
|
|
|
|
const sent = await harness.service.sendRegistrationCode({
|
|
email: "new-user@example.invalid",
|
|
inviteCode: invite.code,
|
|
});
|
|
const code = harness.resend.readLatestCode("new-user@example.invalid");
|
|
expect(code).toBe("482913");
|
|
expect(sent).toMatchObject({ status: "verification_sent" });
|
|
|
|
const request = {
|
|
code,
|
|
creatorName: "Dada Creator",
|
|
idempotencyKey: "wp1-valid-register-idempotency-key-0001",
|
|
privacyConsentAccepted: true,
|
|
privacyNoticeVersion,
|
|
registrationId: sent.registrationId,
|
|
socialId: "@@dada_creator",
|
|
};
|
|
const completed = harness.service.completeRegistration(request);
|
|
const replayed = harness.service.completeRegistration(request);
|
|
const session = harness.service.readUserSession(completed.sessionToken);
|
|
const after = snapshot(harness.databasePath);
|
|
|
|
expect(completed).toMatchObject({
|
|
credits: { availableBalance: 10, reservedBalance: 0 },
|
|
sessionExpiresAt: fixedNow + 30 * 86_400_000,
|
|
status: "registered",
|
|
user: { creatorName: "Dada Creator", role: "user", socialId: "@dada_creator", status: "active" },
|
|
});
|
|
expect(replayed).toEqual(completed);
|
|
expect(session).toMatchObject({ audience: "user", userId: completed.user.userId });
|
|
expect(after).toEqual({
|
|
challenges: before.challenges + 1,
|
|
consents: before.consents + 1,
|
|
credits: before.credits + 1,
|
|
inviteUsedCount: before.inviteUsedCount + 1,
|
|
ledger: before.ledger + 1,
|
|
profiles: before.profiles + 1,
|
|
sessions: before.sessions + 1,
|
|
users: before.users + 1,
|
|
});
|
|
|
|
const stored = withDatabase(harness.databasePath, (database) => ({
|
|
challenge: database.prepare("SELECT code_hmac, consumed_at FROM email_challenges").get(),
|
|
ledger: database.prepare("SELECT entry_type, amount, available_before, available_after, reserved_before, reserved_after FROM credit_ledger").get(),
|
|
session: database.prepare("SELECT token_digest, audience, revoked_at FROM sessions").get(),
|
|
}));
|
|
expect(stored.challenge.code_hmac).not.toContain(code);
|
|
expect(stored.challenge.consumed_at).toBe(fixedNow);
|
|
expect(stored.ledger).toEqual({
|
|
amount: 10,
|
|
available_after: 10,
|
|
available_before: 0,
|
|
entry_type: "registration_grant",
|
|
reserved_after: 0,
|
|
reserved_before: 0,
|
|
});
|
|
expect(stored.session).toMatchObject({ audience: "user", revoked_at: null });
|
|
expect(stored.session.token_digest).not.toBe(completed.sessionToken);
|
|
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "request.json", {
|
|
operation: ["REGISTER_SEND", "REGISTER_COMPLETE", "AUTH_SESSION"],
|
|
privacy_notice_version: privacyNoticeVersion,
|
|
sensitive_fields: "redacted",
|
|
});
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "response.json", {
|
|
audience: session?.audience,
|
|
available_balance: completed.credits.availableBalance,
|
|
reserved_balance: completed.credits.reservedBalance,
|
|
session_days: 30,
|
|
status: completed.status,
|
|
});
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "db-diff.json", { after, before, plaintext_secret_fields: 0 });
|
|
});
|
|
});
|
|
|
|
describe("TDD-WP1-AUTH-002-invalid-invite", () => {
|
|
it.each(["expired", "disabled", "exhausted", "not_found"] as const)(
|
|
"rejects %s before creating a challenge or calling Resend",
|
|
async (scenario) => {
|
|
const harness = createHarness(`DADA-WP1-${scenario.toUpperCase()}`);
|
|
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 });
|
|
withDatabase(harness.databasePath, (database) => {
|
|
if (scenario === "expired") database.prepare("UPDATE invite_codes SET expires_at = ?").run(fixedNow - 1);
|
|
if (scenario === "disabled") database.prepare("UPDATE invite_codes SET status = 'disabled'").run();
|
|
if (scenario === "exhausted") database.prepare("UPDATE invite_codes SET used_count = max_uses").run();
|
|
});
|
|
const before = snapshot(harness.databasePath);
|
|
const expectedReason = scenario === "not_found" ? "invite_not_found" : `invite_${scenario}`;
|
|
|
|
await expect(
|
|
harness.service.sendRegistrationCode({
|
|
email: "rejected-user@example.invalid",
|
|
inviteCode: scenario === "not_found" ? `${invite.code}-missing` : invite.code,
|
|
}),
|
|
).rejects.toMatchObject({ code: "REGISTRATION_REJECTED", reason: expectedReason });
|
|
|
|
expect(snapshot(harness.databasePath)).toEqual(before);
|
|
expect(harness.resend.calls).toEqual([]);
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", `${scenario}-response.json`, {
|
|
code: "REGISTRATION_REJECTED",
|
|
field: "invite_code",
|
|
reason: expectedReason,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("writes the aggregate zero-side-effect evidence", () => {
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", "db-diff.json", {
|
|
challenge_delta: 0,
|
|
consent_delta: 0,
|
|
credit_delta: 0,
|
|
invite_used_count_delta: 0,
|
|
session_delta: 0,
|
|
user_delta: 0,
|
|
});
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", "external-calls.json", { resend_calls: 0 });
|
|
});
|
|
});
|
|
|
|
describe("TDD-WP1-AUTH-001-final-recheck-race", () => {
|
|
it.each(["exhausted", "disabled", "expired", "stage_limit"] as const)(
|
|
"rechecks %s under BEGIN IMMEDIATE and replays the same failure",
|
|
async (scenario) => {
|
|
const harness = createHarness(`DADA-WP1-RACE-${scenario.toUpperCase()}`);
|
|
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 });
|
|
seedOccupiedSlots(harness.databasePath, 9);
|
|
const sent = await harness.service.sendRegistrationCode({
|
|
email: "race-user@example.invalid",
|
|
inviteCode: invite.code,
|
|
});
|
|
const code = harness.resend.readLatestCode("race-user@example.invalid");
|
|
withDatabase(harness.databasePath, (database) => {
|
|
if (scenario === "exhausted") database.prepare("UPDATE invite_codes SET used_count = max_uses").run();
|
|
if (scenario === "disabled") database.prepare("UPDATE invite_codes SET status = 'disabled'").run();
|
|
if (scenario === "expired") database.prepare("UPDATE invite_codes SET expires_at = ?").run(fixedNow - 1);
|
|
if (scenario === "stage_limit") {
|
|
database.prepare(`
|
|
INSERT INTO users (
|
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
|
registration_id, created_at
|
|
) VALUES (?, ?, 'user', 'active', 1, ?, ?)
|
|
`).run(randomUUID(), "tenth-user@example.invalid", randomUUID(), fixedNow);
|
|
}
|
|
});
|
|
const before = snapshot(harness.databasePath);
|
|
const request = {
|
|
code,
|
|
creatorName: "Race User",
|
|
idempotencyKey: `wp1-race-idempotency-key-${scenario}-0001`,
|
|
privacyConsentAccepted: true,
|
|
privacyNoticeVersion,
|
|
registrationId: sent.registrationId,
|
|
socialId: "@race_user",
|
|
};
|
|
const expectedReason = scenario === "stage_limit" ? "stage_limit_reached" : `invite_${scenario}`;
|
|
|
|
let first: RegistrationError | undefined;
|
|
let second: RegistrationError | undefined;
|
|
try { harness.service.completeRegistration(request); } catch (error) { first = error as RegistrationError; }
|
|
try { harness.service.completeRegistration(request); } catch (error) { second = error as RegistrationError; }
|
|
|
|
expect(first).toMatchObject({ code: "REGISTRATION_REJECTED", reason: expectedReason });
|
|
expect(second).toMatchObject({ code: first?.code, reason: first?.reason });
|
|
expect(snapshot(harness.databasePath)).toEqual(before);
|
|
expect(harness.transactionEvents.filter((event) => event.operation === "registration_complete"))
|
|
.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ mode: "BEGIN IMMEDIATE", outcome: "rejected" }),
|
|
expect.objectContaining({ mode: "BEGIN IMMEDIATE", outcome: "idempotent_replay" }),
|
|
]));
|
|
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", `${scenario}-response.json`, {
|
|
code: first?.code,
|
|
reason: first?.reason,
|
|
retry_code: second?.code,
|
|
retry_reason: second?.reason,
|
|
});
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", `${scenario}-db-diff.json`, { after: snapshot(harness.databasePath), before });
|
|
},
|
|
);
|
|
|
|
it("writes the aggregate transaction evidence", () => {
|
|
writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", "transaction-trace.json", {
|
|
business_side_effects: 0,
|
|
failure_replay: "stable",
|
|
transaction_mode: "BEGIN IMMEDIATE",
|
|
});
|
|
});
|
|
});
|