feat: implement TASK-WP1-03 registration notice

This commit is contained in:
suyx
2026-07-28 16:48:49 +08:00
parent 4d81f9c723
commit aec4c83eca
10 changed files with 1049 additions and 33 deletions
@@ -0,0 +1,127 @@
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 { 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 { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
function createHarness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp1-03-notice-"));
roots.push(root);
const resend = new MockResendAdapter();
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x74),
clock: () => Date.parse("2026-07-28T09:30:00.000Z"),
codeGenerator: () => "418205",
currentPrivacyNoticeVersion: registrationNotice.version,
databasePath: join(root, "dada.sqlite3"),
inviteCodeGenerator: () => "DADA-WP1-03-NOTICE",
invitePepper: Buffer.alloc(32, 0x75),
resend,
sessionPepper: Buffer.alloc(32, 0x76),
});
services.push(registration);
return { registration, resend };
}
function snapshot(registration: RegistrationService) {
return {
consents: registration.database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count,
credits: registration.database.prepare("SELECT COUNT(*) AS count FROM credit_accounts").get().count,
invite_used: registration.database.prepare("SELECT COALESCE(SUM(used_count), 0) AS count FROM invite_codes").get().count,
sessions: registration.database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
users: registration.database.prepare("SELECT COUNT(*) AS count FROM users").get().count,
};
}
function writeEvidence(file: string, value: unknown) {
const directory = process.env.DADA_EVIDENCE_DIR_NOTICE;
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-NOTICE-001-registration-consent", () => {
it("rejects missing/stale consent without side effects and records current consent atomically", async () => {
const { registration, resend } = createHarness();
const invite = registration.createInvite({ expiresAt: Date.parse("2026-07-29T09:30:00.000Z"), maxUses: 1 });
const sent = await registration.sendRegistrationCode({ email: "notice-api@example.invalid", inviteCode: invite.code });
const code = resend.readLatestCode("notice-api@example.invalid");
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
const before = snapshot(registration);
const basePayload = {
creator_name: "Notice User",
privacy_notice_version: registrationNotice.version,
registration_id: sent.registrationId,
social_id: "@notice_user",
verification_code: code,
};
const missingConsent = await app.inject({
headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-missing-0000000000001" },
method: "POST",
payload: { ...basePayload, privacy_consent_accepted: false },
url: "/api/v1/auth/register/complete",
});
expect(missingConsent.statusCode).toBe(400);
expect(missingConsent.json()).toMatchObject({
error: { details: { field_errors: [{ message_key: "auth.privacy.consent_required" }] } },
});
expect(snapshot(registration)).toEqual(before);
const staleVersion = await app.inject({
headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-stale-00000000000001" },
method: "POST",
payload: { ...basePayload, privacy_consent_accepted: true, privacy_notice_version: "stale-notice" },
url: "/api/v1/auth/register/complete",
});
expect(staleVersion.statusCode).toBe(400);
expect(staleVersion.json()).toMatchObject({
error: { details: { field_errors: [{ message_key: "auth.privacy.notice_version_invalid" }] } },
});
expect(snapshot(registration)).toEqual(before);
const completed = await app.inject({
headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-success-0000000000001" },
method: "POST",
payload: { ...basePayload, privacy_consent_accepted: true },
url: "/api/v1/auth/register/complete",
});
expect(completed.statusCode).toBe(200);
const after = snapshot(registration);
expect(after).toEqual({ consents: 1, credits: 1, invite_used: 1, sessions: 1, users: 1 });
const consent = registration.database.prepare("SELECT notice_version, consented_at FROM privacy_consents").get();
expect(consent).toEqual({
consented_at: Date.parse("2026-07-28T09:30:00.000Z"),
notice_version: registrationNotice.version,
});
writeEvidence("response.json", {
accepted: completed.json().status,
missing_consent: missingConsent.json().error.details.field_errors[0].message_key,
stale_version: staleVersion.json().error.details.field_errors[0].message_key,
});
writeEvidence("db-diff.json", {
after,
before,
consent_recorded_at: "2026-07-28T09:30:00.000Z",
notice_content_sha256: registrationNotice.contentSha256,
notice_effective_at: registrationNotice.effectiveAt,
notice_version: registrationNotice.version,
});
await app.close();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
let vite: ViteDevServer;
let webUrl: string;
test.use({ trace: "off" });
test.beforeAll(async () => {
vite = await createServer({
configFile: resolve("apps/web/vite.config.ts"),
root: resolve("apps/web"),
server: { host: "127.0.0.1", port: 0 },
});
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", async ({ page }) => {
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
contentType: "application/json",
status: 200,
body: JSON.stringify({
challenge_expires_at: "2026-07-28T09:10:00.000Z",
registration_id: "00000000-0000-4000-8000-000000000003",
resend_available_at: "2026-07-28T09:01:00.000Z",
status: "verification_sent",
}),
}));
await page.goto(webUrl);
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("textbox", { name: "邀请码" }).fill("DADA-P0A-TEST-7K2");
await page.getByRole("textbox", { name: "邮箱" }).fill("registration@example.invalid");
await page.getByRole("button", { name: "获取验证码" }).click();
await expect(page.getByRole("heading", { name: "完善注册资料" })).toBeVisible();
await expect(page.getByText("邀请码 · 已验证")).toBeVisible();
await expect(page.getByText("邮箱 · 已验证")).toBeVisible();
await expect(page.getByRole("button", { name: "修改邀请码和邮箱" })).toBeVisible();
await expect(page.getByLabel("验证码")).toBeVisible();
await expect(page.getByLabel("创作署名")).toBeVisible();
await expect(page.getByLabel("社交 ID")).toBeVisible();
await expect(page.getByRole("checkbox", { name: /同意/ })).not.toBeChecked();
await expect(page.getByRole("button", { name: "注册并进入 Dada" })).toBeDisabled();
await page.getByRole("button", { name: "查看全文" }).click();
const dialog = page.getByRole("dialog", { name: "内测使用与隐私告知" });
const noticeClose = page.getByRole("button", { name: "我已阅读" });
await expect(noticeClose).toBeFocused();
await expect(dialog).toContainText("AI 网关");
await expect(dialog).toContainText("Resend");
await expect(dialog).toContainText("高德");
await expect(dialog).toContainText("DYN004");
await expect(dialog).toContainText("180 天");
await expect(dialog).toContainText("不会迁移到正式系统");
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NOTICE;
if (evidenceDirectory) {
mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true });
await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "notice-expanded.png") });
}
await page.keyboard.press("Tab");
await expect(noticeClose).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("button", { name: "查看全文" })).toBeFocused();
await expect(page.getByRole("checkbox", { name: /同意/ })).not.toBeChecked();
});
test("TDD-WP1-SLOT-001 preserves profile fields when final capacity recheck fails", async ({ page }) => {
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
contentType: "application/json",
status: 200,
body: JSON.stringify({
challenge_expires_at: "2026-07-28T09:10:00.000Z",
registration_id: "00000000-0000-4000-8000-000000000004",
resend_available_at: "2026-07-28T09:01:00.000Z",
status: "verification_sent",
}),
}));
await page.route("**/api/v1/auth/register/complete", async (route) => {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
await route.fulfill({
contentType: "application/json",
status: 409,
body: JSON.stringify({
error: {
code: "REGISTRATION_REJECTED",
correlation_id: "00000000-0000-4000-8000-000000000005",
details: { field_errors: [{ field: "invite_code", message_key: "auth.registration.stage_limit_reached" }] },
message_key: "registration.rejected",
},
}),
});
});
await page.goto(webUrl);
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("textbox", { name: "邀请码" }).fill("DADA-P0A-TEST-9M4");
await page.getByRole("textbox", { name: "邮箱" }).fill("capacity@example.invalid");
await page.getByRole("button", { name: "获取验证码" }).click();
await page.getByLabel("验证码").fill("418205");
await page.getByLabel("创作署名").fill("Capacity User");
await page.getByLabel("社交 ID").fill("@capacity_user");
await page.getByRole("checkbox", { name: /同意/ }).check();
await page.getByRole("button", { name: "注册并进入 Dada" }).click();
await expect(page.getByRole("button", { name: "注册中" })).toBeDisabled();
await expect(page.getByRole("alert")).toContainText("本轮内测名额已满");
await expect(page.getByLabel("创作署名")).toHaveValue("Capacity User");
await expect(page.getByLabel("社交 ID")).toHaveValue("@capacity_user");
await expect(page.getByRole("checkbox", { name: /同意/ })).toBeChecked();
});
+155
View File
@@ -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,
});
});
});
+35
View File
@@ -0,0 +1,35 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { registrationNotice } from "../../packages/shared-contracts/src/index.js";
describe("TDD-WP1-NOTICE-001-registration-consent", () => {
it("freezes a versioned and hash-verifiable registration notice", () => {
expect(registrationNotice.version).toMatch(/^p0a-registration-notice-v[1-9][0-9]*$/);
expect(registrationNotice.effectiveAt).toMatch(/^20[0-9]{2}-[0-9]{2}-[0-9]{2}$/);
expect(registrationNotice.sections.length).toBeGreaterThanOrEqual(5);
expect(
createHash("sha256").update(registrationNotice.content, "utf8").digest("hex"),
).toBe(registrationNotice.contentSha256);
});
it.each([
"AI 网关",
"Resend",
"高德",
"DYN004",
"Windows",
"文件系统权限",
"应用层加密",
"云备份",
"LocalDataRoot",
"超级管理员",
"账号注销",
"180 天",
"不自动备份",
"不会迁移到正式系统",
])("contains the frozen topic %s", (topic) => {
expect(registrationNotice.content).toContain(topic);
});
});