feat: implement TASK-WP1-01 registration transaction
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user