feat: implement TASK-WP1-03 registration notice
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { 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 fixedNow = Date.parse("2026-07-28T09:00:00.000Z");
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
|
||||
function createHarness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-03-slot-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const resend = new MockResendAdapter();
|
||||
const service = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x71),
|
||||
clock: () => fixedNow,
|
||||
codeGenerator: () => "731905",
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||
databasePath,
|
||||
inviteCodeGenerator: () => `DADA-SLOT-${randomUUID()}`,
|
||||
invitePepper: Buffer.alloc(32, 0x72),
|
||||
resend,
|
||||
sessionPepper: Buffer.alloc(32, 0x73),
|
||||
});
|
||||
services.push(service);
|
||||
return { databasePath, 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 seedSubject(
|
||||
databasePath: string,
|
||||
index: number,
|
||||
role: "user" | "super_admin",
|
||||
status: "active" | "suspended" | "deleted",
|
||||
) {
|
||||
withDatabase(databasePath, (database) => {
|
||||
database.prepare(`
|
||||
INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||
registration_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
randomUUID(),
|
||||
`slot-fixture-${role}-${status}-${index}@example.invalid`,
|
||||
role,
|
||||
status,
|
||||
role === "user" ? 1 : 0,
|
||||
randomUUID(),
|
||||
fixedNow - 1_000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function writeEvidence(file: string, value: unknown) {
|
||||
const directory = process.env.DADA_EVIDENCE_DIR_SLOT;
|
||||
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-SLOT-001-stage-limit", () => {
|
||||
it("counts active and suspended users while excluding deleted users and multiple super_admins", async () => {
|
||||
const harness = createHarness();
|
||||
for (let index = 0; index < 8; index += 1) seedSubject(harness.databasePath, index, "user", "active");
|
||||
seedSubject(harness.databasePath, 8, "user", "suspended");
|
||||
seedSubject(harness.databasePath, 9, "user", "deleted");
|
||||
seedSubject(harness.databasePath, 10, "super_admin", "active");
|
||||
seedSubject(harness.databasePath, 11, "super_admin", "active");
|
||||
|
||||
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 3 });
|
||||
const tenth = await harness.service.sendRegistrationCode({
|
||||
email: "stage-tenth@example.invalid",
|
||||
inviteCode: invite.code,
|
||||
});
|
||||
const completed = harness.service.completeRegistration({
|
||||
code: harness.resend.readLatestCode("stage-tenth@example.invalid"),
|
||||
creatorName: "Tenth User",
|
||||
idempotencyKey: "wp1-03-stage-tenth-complete-00000001",
|
||||
privacyConsentAccepted: true,
|
||||
privacyNoticeVersion: "p0a-registration-notice-v1",
|
||||
registrationId: tenth.registrationId,
|
||||
socialId: "@stage_tenth",
|
||||
});
|
||||
expect(completed.status).toBe("registered");
|
||||
|
||||
await expect(harness.service.sendRegistrationCode({
|
||||
email: "stage-eleventh@example.invalid",
|
||||
inviteCode: invite.code,
|
||||
})).rejects.toMatchObject({ reason: "stage_limit_reached" });
|
||||
|
||||
const counts = withDatabase(harness.databasePath, (database) => ({
|
||||
active_and_suspended_users: database.prepare(`
|
||||
SELECT COUNT(*) AS count FROM users
|
||||
WHERE role = 'user' AND status IN ('active', 'suspended')
|
||||
`).get().count,
|
||||
deleted_users: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'user' AND status = 'deleted'").get().count,
|
||||
super_admins: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'super_admin'").get().count,
|
||||
}));
|
||||
expect(counts).toEqual({ active_and_suspended_users: 10, deleted_users: 1, super_admins: 2 });
|
||||
writeEvidence("response.json", { eleventh: "stage_limit_reached", tenth: completed.status });
|
||||
writeEvidence("db-diff.json", { ...counts, failed_invite_use_delta: 0, failed_user_delta: 0 });
|
||||
});
|
||||
|
||||
it("rechecks capacity after challenge issuance under BEGIN IMMEDIATE", async () => {
|
||||
const harness = createHarness();
|
||||
for (let index = 0; index < 9; index += 1) seedSubject(harness.databasePath, index, "user", "active");
|
||||
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 });
|
||||
const sent = await harness.service.sendRegistrationCode({
|
||||
email: "stage-race@example.invalid",
|
||||
inviteCode: invite.code,
|
||||
});
|
||||
seedSubject(harness.databasePath, 9, "user", "suspended");
|
||||
|
||||
expect(() => harness.service.completeRegistration({
|
||||
code: harness.resend.readLatestCode("stage-race@example.invalid"),
|
||||
creatorName: "Race User",
|
||||
idempotencyKey: "wp1-03-stage-race-complete-000000001",
|
||||
privacyConsentAccepted: true,
|
||||
privacyNoticeVersion: "p0a-registration-notice-v1",
|
||||
registrationId: sent.registrationId,
|
||||
socialId: "@stage_race",
|
||||
})).toThrowError(expect.objectContaining({ reason: "stage_limit_reached" }));
|
||||
|
||||
const state = withDatabase(harness.databasePath, (database) => ({
|
||||
consents: database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count,
|
||||
invite_used: database.prepare("SELECT used_count AS count FROM invite_codes WHERE invite_id = ?").get(invite.inviteId).count,
|
||||
sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
|
||||
users: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'user'").get().count,
|
||||
}));
|
||||
expect(state).toEqual({ consents: 0, invite_used: 0, sessions: 0, users: 10 });
|
||||
writeEvidence("concurrency-trace.json", {
|
||||
final_recheck: "stage_limit_reached",
|
||||
mode: "BEGIN IMMEDIATE",
|
||||
side_effects: state,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user