125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { createApp } from "../../apps/api/src/app.js";
|
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
|
import { isErrorEnvelope } from "../../packages/shared-contracts/src/index.js";
|
|
|
|
const fixedNow = Date.parse("2026-07-28T06:00:00.000Z");
|
|
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
|
const roots: string[] = [];
|
|
const services: RegistrationService[] = [];
|
|
|
|
function createRegistrationService() {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-01-api-"));
|
|
roots.push(root);
|
|
const resend = new MockResendAdapter();
|
|
const registration = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x41),
|
|
clock: () => fixedNow,
|
|
codeGenerator: () => "572914",
|
|
currentPrivacyNoticeVersion: "p0a-notice-v1",
|
|
databasePath: join(root, "dada.sqlite3"),
|
|
inviteCodeGenerator: () => "DADA-WP1-API",
|
|
invitePepper: Buffer.alloc(32, 0x42),
|
|
resend,
|
|
sessionPepper: Buffer.alloc(32, 0x43),
|
|
});
|
|
services.push(registration);
|
|
return { registration, resend };
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const service of services.splice(0)) {
|
|
try { service.close(); } catch { /* already closed by the test */ }
|
|
}
|
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
|
});
|
|
|
|
describe("TASK-WP1-01 registration API contract", () => {
|
|
it("resolves REGISTER_SEND and REGISTER_COMPLETE to one OpenAPI operation each", async () => {
|
|
const { registration, resend } = createRegistrationService();
|
|
const invite = registration.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 });
|
|
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
|
|
|
const sent = await app.inject({
|
|
headers: writeHeaders,
|
|
method: "POST",
|
|
payload: { email: "api-user@example.invalid", invite_code: invite.code },
|
|
url: "/api/v1/auth/register/send",
|
|
});
|
|
expect(sent.statusCode).toBe(200);
|
|
expect(sent.json()).toMatchObject({ status: "verification_sent" });
|
|
|
|
const completed = await app.inject({
|
|
headers: { ...writeHeaders, "idempotency-key": "wp1-api-idempotency-key-000000000001" },
|
|
method: "POST",
|
|
payload: {
|
|
creator_name: "API Creator",
|
|
privacy_consent_accepted: true,
|
|
privacy_notice_version: "p0a-notice-v1",
|
|
registration_id: sent.json().registration_id,
|
|
social_id: "@@api_creator",
|
|
verification_code: resend.readLatestCode("api-user@example.invalid"),
|
|
},
|
|
url: "/api/v1/auth/register/complete",
|
|
});
|
|
expect(completed.statusCode).toBe(200);
|
|
expect(completed.json()).toMatchObject({
|
|
credits: { available_balance: 10, reserved_balance: 0 },
|
|
status: "registered",
|
|
user: { role: "user", social_id: "@api_creator", status: "active" },
|
|
});
|
|
expect(completed.headers["set-cookie"]).toContain("dada_session=");
|
|
expect(completed.headers["set-cookie"]).toContain("HttpOnly");
|
|
expect(completed.headers["set-cookie"]).toContain("SameSite=Strict");
|
|
|
|
const session = await app.inject({
|
|
headers: { cookie: completed.headers["set-cookie"], host: "127.0.0.1:43121" },
|
|
method: "GET",
|
|
url: "/api/v1/auth/session",
|
|
});
|
|
expect(session.statusCode).toBe(200);
|
|
expect(session.json()).toMatchObject({
|
|
audience: "user",
|
|
authenticated: true,
|
|
credits: { available_balance: 10, reserved_balance: 0 },
|
|
});
|
|
|
|
const openapi = app.swagger() as { paths?: Record<string, Record<string, { operationId?: string }>> };
|
|
const operationIds = Object.values(openapi.paths ?? {}).flatMap((path) =>
|
|
Object.values(path).map((operation) => operation.operationId),
|
|
);
|
|
expect(operationIds.filter((id) => id === "sendRegistrationCode")).toHaveLength(1);
|
|
expect(operationIds.filter((id) => id === "completeRegistration")).toHaveLength(1);
|
|
await app.close();
|
|
});
|
|
|
|
it("returns a stable field error and does not send mail for an invalid invite", async () => {
|
|
const { registration, resend } = createRegistrationService();
|
|
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
|
const response = await app.inject({
|
|
headers: writeHeaders,
|
|
method: "POST",
|
|
payload: { email: "api-rejected@example.invalid", invite_code: "missing-invite" },
|
|
url: "/api/v1/auth/register/send",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(isErrorEnvelope(response.json())).toBe(true);
|
|
expect(response.json()).toMatchObject({
|
|
error: {
|
|
code: "REGISTRATION_REJECTED",
|
|
details: { field_errors: [{ field: "invite_code", message_key: "auth.invite.not_found" }] },
|
|
message_key: "auth.registration.rejected",
|
|
},
|
|
});
|
|
expect(resend.calls).toEqual([]);
|
|
await app.close();
|
|
});
|
|
});
|