diff --git a/apps/web/src/user-auth.css b/apps/web/src/user-auth.css index f5afd8b..8207328 100644 --- a/apps/web/src/user-auth.css +++ b/apps/web/src/user-auth.css @@ -30,6 +30,10 @@ input { outline-offset: 3px; } +button { + letter-spacing: 0; +} + .auth-page { min-height: 100vh; display: grid; @@ -157,6 +161,10 @@ input { line-height: 1.25; } +.auth-registration-form { + min-height: 520px; +} + .auth-form label { margin: 0 0 7px; font-size: 13px; @@ -231,6 +239,169 @@ input { color: #3c5b32; } +.auth-registration-status { + align-self: flex-end; + margin: -52px 0 14px; + padding: 6px 10px; + color: #f2f500; + background: #111111; + font-size: 12px; + font-weight: 700; +} + +.auth-verified-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 8px; +} + +.auth-verified-grid > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 7px; + min-height: 64px; + border: 1px solid #b4b4af; + padding: 10px; + background: #ffffff; +} + +.auth-verified-grid strong { + color: #2f6b45; + font-size: 11px; +} + +.auth-verified-grid span { + overflow: hidden; + font-family: Consolas, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auth-text-action { + align-self: flex-end; + border: 0; + padding: 4px 0; + color: #075f8f; + background: transparent; + font-size: 12px; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.auth-registration-form > .auth-text-action { + margin-bottom: 12px; +} + +.auth-consent { + border: 1px solid #b4b4af; + padding: 10px; + background: #ffffff; +} + +.auth-consent-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 9px; + font-size: 13px; +} + +.auth-checkbox { + display: grid; + grid-template-columns: 20px 1fr; + align-items: start; + gap: 8px; + margin: 0; + font-weight: 600; + line-height: 1.5; +} + +.auth-form .auth-checkbox input { + width: 18px; + height: 18px; + margin: 1px 0 0; + accent-color: #111111; +} + +.auth-consent p { + margin: 8px 0 0 28px; + color: #8d281b; + font-size: 11px; +} + +.auth-dialog-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + place-items: center; + padding: 28px; + background: rgba(17, 17, 17, 0.82); +} + +.auth-dialog { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(720px, 100%); + max-height: min(760px, calc(100vh - 56px)); + border: 2px solid #111111; + background: #f6f6f4; + box-shadow: 12px 12px 0 #f2f500; +} + +.auth-dialog > header { + border-bottom: 1px solid #111111; + padding: 22px 24px 18px; +} + +.auth-dialog header p { + margin: 0 0 6px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.auth-dialog h2 { + margin: 0; + font-size: 24px; +} + +.auth-dialog-content { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 24px; + overflow-y: auto; + padding: 6px 24px 18px; +} + +.auth-dialog-content section { + border-bottom: 1px solid #ccccb9; + padding: 14px 0; +} + +.auth-dialog-content h3 { + margin: 0 0 6px; + font-size: 15px; +} + +.auth-dialog-content p { + margin: 0; + font-size: 13px; + line-height: 1.7; +} + +.auth-dialog > footer { + border-top: 1px solid #111111; + padding: 0 24px 18px; + background: #f6f6f4; +} + .auth-error { border-left: 4px solid #c7432f; padding: 8px 10px; @@ -304,6 +475,27 @@ input { margin-left: 0; } + .auth-registration-status { + margin-top: 0; + } + + .auth-verified-grid { + grid-template-columns: 1fr; + } + + .auth-dialog-backdrop { + padding: 16px; + } + + .auth-dialog { + max-height: calc(100vh - 32px); + box-shadow: 6px 6px 0 #f2f500; + } + + .auth-dialog-content { + display: block; + } + .auth-code-row { grid-template-columns: minmax(0, 1fr) 126px; } diff --git a/apps/web/src/user-auth.tsx b/apps/web/src/user-auth.tsx index 2db1c6e..5c2970c 100644 --- a/apps/web/src/user-auth.tsx +++ b/apps/web/src/user-auth.tsx @@ -1,4 +1,5 @@ -import { useEffect, useId, useRef, useState, type FormEvent } from "react"; +import { registrationNotice } from "@dada/shared-contracts"; +import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; import "./user-auth.css"; @@ -20,6 +21,16 @@ const messageByKey: Record = { "auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。", "auth.login.admin_required": "此邮箱需从管理员登录入口进入。", "auth.login.registration_required": "该邮箱尚未注册,请切换到注册。", + "auth.email.already_registered": "该邮箱已注册,请切换到登录。", + "auth.invite.disabled": "邀请码已停用,请更换邀请码。", + "auth.invite.exhausted": "邀请码使用次数已耗尽,请更换邀请码。", + "auth.invite.expired": "邀请码已过期,请更换邀请码。", + "auth.invite.not_found": "邀请码无效,请检查后重试。", + "auth.privacy.consent_required": "请阅读并同意《内测使用与隐私告知》。", + "auth.privacy.notice_version_invalid": "告知版本已更新,请重新阅读后同意。", + "auth.profile.invalid": "请检查创作署名和社交 ID。", + "auth.registration.login_required": "该邮箱已注册,请切换到登录。", + "auth.registration.stage_limit_reached": "本轮内测名额已满,请返回登录。", "auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。", }; @@ -34,22 +45,47 @@ function maskedEmail(email: string) { return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`; } +function maskedRegistrationEmail(email: string) { + const [local = "", domain = ""] = email.split("@", 2); + return `${local.slice(0, 1)}***@${domain.slice(0, 1)}***`; +} + +function maskedInvite(inviteCode: string) { + return `••••${inviteCode.slice(-3)}`; +} + export function UserAuthPage() { const emailId = useId(); const codeId = useId(); const inviteId = useId(); + const creatorNameId = useId(); + const socialId = useId(); const loginTab = useRef(null); const registerTab = useRef(null); + const noticeButton = useRef(null); + const noticeCloseButton = useRef(null); + const noticeDialog = useRef(null); const [mode, setMode] = useState("login"); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [inviteCode, setInviteCode] = useState(""); + const [creatorName, setCreatorName] = useState(""); + const [socialHandle, setSocialHandle] = useState(""); + const [privacyConsentAccepted, setPrivacyConsentAccepted] = useState(false); + const [noticeOpen, setNoticeOpen] = useState(false); const [registrationId, setRegistrationId] = useState(); const [sendState, setSendState] = useState("idle"); const [countdown, setCountdown] = useState(0); const [error, setError] = useState(); const [submitting, setSubmitting] = useState(false); const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); + const registrationReady = Boolean( + registrationId + && /^[0-9]{6}$/.test(code) + && creatorName.trim() + && socialHandle.trim() + && privacyConsentAccepted, + ); useEffect(() => { if (countdown <= 0) return; @@ -57,10 +93,28 @@ export function UserAuthPage() { return () => window.clearInterval(timer); }, [countdown]); + useEffect(() => { + if (!noticeOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + noticeCloseButton.current?.focus(); + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [noticeOpen]); + + function resetRegistrationDetails() { + setCode(""); + setCreatorName(""); + setSocialHandle(""); + setPrivacyConsentAccepted(false); + setNoticeOpen(false); + } + function switchMode(nextMode: AuthMode) { if (nextMode === mode) return; setMode(nextMode); - setCode(""); + resetRegistrationDetails(); setInviteCode(""); setRegistrationId(undefined); setSendState("idle"); @@ -68,6 +122,43 @@ export function UserAuthPage() { setError(undefined); } + function modifyRegistrationEntry() { + resetRegistrationDetails(); + setRegistrationId(undefined); + setSendState("idle"); + setCountdown(0); + setError(undefined); + window.requestAnimationFrame(() => document.getElementById(inviteId)?.focus()); + } + + function closeNotice() { + setNoticeOpen(false); + window.requestAnimationFrame(() => noticeButton.current?.focus()); + } + + function handleNoticeKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + closeNotice(); + return; + } + if (event.key === "Tab") { + const focusable = Array.from( + noticeDialog.current?.querySelectorAll("button, [href], input, [tabindex]:not([tabindex='-1'])") ?? [], + ).filter((element) => !element.hasAttribute("disabled")); + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } + function switchTab(nextMode: AuthMode) { switchMode(nextMode); window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus()); @@ -129,8 +220,44 @@ export function UserAuthPage() { } } + async function completeRegistration(event: FormEvent) { + event.preventDefault(); + if (!registrationReady || !registrationId || submitting) return; + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/v1/auth/register/complete", { + body: JSON.stringify({ + creator_name: creatorName, + privacy_consent_accepted: privacyConsentAccepted, + privacy_notice_version: registrationNotice.version, + registration_id: registrationId, + social_id: socialHandle, + verification_code: code, + }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody; + if (!response.ok) { + setError(errorMessage(body)); + return; + } + window.location.assign("/app"); + } catch { + setError("注册请求未完成,请重试。"); + } finally { + setSubmitting(false); + } + } + return ( -
+ <> +
DADA
@@ -227,34 +354,109 @@ export function UserAuthPage() { ) : ( -
-

邀请码注册

- - setInviteCode(event.target.value)} - value={inviteCode} - /> - - setEmail(event.target.value)} - type="email" - value={email} - /> - +
+

{registrationId ? "完善注册资料" : "邀请码注册"}

+ {!registrationId ? ( + <> + + setInviteCode(event.target.value)} + value={inviteCode} + /> + + setEmail(event.target.value)} + placeholder="请输入邮箱" + type="email" + value={email} + /> + + + ) : ( + <> +
验证码已发送
+
+
+ 邀请码 · 已验证 + {maskedInvite(inviteCode)} +
+
+ 邮箱 · 已验证 + {maskedRegistrationEmail(email)} +
+
+ + + setCode(event.target.value.replace(/\D/g, ""))} + placeholder="输入 6 位验证码" + value={code} + /> + + setCreatorName(event.target.value)} + placeholder="成品中显示的名称" + value={creatorName} + /> + + setSocialHandle(event.target.value)} + placeholder="成品中显示的账号文本" + value={socialHandle} + /> +
+
+ 《内测使用与隐私告知》 + +
+ + {!privacyConsentAccepted ?

完成注册前必须阅读并勾选同意。

: null} +
+ + + )} {error ?

{error}

: null} -
+ )}
@@ -262,6 +464,39 @@ export function UserAuthPage() {
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
-
+
+ {noticeOpen ? ( +
+
+
+
+

版本 {registrationNotice.version} · 生效日期 {registrationNotice.effectiveAt}

+

{registrationNotice.title}

+
+
+
+ {registrationNotice.sections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+
+ +
+
+
+ ) : null} + ); } diff --git a/package.json b/package.json index dba05c9..b65e538 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -43,7 +43,9 @@ "test:wp1-01": "node scripts/run-wp1-01-validation.mjs", "test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red", "test:wp1-02": "node scripts/run-wp1-02-validation.mjs", - "test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red" + "test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red", + "test:wp1-03": "node scripts/run-wp1-03-validation.mjs", + "test:wp1-03:red": "node scripts/run-wp1-03-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index bd297ca..a29cbc2 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -3,3 +3,4 @@ export * from "./api.js"; export * from "./auth.js"; export * from "./bootstrap.js"; export * from "./events.js"; +export * from "./registration-notice.js"; diff --git a/packages/shared-contracts/src/registration-notice.ts b/packages/shared-contracts/src/registration-notice.ts new file mode 100644 index 0000000..7d7bd33 --- /dev/null +++ b/packages/shared-contracts/src/registration-notice.ts @@ -0,0 +1,39 @@ +export const registrationNoticeSections = [ + { + title: "外部服务", + body: "注册验证码通过 Resend 发送;创作功能会按用户主动操作调用 AI 网关;使用 DYN004 自动定位时会调用高德处理地点文字和坐标。未使用相应功能时不会为该功能发起调用。", + }, + { + title: "本机存储与保护", + body: "P0-A 数据保存在当前 Windows 电脑的 LocalDataRoot,依赖当前 Windows 用户登录和文件系统权限保护。Dada 不提供应用层加密或云备份;机器损坏、系统重装或 LocalDataRoot 被删除后数据不可恢复,重要图片请主动下载。", + }, + { + title: "超级管理员访问", + body: "超级管理员可按产品权限查看运营信息,并可打开用户私有图片或完整提示词;每次打开都会记录访问审计。审计记录在规定保留期内不可人工修改或删除。", + }, + { + title: "DYN004 坐标", + body: "DYN004 可在用户主动使用时处理和保存原始定位坐标;坐标属于私有项目数据,账号注销时按规则删除。高德不可用或达到应用硬上限时,自动定位停止。", + }, + { + title: "账号注销与保留", + body: "账号注销后,账号资料、项目、图片、提示词、原始坐标、导出成品和未使用点数立即不可恢复。允许的匿名生成与点数事件及私有访问审计最多保留 180 天,且不得保留可回溯个人或单次作品的信息。", + }, + { + title: "无备份与迁移", + body: "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。P0-A 不提供可编辑项目包或业务数据的导出、导入和迁移;用户下载的图片仅用于自行留存。", + }, +] as const; + +export const registrationNoticeContent = registrationNoticeSections + .map((section) => `${section.title}\n${section.body}`) + .join("\n\n"); + +export const registrationNotice = Object.freeze({ + content: registrationNoticeContent, + contentSha256: "10dea4fb1b54d7208c017c126693d2061d8261bc5f8bc2b656309329ef22a791", + effectiveAt: "2026-07-28", + sections: registrationNoticeSections, + title: "内测使用与隐私告知", + version: "p0a-registration-notice-v1", +}); diff --git a/scripts/run-wp1-03-validation.mjs b/scripts/run-wp1-03-validation.mjs new file mode 100644 index 0000000..06688e8 --- /dev/null +++ b/scripts/run-wp1-03-validation.mjs @@ -0,0 +1,111 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!['red', 'green'].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp1-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const noticeCase = "TDD-WP1-NOTICE-001-registration-consent"; +const slotCase = "TDD-WP1-SLOT-001-stage-limit"; +const directories = { + [noticeCase]: resolve(runDirectory, "cases", noticeCase), + [slotCase]: resolve(runDirectory, "cases", slotCase), +}; +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true }); + +const commandsToRun = phase === "red" + ? [ + ["notice-unit", ["exec", "vitest", "run", "tests/unit/wp1-03-notice.test.ts"]], + ["slot-integration", ["exec", "vitest", "run", "tests/integration/wp1-03-slot-limit.test.ts"]], + ["notice-e2e", ["exec", "playwright", "test", "tests/e2e/user-registration.spec.ts", "--config", "playwright.config.ts"]], + ] + : [ + ["unit", ["test:unit"]], + ["api", ["test:api"]], + ["e2e", ["test:e2e"]], + ["integration", ["test:integration"]], + ["tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_NOTICE: directories[noticeCase], + DADA_EVIDENCE_DIR_SLOT: directories[slotCase], +}; +const commandResults = []; +for (const [name, args] of commandsToRun) { + const command = `pnpm ${args.join(" ")}`; + const executable = process.env.ComSpec ?? "cmd.exe"; + const started_at = new Date().toISOString(); + const execution = spawnSync(executable, ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} + +const byName = Object.fromEntries(commandResults.map((result) => [result.name, result])); +const statuses = phase === "red" + ? { + [noticeCase]: byName["notice-unit"].exit_code !== 0 && byName["notice-e2e"].exit_code !== 0 ? "red_confirmed" : "failed", + [slotCase]: byName["slot-integration"].exit_code === 0 ? "preexisting_green" : "red_confirmed", + } + : { + [noticeCase]: commandResults.every((result) => result.exit_code === 0) ? "awaiting_manual_review" : "failed", + [slotCase]: commandResults.every((result) => result.exit_code === 0) ? "passed" : "failed", + }; +for (const directory of Object.values(directories)) { + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +} +if (phase === "red") { + writeFileSync(resolve(directories[noticeCase], "red-observation.json"), `${JSON.stringify({ + expected_failures: ["registration notice contract missing", "DVPM8 second stage missing"], + status: statuses[noticeCase], + }, null, 2)}\n`); + writeFileSync(resolve(directories[slotCase], "red-observation.json"), `${JSON.stringify({ + explanation: "TASK-WP1-01 already implemented the normative slot transaction; no failure was fabricated.", + status: statuses[slotCase], + }, null, 2)}\n`); +} +const expected = phase === "red" + ? { [noticeCase]: ["red-observation.json"], [slotCase]: ["red-observation.json"] } + : { + [noticeCase]: ["response.json", "db-diff.json", "screenshots/notice-expanded.png", "manual-review.json"], + [slotCase]: ["response.json", "db-diff.json", "concurrency-trace.json"], + }; +const manifest = { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(), +}; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const results = Object.entries(statuses).map(([testId, status]) => { + const evidence_refs = expected[testId]; + const missing_evidence = evidence_refs.filter((file) => !existsSync(resolve(directories[testId], file))); + const result = { + acceptance_criteria: ["AC-01", "AC-32", "AC-41", "AC-45", "AC-56"], + automation: ["automated", "manual_review"], + commit, + evidence_refs, + manifest, + missing_evidence, + phase, + requirements: ["AUTH-01", "AUTH-03", "NFR-04", "PRIV-04", "PRIV-06"], + run_id: runId, + status, + task_id: "TASK-WP1-03", + test_id: testId, + work_package: "WP-1", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", + }; + writeFileSync(resolve(directories[testId], "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +}); +const redAccepted = phase === "red" && statuses[noticeCase] === "red_confirmed" && ["preexisting_green", "red_confirmed"].includes(statuses[slotCase]); +const greenAccepted = phase === "green" && statuses[noticeCase] === "awaiting_manual_review" && statuses[slotCase] === "passed"; +const summary = { cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), phase, run_id: runId, status: redAccepted ? "red_confirmed" : greenAccepted ? "awaiting_manual_review" : "failed" }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!redAccepted && !greenAccepted) process.exit(1); diff --git a/tests/api/wp1-03-registration-consent.test.ts b/tests/api/wp1-03-registration-consent.test.ts new file mode 100644 index 0000000..bc9b220 --- /dev/null +++ b/tests/api/wp1-03-registration-consent.test.ts @@ -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(); + }); +}); diff --git a/tests/e2e/user-registration.spec.ts b/tests/e2e/user-registration.spec.ts new file mode 100644 index 0000000..e52c8f9 --- /dev/null +++ b/tests/e2e/user-registration.spec.ts @@ -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(); +}); diff --git a/tests/integration/wp1-03-slot-limit.test.ts b/tests/integration/wp1-03-slot-limit.test.ts new file mode 100644 index 0000000..c785a12 --- /dev/null +++ b/tests/integration/wp1-03-slot-limit.test.ts @@ -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(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, + }); + }); +}); diff --git a/tests/unit/wp1-03-notice.test.ts b/tests/unit/wp1-03-notice.test.ts new file mode 100644 index 0000000..7f29fca --- /dev/null +++ b/tests/unit/wp1-03-notice.test.ts @@ -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); + }); +});