156 lines
6.5 KiB
TypeScript
156 lines
6.5 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|