feat: implement TASK-WP1-03 registration notice
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user