From 1c46311e05a1938dbc3949a98367f657adf4a09a Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 11:51:09 +0800 Subject: [PATCH] feat: implement sensitive operation audit retention (TASK-WP6-04) --- apps/api/src/admin-audit.ts | 148 ++++++++ apps/api/src/app.ts | 80 ++++ apps/api/src/registration.ts | 59 ++- apps/api/src/sticker-releases.ts | 46 +++ apps/web/src/admin-audit.css | 164 ++++++++ apps/web/src/admin-audit.tsx | 181 +++++++++ apps/web/src/generated/api/sdk.gen.ts | 16 +- apps/web/src/generated/api/types.gen.ts | 40 ++ apps/web/src/main.tsx | 3 +- apps/worker/src/project-purge-cleanup.ts | 30 +- openapi/openapi.json | 353 ++++++++++++++++++ package.json | 4 +- packages/shared-contracts/src/admin.ts | 61 +++ scripts/run-wp6-04-validation.mjs | 102 +++++ tests/api/wp6-04-audit.test.ts | 123 ++++++ tests/e2e/wp5-05-admin-assets.spec.ts | 11 +- tests/e2e/wp6-04-audit.spec.ts | 51 +++ tests/fixtures/wp6-04-audit.ts | 52 +++ .../wp5-05-sticker-release.test.ts | 26 +- .../wp5-06-sticker-cleanup.test.ts | 41 ++ .../wp6-04-sensitive-audit.test.ts | 90 +++++ 21 files changed, 1670 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/admin-audit.ts create mode 100644 apps/web/src/admin-audit.css create mode 100644 apps/web/src/admin-audit.tsx create mode 100644 scripts/run-wp6-04-validation.mjs create mode 100644 tests/api/wp6-04-audit.test.ts create mode 100644 tests/e2e/wp6-04-audit.spec.ts create mode 100644 tests/fixtures/wp6-04-audit.ts create mode 100644 tests/integration/wp6-04-sensitive-audit.test.ts diff --git a/apps/api/src/admin-audit.ts b/apps/api/src/admin-audit.ts new file mode 100644 index 0000000..1d05e22 --- /dev/null +++ b/apps/api/src/admin-audit.ts @@ -0,0 +1,148 @@ +import type BetterSqlite3 from "better-sqlite3"; + +import type { + AdminAuditQuery, + AdminOperationAuditItem, + AdminOperationAuditResponse, + PrivateContentAccessAuditItem, + PrivateContentAccessAuditResponse, +} from "@dada/shared-contracts"; + +interface AuditCursor { + logId: string; + occurredAt: number; +} + +interface AdminOperationRow { + actor_ref: string; + actor_type: "system" | "super_admin"; + after_summary: string | null; + before_summary: string | null; + expires_at: number; + log_id: string; + occurred_at: number; + operation_type: string; + result: "failed" | "succeeded"; + target_ref: string; + target_type: string; +} + +interface PrivateContentAccessRow { + actor_ref: string; + content_type: "image" | "prompt"; + expires_at: number; + log_id: string; + occurred_at: number; + target_ref: string; +} + +export class AdminAuditQueryError extends Error { + constructor() { + super("admin_audit_query_invalid"); + this.name = "AdminAuditQueryError"; + } +} + +function encodeCursor(row: { log_id: string; occurred_at: number }) { + return Buffer.from(JSON.stringify([row.occurred_at, row.log_id]), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string | undefined): AuditCursor | undefined { + if (!cursor) return undefined; + try { + const parsed: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (!Array.isArray(parsed) || parsed.length !== 2 || !Number.isSafeInteger(parsed[0]) + || typeof parsed[1] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(parsed[1])) { + throw new AdminAuditQueryError(); + } + return { occurredAt: parsed[0] as number, logId: parsed[1] }; + } catch (error) { + if (error instanceof AdminAuditQueryError) throw error; + throw new AdminAuditQueryError(); + } +} + +function normalizeLimit(limit: number | undefined) { + if (limit === undefined) return 50; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new AdminAuditQueryError(); + return limit; +} + +function pageRows(rows: Row[], limit: number) { + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + return { items, nextCursor: hasMore ? encodeCursor(items[items.length - 1]!) : null }; +} + +function iso(value: number) { + return new Date(value).toISOString(); +} + +export function listAdminOperationAudit( + database: BetterSqlite3.Database, + query: AdminAuditQuery, + clock: () => number = Date.now, +): AdminOperationAuditResponse { + const cursor = decodeCursor(query.cursor); + const limit = normalizeLimit(query.limit); + const rows = (cursor + ? database.prepare(` + SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id, + occurred_at, operation_type, result, target_ref, target_type + FROM admin_operation_logs + WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?) + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1) + : database.prepare(` + SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id, + occurred_at, operation_type, result, target_ref, target_type + FROM admin_operation_logs + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(limit + 1)) as AdminOperationRow[]; + const page = pageRows(rows, limit); + const items: AdminOperationAuditItem[] = page.items.map((row) => ({ + actor_ref: row.actor_ref, + actor_type: row.actor_type, + after_summary: row.after_summary, + before_summary: row.before_summary, + expires_at: iso(row.expires_at), + log_id: row.log_id, + occurred_at: iso(row.occurred_at), + operation_type: row.operation_type, + result: row.result, + target_ref: row.target_ref, + target_type: row.target_type, + })); + return { generated_at: iso(clock()), items, next_cursor: page.nextCursor }; +} + +export function listPrivateContentAccessAudit( + database: BetterSqlite3.Database, + query: AdminAuditQuery, + clock: () => number = Date.now, +): PrivateContentAccessAuditResponse { + const cursor = decodeCursor(query.cursor); + const limit = normalizeLimit(query.limit); + const rows = (cursor + ? database.prepare(` + SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref + FROM private_content_access_logs + WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?) + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1) + : database.prepare(` + SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref + FROM private_content_access_logs + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(limit + 1)) as PrivateContentAccessRow[]; + const page = pageRows(rows, limit); + const items: PrivateContentAccessAuditItem[] = page.items.map((row) => ({ + actor_ref: row.actor_ref, + content_type: row.content_type, + expires_at: iso(row.expires_at), + log_id: row.log_id, + occurred_at: iso(row.occurred_at), + target_ref: row.target_ref, + })); + return { generated_at: iso(clock()), items, next_cursor: page.nextCursor }; +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 33d6e03..7623b3a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,10 +9,13 @@ import { AccountProfileUpdateRequestSchema, AccountProfileUpdateResponseSchema, AccountSettingsResponseSchema, + AdminAuditQuerySchema, AdminAuthenticatedUserSchema, AdminGenerationRecordSchema, AdminGenerationListResponseSchema, AdminOverviewResponseSchema, + AdminOperationAuditItemSchema, + AdminOperationAuditResponseSchema, AdminServicesResponseSchema, AdminServiceHealthCheckRequestSchema, AdminServiceLimitRequestSchema, @@ -78,6 +81,8 @@ import { PrivateContentNoticeAckRequestSchema, PrivateContentNoticeAckResponseSchema, PrivateContentPromptResponseSchema, + PrivateContentAccessAuditItemSchema, + PrivateContentAccessAuditResponseSchema, FailedEmptyTrashRequestSchema, FailedEmptyTrashResponseSchema, ExportFormatSchema, @@ -129,6 +134,7 @@ import { type AdminLoginCompleteRequest, type AdminLoginSendRequest, type AdminOverviewResponse, + type AdminAuditQuery, type AdminDiagnosticsResponse, type AdminServicesStorageResponse, type AccountDeletionCompleteRequest, @@ -195,6 +201,11 @@ import { registrationFieldError, } from "./registration-errors.js"; import type { RegistrationService } from "./registration.js"; +import { + AdminAuditQueryError, + listAdminOperationAudit, + listPrivateContentAccessAudit, +} from "./admin-audit.js"; import type { AssetPreviewGrantService } from "./preview-grants.js"; import type { RecentAssetService } from "./recent-assets.js"; import type { AmapAdapter } from "./amap-adapter.js"; @@ -749,11 +760,16 @@ export async function createApp(options: CreateAppOptions = {}) { AdminLoginCompleteRequestSchema, AdminLoginCompleteResponseSchema, AdminSessionResponseSchema, + AdminAuditQuerySchema, + AdminOperationAuditItemSchema, + AdminOperationAuditResponseSchema, AdminGenerationRecordSchema, AdminGenerationListResponseSchema, PrivateContentNoticeAckRequestSchema, PrivateContentNoticeAckResponseSchema, PrivateContentPromptResponseSchema, + PrivateContentAccessAuditItemSchema, + PrivateContentAccessAuditResponseSchema, PrivateContentGenerationParamsSchema, AdminOverviewResponseSchema, AdminServicesResponseSchema, @@ -1684,6 +1700,70 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/admin/audit/operations", + { + schema: { + operationId: "getAdminOperationAudit", + querystring: Type.Ref(AdminAuditQuerySchema), + response: { + 200: Type.Ref(AdminOperationAuditResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + reply.header("Cache-Control", "private, no-store"); + return listAdminOperationAudit(options.registration.database, request.query as AdminAuditQuery); + } catch (error) { + if (error instanceof AdminAuditQueryError) return reply.code(400).send(null); + throw error; + } + }, + ); + + app.get( + "/api/v1/admin/audit/private-content", + { + schema: { + operationId: "getPrivateContentAccessAudit", + querystring: Type.Ref(AdminAuditQuerySchema), + response: { + 200: Type.Ref(PrivateContentAccessAuditResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + reply.header("Cache-Control", "private, no-store"); + return listPrivateContentAccessAudit(options.registration.database, request.query as AdminAuditQuery); + } catch (error) { + if (error instanceof AdminAuditQueryError) return reply.code(400).send(null); + throw error; + } + }, + ); + app.get( "/api/v1/admin/overview", { diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index cf505a9..73c3540 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import type BetterSqlite3 from "better-sqlite3"; import { + auditRetentionMilliseconds, ensureAdminOperationAuditSchema, ensurePrivateAccessAuditSchema, isSafeAuditRef, @@ -281,6 +282,39 @@ export class RegistrationService { return { code, inviteId }; } + createAdminInvite(input: { actorId: string; expiresAt: number; maxUses: number }) { + if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.maxUses) || input.maxUses < 1) { + throw new Error("Invite request is invalid."); + } + const code = this.options.inviteCodeGenerator(); + const inviteId = randomUUID(); + const now = this.options.clock(); + this.runImmediate("invite_create", () => { + const admin = this.database.prepare(` + SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(input.actorId); + if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare(` + INSERT INTO invite_codes ( + invite_id, code_hmac, max_uses, used_count, expires_at, status, created_at + ) VALUES (?, ?, ?, 0, ?, 'enabled', ?) + `).run(inviteId, this.inviteHmac(code), input.maxUses, input.expiresAt, now); + this.recordAdminAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { max_uses: input.maxUses, status: "enabled" }, + beforeSummary: null, + operationType: "invite_create", + result: "succeeded", + targetRef: inviteId, + targetType: "invite", + }, now); + return { outcome: "committed", value: undefined }; + }); + return { code, inviteId }; + } + async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise { const email = normalizeEmail(input.email); const inviteCode = normalizeProfileValue(input.inviteCode, 160); @@ -1241,14 +1275,35 @@ export class RegistrationService { return outcome; } - changeUserStatus(userId: string, status: "suspended" | "deleted") { + changeUserStatus(userId: string, status: "suspended" | "deleted", actorId?: string) { const now = this.options.clock(); this.runImmediate("session_revoke", () => { + if (actorId) { + const admin = this.database.prepare(` + SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(actorId); + if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + const before = this.database.prepare("SELECT status FROM users WHERE user_id = ? AND role = 'user'") + .get(userId) as { status: "active" | "suspended" | "deleted" } | undefined; const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'") .run(status, userId); if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") .run(now, userId); + if (actorId) { + this.recordAdminAudit({ + actorRef: actorId, + actorType: "super_admin", + afterSummary: { status }, + beforeSummary: { status: before?.status ?? "unknown" }, + operationType: "user_status_change", + result: "succeeded", + targetRef: userId, + targetType: "user_account", + }, now); + } return { outcome: "committed", value: undefined }; }); } @@ -1772,7 +1827,7 @@ export class RegistrationService { serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), now, - now + 180 * 24 * 60 * 60 * 1_000, + now + auditRetentionMilliseconds, ); } diff --git a/apps/api/src/sticker-releases.ts b/apps/api/src/sticker-releases.ts index fc252a8..20a552e 100644 --- a/apps/api/src/sticker-releases.ts +++ b/apps/api/src/sticker-releases.ts @@ -9,6 +9,12 @@ import sharp, { type Metadata } from "sharp"; import type { StaticStickerCatalogItem } from "@dada/static-sticker-catalog"; +import { + auditRetentionMilliseconds, + isSafeAuditRef, + isSafeAuditSummaryJson, + serializeAuditSummary, +} from "./audit-policy.js"; import { ManagedStorage, type StagedManagedFile } from "./managed-storage.js"; import { StickerReleaseError } from "./sticker-release-errors.js"; import { classifyCapacity } from "./storage-policy.js"; @@ -115,6 +121,12 @@ export class StickerReleaseService { this.database.pragma("journal_mode = WAL"); this.database.pragma("foreign_keys = ON"); this.database.pragma("busy_timeout = 5000"); + this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef); + this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson); + this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0); + this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => ""); + this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); + this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); this.storage = input.storage; this.migrate(); } @@ -234,6 +246,13 @@ export class StickerReleaseService { WHERE release_version = ? AND stable_id = ? `).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId); this.finalizeRelease(version, current, input.actorId); + this.insertReleaseAudit({ + actorId: input.actorId, + afterSummary: { enabled: input.enabled ?? existing.enabled === 1, order, part, stable_id: input.stableId }, + beforeSummary: { enabled: existing.enabled === 1, order: existing.order_index, part: existing.part, stable_id: input.stableId }, + operationType: "sticker_release_update", + releaseVersion: version, + }); return version; }); return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion }; @@ -342,9 +361,36 @@ export class StickerReleaseService { VALUES (?, ?, ?, ?, ?, ?) `).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock())); this.finalizeRelease(releaseVersion, previous, input.actorId); + this.insertReleaseAudit({ + actorId: input.actorId, + afterSummary: { enabled: input.enabled, order: input.order, part: input.part, stable_id: input.stableId }, + beforeSummary: previous ? { release_version: previous } : null, + operationType: "sticker_release_publish", + releaseVersion, + }); return releaseVersion; } + private insertReleaseAudit(input: { + actorId: string; + afterSummary: Record; + beforeSummary: Record | null; + operationType: "sticker_release_publish" | "sticker_release_update"; + releaseVersion: string; + }) { + const occurredAt = this.clock(); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'super_admin', ?, ?, 'sticker_release', ?, 'succeeded', ?, ?, ?, ?) + `).run( + randomUUID(), input.actorId, input.operationType, input.releaseVersion, + serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), + occurredAt, occurredAt + auditRetentionMilliseconds, + ); + } + private finalizeRelease(releaseVersion: string, previous: string | null, actorId: string) { const rows = this.database.prepare(` SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled diff --git a/apps/web/src/admin-audit.css b/apps/web/src/admin-audit.css new file mode 100644 index 0000000..fa7c872 --- /dev/null +++ b/apps/web/src/admin-audit.css @@ -0,0 +1,164 @@ +.admin-audit-page { + width: min(100% - 48px, 1440px); + margin: 0 auto; + padding: 28px 0 40px; + color: #1a1a18; +} + +.admin-audit-heading { + display: flex; + min-height: 72px; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + border-bottom: 2px solid #1a1a18; +} + +.admin-audit-heading p, +.admin-audit-heading h2 { + margin: 0; +} + +.admin-audit-heading p { + color: #686861; + font-size: 12px; + font-weight: 800; +} + +.admin-audit-heading h2 { + padding: 4px 0 12px; + font-size: 28px; + line-height: 40px; +} + +.admin-audit-heading time { + padding-bottom: 14px; + color: #686861; + font-size: 12px; +} + +.admin-audit-tabs { + display: flex; + gap: 0; + margin-top: 24px; + border-bottom: 1px solid #a9a9a2; +} + +.admin-audit-tabs button { + min-height: 40px; + padding: 0 18px; + border: 0; + border-bottom: 3px solid transparent; + color: #4f4f49; + background: transparent; + font-weight: 700; +} + +.admin-audit-tabs button[aria-selected="true"] { + border-bottom-color: #1a1a18; + color: #1a1a18; + background: #f4df32; +} + +.admin-audit-failure { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: 8px 12px; + border-left: 4px solid #c92a24; + background: #fff1ef; +} + +.admin-audit-failure button, +.admin-audit-pagination button { + min-height: 36px; + padding: 0 14px; + border: 1px solid #1a1a18; + background: #fff; + font-weight: 700; +} + +.admin-audit-status { + margin: 0; + padding: 48px 16px; + color: #686861; +} + +.admin-audit-table-scroll { + overflow-x: auto; + border-bottom: 1px solid #a9a9a2; +} + +.admin-audit-page table { + width: 100%; + min-width: 1120px; + border-collapse: collapse; + table-layout: fixed; +} + +.admin-audit-page th, +.admin-audit-page td { + min-height: 40px; + padding: 10px 12px; + border-bottom: 1px solid #d7d7d1; + overflow-wrap: anywhere; + text-align: left; + vertical-align: top; + font-size: 12px; +} + +.admin-audit-page th { + color: #55554f; + background: #efefeb; + font-weight: 800; +} + +.admin-audit-page th:nth-child(1) { width: 132px; } +.admin-audit-page th:nth-child(2) { width: 210px; } +.admin-audit-page th:nth-child(3) { width: 180px; } +.admin-audit-page th:nth-child(5) { width: 100px; } +.admin-audit-page th:nth-child(6) { width: 210px; } + +.admin-audit-page td small { + display: block; + margin-top: 4px; + color: #686861; +} + +.admin-audit-page td strong { + color: #16794b; +} + +.admin-audit-page td strong.is-failed { + color: #c92a24; +} + +.admin-audit-pagination { + display: flex; + justify-content: flex-end; + padding-top: 16px; +} + +.admin-audit-retention { + margin: 24px 0 0; + padding-top: 12px; + border-top: 1px solid #d7d7d1; + color: #686861; + font-size: 12px; +} + +.admin-audit-page :focus-visible { + outline: 2px solid #005fcc; + outline-offset: 2px; +} + +@media (max-width: 700px) { + .admin-audit-page { width: calc(100% - 24px); } + .admin-audit-heading { align-items: flex-start; flex-direction: column; gap: 4px; } + .admin-audit-heading time { padding-bottom: 12px; } + .admin-audit-tabs { display: grid; grid-template-columns: 1fr 1fr; } + .admin-audit-tabs button { min-width: 0; padding: 8px; } +} diff --git a/apps/web/src/admin-audit.tsx b/apps/web/src/admin-audit.tsx new file mode 100644 index 0000000..4dd74bd --- /dev/null +++ b/apps/web/src/admin-audit.tsx @@ -0,0 +1,181 @@ +import type { + AdminOperationAuditItem, + AdminOperationAuditResponse, + PrivateContentAccessAuditItem, + PrivateContentAccessAuditResponse, +} from "@dada/shared-contracts"; +import { useCallback, useEffect, useState } from "react"; + +import "./admin-audit.css"; + +type AuditTab = "operations" | "private-content"; + +interface AuditPageState { + failed: boolean; + generatedAt: string | null; + items: Item[]; + loading: boolean; + nextCursor: string | null; +} + +const emptyState = (): AuditPageState => ({ + failed: false, + generatedAt: null, + items: [], + loading: false, + nextCursor: null, +}); + +function formatTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + month: "2-digit", + second: "2-digit", + }).format(new Date(value)); +} + +function operationSummary(item: AdminOperationAuditItem) { + if (item.after_summary) return item.after_summary; + if (item.before_summary) return item.before_summary; + return "无变更摘要"; +} + +export function AdminAuditPage() { + const [tab, setTab] = useState("operations"); + const [operations, setOperations] = useState>(emptyState); + const [privateAccess, setPrivateAccess] = useState>(emptyState); + + const loadOperations = useCallback(async (cursor?: string, append = false) => { + setOperations((current) => ({ ...current, failed: false, loading: true })); + try { + const query = new URLSearchParams({ limit: "50" }); + if (cursor) query.set("cursor", cursor); + const response = await fetch(`/api/v1/admin/audit/operations?${query}`, { credentials: "same-origin" }); + if (response.status === 401) { + window.dispatchEvent(new Event("dada:session-invalid")); + return; + } + if (!response.ok) throw new Error("admin_operation_audit_unavailable"); + const body = await response.json() as AdminOperationAuditResponse; + setOperations((current) => ({ + failed: false, + generatedAt: body.generated_at, + items: append ? [...current.items, ...body.items] : body.items, + loading: false, + nextCursor: body.next_cursor, + })); + } catch { + setOperations((current) => ({ ...current, failed: true, loading: false })); + } + }, []); + + const loadPrivateAccess = useCallback(async (cursor?: string, append = false) => { + setPrivateAccess((current) => ({ ...current, failed: false, loading: true })); + try { + const query = new URLSearchParams({ limit: "50" }); + if (cursor) query.set("cursor", cursor); + const response = await fetch(`/api/v1/admin/audit/private-content?${query}`, { credentials: "same-origin" }); + if (response.status === 401) { + window.dispatchEvent(new Event("dada:session-invalid")); + return; + } + if (!response.ok) throw new Error("private_content_audit_unavailable"); + const body = await response.json() as PrivateContentAccessAuditResponse; + setPrivateAccess((current) => ({ + failed: false, + generatedAt: body.generated_at, + items: append ? [...current.items, ...body.items] : body.items, + loading: false, + nextCursor: body.next_cursor, + })); + } catch { + setPrivateAccess((current) => ({ ...current, failed: true, loading: false })); + } + }, []); + + useEffect(() => { void loadOperations(); }, [loadOperations]); + + function selectTab(next: AuditTab) { + setTab(next); + if (next === "private-content" && !privateAccess.generatedAt && !privateAccess.loading) void loadPrivateAccess(); + } + + const state = tab === "operations" ? operations : privateAccess; + const reload = tab === "operations" ? loadOperations : loadPrivateAccess; + + return ( +
+
+

IMMUTABLE / 180 DAYS

审计

+ {state.generatedAt ? : null} +
+ +
+ + +
+ + {state.failed ? ( +
+ 审计记录暂时无法读取{state.generatedAt ? ",已保留上次结果" : ""}。 + +
+ ) : null} + + {tab === "operations" ? ( +
+ {operations.loading && operations.items.length === 0 ?

正在读取后台操作审计

: null} + {!operations.loading && !operations.failed && operations.items.length === 0 ?

当前没有后台操作审计记录。

: null} + {operations.items.length > 0 ? ( +
+ + + {operations.items.map((item) => ( + + + + + + + + + ))} +
时间管理员操作类型对象安全摘要结果Operation ID
{item.actor_ref}{item.actor_type}{item.operation_type}{item.target_type}:{item.target_ref}{operationSummary(item)}{item.result}{item.log_id}
+
+ ) : null} +
+ ) : ( +
+ {privateAccess.loading && privateAccess.items.length === 0 ?

正在读取私有内容访问审计

: null} + {!privateAccess.loading && !privateAccess.failed && privateAccess.items.length === 0 ?

当前没有私有内容访问审计记录。

: null} + {privateAccess.items.length > 0 ? ( +
+ + + {privateAccess.items.map((item) => ( + + + + + + + + + ))} +
时间管理员安全目标标识内容类型到期时间Access ID
{item.actor_ref}{item.target_ref}{item.content_type}{item.log_id}
+
+ ) : null} +
+ )} + + {state.nextCursor ? ( +
+ +
+ ) : null} +

记录保留 180 天。此页面不提供编辑、删除或清空能力。

+
+ ); +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index c0544fc..4154e56 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js"; +import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOperationAuditResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, PrivateContentAccessAuditResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -120,6 +120,13 @@ export async function getAdminDiagnostics(options: ClientOptions = {}): Promise< return response.json() as Promise; } +export async function getAdminOperationAudit(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/operations`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getAdminOverview(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} }); @@ -208,6 +215,13 @@ export async function getMyCredits(options: ClientOptions = {}): Promise; } +export async function getPrivateContentAccessAudit(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/private-content`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getProject(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} }); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index a617fdc..a38445e 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -50,6 +50,11 @@ export type AccountSettingsResponse = { }; }; +export type AdminAuditQuery = { + "cursor"?: string; + "limit"?: number; +}; + export type AdminAuthenticatedUser = { "role": "super_admin"; "status": "active"; @@ -112,6 +117,26 @@ export type AdminLoginSendRequest = { "email": string; }; +export type AdminOperationAuditItem = { + "actor_ref": string; + "actor_type": "system" | "super_admin"; + "after_summary": string | null; + "before_summary": string | null; + "expires_at": string; + "log_id": string; + "occurred_at": string; + "operation_type": string; + "result": "succeeded" | "failed"; + "target_ref": string; + "target_type": string; +}; + +export type AdminOperationAuditResponse = { + "generated_at": string; + "items": Array; + "next_cursor": string | null; +}; + export type AdminOverviewResponse = { "asset_cleanup": { "pending_jobs": number; @@ -681,6 +706,21 @@ export type ModelRuntimeSseEvent = { "runtime_availability_version": number; }; +export type PrivateContentAccessAuditItem = { + "actor_ref": string; + "content_type": "image" | "prompt"; + "expires_at": string; + "log_id": string; + "occurred_at": string; + "target_ref": string; +}; + +export type PrivateContentAccessAuditResponse = { + "generated_at": string; + "items": Array; + "next_cursor": string | null; +}; + export type PrivateContentGenerationParams = { "generationId": string; }; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9e6872e..150799f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,6 +10,7 @@ import { AdminModelsPage } from "./admin-models.js"; import { AdminAssetsPage } from "./admin-assets.js"; import { AdminGenerationsPage } from "./admin-generations.js"; import { AdminServicesStoragePage } from "./admin-services-storage.js"; +import { AdminAuditPage } from "./admin-audit.js"; import { CreditsPage } from "./credits-page.js"; import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js"; import { EditorPage } from "./editor-page.js"; @@ -43,7 +44,7 @@ function renderAuthenticationEntry() { const adminPages: Record = { "/admin": { content: , title: "运营总览" }, "/admin/assets": { content: , title: "素材" }, - "/admin/audit": { content: , title: "审计" }, + "/admin/audit": { content: , title: "审计" }, "/admin/generations": { content: , title: "生成记录" }, "/admin/invites": { content: , title: "邀请码" }, "/admin/models": { content: , title: "模型" }, diff --git a/apps/worker/src/project-purge-cleanup.ts b/apps/worker/src/project-purge-cleanup.ts index 76d0edb..9547d85 100644 --- a/apps/worker/src/project-purge-cleanup.ts +++ b/apps/worker/src/project-purge-cleanup.ts @@ -212,10 +212,19 @@ export class ProjectPurgeCleanup { transaction.immediate(); completed += 1; } catch { - this.database.prepare(` - UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed' - WHERE cleanup_id = ? - `).run(row.cleanup_id); + const transaction = this.database.transaction(() => { + this.database.prepare(` + UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed' + WHERE cleanup_id = ? + `).run(row.cleanup_id); + if (row.managed_file_id && this.tableExists("asset_cleanup_request_items")) { + const request = this.database.prepare(` + SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ? LIMIT 1 + `).get(row.managed_file_id) as { request_id: string } | undefined; + if (request) this.insertAssetCleanupFailureAudit(request.request_id, this.clock()); + } + }); + transaction.immediate(); failed += 1; } } @@ -296,6 +305,19 @@ export class ProjectPurgeCleanup { ); } + private insertAssetCleanupFailureAudit(requestId: string, occurredAt: number) { + if (!this.tableExists("admin_operation_logs")) return; + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'project_purge_worker', 'asset_cleanup_physical_failed', 'asset_cleanup', ?, 'failed', NULL, ?, ?, ?) + `).run( + randomUUID(), requestId, JSON.stringify({ failed_count: 1, status: "retry_pending" }), occurredAt, + occurredAt + auditRetentionMilliseconds, + ); + } + private remeasureManagedCapacity() { const state = this.database.prepare(` SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1 diff --git a/openapi/openapi.json b/openapi/openapi.json index 9758b71..a1e7e95 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -260,6 +260,22 @@ ], "type": "object" }, + "AdminAuditQuery": { + "additionalProperties": false, + "properties": { + "cursor": { + "maxLength": 512, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "limit": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, "AdminAuthenticatedUser": { "additionalProperties": false, "properties": { @@ -717,6 +733,143 @@ ], "type": "object" }, + "AdminOperationAuditItem": { + "additionalProperties": false, + "properties": { + "actor_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$", + "type": "string" + }, + "actor_type": { + "anyOf": [ + { + "enum": [ + "system" + ], + "type": "string" + }, + { + "enum": [ + "super_admin" + ], + "type": "string" + } + ] + }, + "after_summary": { + "anyOf": [ + { + "maxLength": 2048, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "before_summary": { + "anyOf": [ + { + "maxLength": 2048, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expires_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "log_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "operation_type": { + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$", + "type": "string" + }, + "result": { + "anyOf": [ + { + "enum": [ + "succeeded" + ], + "type": "string" + }, + { + "enum": [ + "failed" + ], + "type": "string" + } + ] + }, + "target_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$", + "type": "string" + }, + "target_type": { + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$", + "type": "string" + } + }, + "required": [ + "actor_ref", + "actor_type", + "after_summary", + "before_summary", + "expires_at", + "log_id", + "occurred_at", + "operation_type", + "result", + "target_ref", + "target_type" + ], + "type": "object" + }, + "AdminOperationAuditResponse": { + "additionalProperties": false, + "properties": { + "generated_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/AdminOperationAuditItem" + }, + "maxItems": 100, + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "maxLength": 512, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "generated_at", + "items", + "next_cursor" + ], + "type": "object" + }, "AdminOverviewResponse": { "additionalProperties": false, "properties": { @@ -4471,6 +4624,90 @@ ], "type": "object" }, + "PrivateContentAccessAuditItem": { + "additionalProperties": false, + "properties": { + "actor_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$", + "type": "string" + }, + "content_type": { + "anyOf": [ + { + "enum": [ + "image" + ], + "type": "string" + }, + { + "enum": [ + "prompt" + ], + "type": "string" + } + ] + }, + "expires_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "log_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "target_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$", + "type": "string" + } + }, + "required": [ + "actor_ref", + "content_type", + "expires_at", + "log_id", + "occurred_at", + "target_ref" + ], + "type": "object" + }, + "PrivateContentAccessAuditResponse": { + "additionalProperties": false, + "properties": { + "generated_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/PrivateContentAccessAuditItem" + }, + "maxItems": 100, + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "maxLength": 512, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "generated_at", + "items", + "next_cursor" + ], + "type": "object" + }, "PrivateContentGenerationParams": { "additionalProperties": false, "properties": { @@ -6214,6 +6451,122 @@ ] } }, + "/api/v1/admin/audit/operations": { + "get": { + "operationId": "getAdminOperationAudit", + "parameters": [ + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 512, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminOperationAuditResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "description": "Default Response" + } + }, + "tags": [ + "Admin Operations" + ] + } + }, + "/api/v1/admin/audit/private-content": { + "get": { + "operationId": "getPrivateContentAccessAudit", + "parameters": [ + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 512, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateContentAccessAuditResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "description": "Default Response" + } + }, + "tags": [ + "Admin Operations" + ] + } + }, "/api/v1/admin/diagnostics": { "get": { "operationId": "getAdminDiagnostics", diff --git a/package.json b/package.json index 2a337ae..0afec29 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/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp5-05-admin-assets.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-05-state.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/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp5-05-admin-assets.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-04-audit.spec.ts tests/e2e/wp6-05-state.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", @@ -99,6 +99,8 @@ "test:wp5-05:red": "node scripts/run-wp5-05-validation.mjs --phase red", "test:wp6-01": "node scripts/run-wp6-01-validation.mjs --phase scaffold", "test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red", + "test:wp6-04": "node scripts/run-wp6-04-validation.mjs --phase green", + "test:wp6-04:red": "node scripts/run-wp6-04-validation.mjs --phase red", "test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts" }, "devDependencies": { diff --git a/packages/shared-contracts/src/admin.ts b/packages/shared-contracts/src/admin.ts index 11fb6b8..eb73916 100644 --- a/packages/shared-contracts/src/admin.ts +++ b/packages/shared-contracts/src/admin.ts @@ -70,6 +70,67 @@ export type PrivateContentNoticeAckRequest = Static; export type PrivateContentPromptResponse = Static; +export const AdminAuditQuerySchema = Type.Object( + { + cursor: Type.Optional(Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" })), + limit: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })), + }, + { additionalProperties: false, $id: "AdminAuditQuery" }, +); + +export const AdminOperationAuditItemSchema = Type.Object( + { + actor_ref: Type.String({ pattern: safeReferencePattern }), + actor_type: Type.Union([Type.Literal("system"), Type.Literal("super_admin")]), + after_summary: Type.Union([Type.String({ maxLength: 2048 }), Type.Null()]), + before_summary: Type.Union([Type.String({ maxLength: 2048 }), Type.Null()]), + expires_at: Type.String({ pattern: isoTimestampPattern }), + log_id: Type.String({ pattern: uuidPattern }), + occurred_at: Type.String({ pattern: isoTimestampPattern }), + operation_type: Type.String({ maxLength: 160, pattern: safeReferencePattern }), + result: Type.Union([Type.Literal("succeeded"), Type.Literal("failed")]), + target_ref: Type.String({ pattern: safeReferencePattern }), + target_type: Type.String({ maxLength: 160, pattern: safeReferencePattern }), + }, + { additionalProperties: false, $id: "AdminOperationAuditItem" }, +); + +export const AdminOperationAuditResponseSchema = Type.Object( + { + generated_at: Type.String({ pattern: isoTimestampPattern }), + items: Type.Array(Type.Ref(AdminOperationAuditItemSchema), { maxItems: 100 }), + next_cursor: Type.Union([Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" }), Type.Null()]), + }, + { additionalProperties: false, $id: "AdminOperationAuditResponse" }, +); + +export const PrivateContentAccessAuditItemSchema = Type.Object( + { + actor_ref: Type.String({ pattern: safeReferencePattern }), + content_type: Type.Union([Type.Literal("image"), Type.Literal("prompt")]), + expires_at: Type.String({ pattern: isoTimestampPattern }), + log_id: Type.String({ pattern: uuidPattern }), + occurred_at: Type.String({ pattern: isoTimestampPattern }), + target_ref: Type.String({ pattern: safeReferencePattern }), + }, + { additionalProperties: false, $id: "PrivateContentAccessAuditItem" }, +); + +export const PrivateContentAccessAuditResponseSchema = Type.Object( + { + generated_at: Type.String({ pattern: isoTimestampPattern }), + items: Type.Array(Type.Ref(PrivateContentAccessAuditItemSchema), { maxItems: 100 }), + next_cursor: Type.Union([Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" }), Type.Null()]), + }, + { additionalProperties: false, $id: "PrivateContentAccessAuditResponse" }, +); + +export type AdminAuditQuery = Static; +export type AdminOperationAuditItem = Static; +export type AdminOperationAuditResponse = Static; +export type PrivateContentAccessAuditItem = Static; +export type PrivateContentAccessAuditResponse = Static; + export const AdminOverviewResponseSchema = Type.Object( { generated_at: Type.String({ pattern: isoTimestampPattern }), diff --git a/scripts/run-wp6-04-validation.mjs b/scripts/run-wp6-04-validation.mjs new file mode 100644 index 0000000..38e7579 --- /dev/null +++ b/scripts/run-wp6-04-validation.mjs @@ -0,0 +1,102 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, 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 (!new Set(["red", "green"]).has(phase)) throw new Error(`Unsupported phase: ${phase}`); + +const runId = process.env.DADA_TDD_RUN_ID ?? `wp6-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const caseDirectory = resolve(runDirectory, "cases", "TDD-WP6-AUD-001-sensitive-operations"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(caseDirectory, { recursive: true }); + +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_WP6_AUD: caseDirectory, + DADA_PLAYWRIGHT_OUTPUT_DIR: resolve(runDirectory, "playwright-output"), +}; +const commands = phase === "red" + ? [ + ["integration-red", ".\\node_modules\\.bin\\vitest.CMD run tests/integration/wp6-04-sensitive-audit.test.ts tests/integration/wp5-05-sticker-release.test.ts"], + ["api-red", ".\\node_modules\\.bin\\vitest.CMD run tests/api/wp6-04-audit.test.ts"], + ["e2e-red", ".\\node_modules\\.bin\\playwright.CMD test tests/e2e/wp6-04-audit.spec.ts --config playwright.config.ts"], + ] + : [ + ["integration", "pnpm.cmd test:integration"], + ["api", "pnpm.cmd test:api"], + ["worker", "pnpm.cmd test:worker"], + ["e2e", "pnpm.cmd test:e2e"], + ["tdd-trace", "pnpm.cmd validate:tdd-trace"], + ]; + +const commandResults = []; +for (const [name, command] of commands) { + const started_at = new Date().toISOString(); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { + encoding: "utf8", + env: environment, + maxBuffer: 40 * 1024 * 1024, + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); + if (phase === "green" && (result.status ?? 1) !== 0) break; +} + +function findFiles(directory, name) { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? findFiles(path, name) : entry.name === name ? [path] : []; + }); +} + +const trace = findFiles(environment.DADA_PLAYWRIGHT_OUTPUT_DIR, "trace.zip") + .find((path) => path.toLowerCase().includes("wp6-04-audit")); +if (trace) copyFileSync(trace, resolve(caseDirectory, "trace.zip")); + +const redConfirmed = phase === "red" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code !== 0); +if (phase === "red") { + writeFileSync(resolve(caseDirectory, "red-observation.json"), `${JSON.stringify({ + expected_failure: "Audit list APIs and UI are absent, while invite, user-status, and sticker-release mutations are missing same-transaction AdminOperationLog coverage.", + observed_commands: commandResults, + red_reason: "TDD-WP6-AUD-001 first Red: required operations can be missing audit rows, the two log types have no separate admin read contract, and no admin audit page exists.", + status: redConfirmed ? "red_confirmed" : "failed", + }, null, 2)}\n`); +} + +const expectedEvidence = phase === "red" + ? ["red-observation.json"] + : ["operation-matrix.json", "db-diff.json", "redaction.json", "trace.zip"]; +const missingEvidence = expectedEvidence.filter((file) => !existsSync(resolve(caseDirectory, file))); +const greenPassed = phase === "green" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code === 0); +const status = phase === "red" + ? redConfirmed && missingEvidence.length === 0 ? "red_confirmed" : "failed" + : greenPassed && missingEvidence.length === 0 ? "green" : "failed"; +const result = { + acceptance_criteria: ["AC-50"], + automation: ["automated"], + commit: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(), + evidence_refs: expectedEvidence, + layer: ["DB", "API", "WRK", "E2E"], + manifest: { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(), + }, + missing_evidence: missingEvidence, + phase, + requirements: ["ADMIN-09"], + run_id: runId, + status, + task_id: "TASK-WP6-04", + test_id: "TDD-WP6-AUD-001-sensitive-operations", + work_package: "WP-6", +}; +writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ missing_evidence: missingEvidence, status, test_id: result.test_id }], phase, run_id: runId, status }, null, 2)}\n`); +console.log(JSON.stringify({ phase, run_id: runId, status }, null, 2)); +if (status === "failed") process.exit(1); diff --git a/tests/api/wp6-04-audit.test.ts b/tests/api/wp6-04-audit.test.ts new file mode 100644 index 0000000..f2bdaf2 --- /dev/null +++ b/tests/api/wp6-04-audit.test.ts @@ -0,0 +1,123 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { auditRetentionMilliseconds, serializeAuditSummary } from "../../apps/api/src/audit-policy.js"; +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 { wp604OperationMatrix } from "../fixtures/wp6-04-audit.js"; + +const roots: string[] = []; +const services: RegistrationService[] = []; +const now = Date.parse("2026-08-04T09:30:00.000Z"); +const requestHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "dada-wp6-04-api-")); + roots.push(root); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0xa1), + clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0xa2), + resend: new MockResendAdapter(), + sessionPepper: Buffer.alloc(32, 0xa3), + }); + services.push(registration); + return registration; +} + +function seedSubject(registration: RegistrationService, role: "super_admin" | "user") { + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, ?, 'active', ?, ?, ?) + `).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now); + if (role === "super_admin") { + registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + } + return userId; +} + +function seedAuditRows(registration: RegistrationService, adminId: string) { + const operationInsert = registration.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'super_admin', ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + wp604OperationMatrix.forEach((entry, index) => { + const occurredAt = now - index * 1_000; + operationInsert.run( + randomUUID(), adminId, entry.operation_type, entry.target_type, randomUUID(), + entry.operation_type === "asset_cleanup_reference_denied" || entry.operation_type === "asset_cleanup_physical_failed" ? "failed" : "succeeded", + serializeAuditSummary({ status: "before" }), serializeAuditSummary({ count: index, status: "after" }), + occurredAt, occurredAt + auditRetentionMilliseconds, + ); + }); + registration.database.prepare(` + INSERT INTO private_content_access_logs ( + log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, 'prompt', ?, ?) + `).run(randomUUID(), adminId, randomUUID(), randomUUID(), now - 60_000, now - 60_000 + auditRetentionMilliseconds); +} + +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-WP6-AUD-001-sensitive-operations", () => { + it("keeps operation and private-content audit APIs separate, admin-only, redacted, and cursor-paged", async () => { + const registration = fixture(); + const adminId = seedSubject(registration, "super_admin"); + const userId = seedSubject(registration, "user"); + seedAuditRows(registration, adminId); + const admin = registration.issueAuthenticatedSession(adminId, "admin"); + const ordinary = registration.issueAuthenticatedSession(userId, "user"); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + + for (const path of ["operations", "private-content"]) { + const denied = await app.inject({ + headers: { ...requestHeaders, cookie: `dada_admin_session=${ordinary.sessionToken}` }, + method: "GET", + url: `/api/v1/admin/audit/${path}?limit=2`, + }); + expect(denied.statusCode).toBe(401); + } + + const headers = { ...requestHeaders, cookie: `dada_admin_session=${admin.sessionToken}` }; + const first = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/operations?limit=2" }); + expect(first.statusCode).toBe(200); + expect(first.json().items).toHaveLength(2); + expect(first.json().next_cursor).toEqual(expect.any(String)); + const second = await app.inject({ headers, method: "GET", url: `/api/v1/admin/audit/operations?limit=2&cursor=${first.json().next_cursor}` }); + expect(second.statusCode).toBe(200); + expect(second.json().items[0].log_id).not.toBe(first.json().items[0].log_id); + + const privateAccess = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/private-content?limit=20" }); + expect(privateAccess.statusCode).toBe(200); + expect(privateAccess.json().items).toHaveLength(1); + expect(privateAccess.json().items[0]).toMatchObject({ content_type: "prompt" }); + expect(JSON.stringify(first.json())).not.toMatch(/content_type|subject_ref|prompt|image|email|secret|path/i); + expect(JSON.stringify(privateAccess.json())).not.toMatch(/operation_type|before_summary|after_summary|email|secret|path/i); + + const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD; + if (evidenceRoot) { + mkdirSync(evidenceRoot, { recursive: true }); + writeFileSync(resolve(evidenceRoot, "redaction.json"), `${JSON.stringify({ + operation_fields: Object.keys(first.json().items[0]).sort(), + private_access_fields: Object.keys(privateAccess.json().items[0]).sort(), + sensitive_fields_present: false, + }, null, 2)}\n`); + } + await app.close(); + }); +}); diff --git a/tests/e2e/wp5-05-admin-assets.spec.ts b/tests/e2e/wp5-05-admin-assets.spec.ts index 3d26b4a..16a03ed 100644 --- a/tests/e2e/wp5-05-admin-assets.spec.ts +++ b/tests/e2e/wp5-05-admin-assets.spec.ts @@ -7,7 +7,16 @@ import { createServer, type ViteDevServer } from "vite"; let vite: ViteDevServer; let webUrl: string; const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64"); -const adminSession = { csrf_token: "csrf-wp5-05-admin-000000000000000000000000000000000" }; +const adminSession = { + acknowledged_private_content_notice_version: null, + admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000001405" }, + audience: "admin", + authenticated: true, + csrf_token: "csrf-wp5-05-admin-000000000000000000000000000000000", + current_private_content_notice_version: null, + expires_at: "2026-09-03T12:00:00.000Z", + notice_acknowledged: false, +}; const projectId = "00000000-0000-4000-8000-000000001405"; const userSession = { audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, diff --git a/tests/e2e/wp6-04-audit.spec.ts b/tests/e2e/wp6-04-audit.spec.ts new file mode 100644 index 0000000..ec5c4de --- /dev/null +++ b/tests/e2e/wp6-04-audit.spec.ts @@ -0,0 +1,51 @@ +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +import { wp604AuditApiFixture, wp604PrivateAuditApiFixture } from "../fixtures/wp6-04-audit.js"; + +let vite: ViteDevServer; +let webUrl: string; + +const adminSession = { + acknowledged_private_content_notice_version: null, + admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000604" }, + audience: "admin", + authenticated: true, + csrf_token: "csrf-wp6-04-admin-0000000000000000000000000000000000", + current_private_content_notice_version: null, + expires_at: "2026-09-04T09:30:00.000Z", + notice_acknowledged: false, +}; + +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-WP6-AUD-001-sensitive-operations renders two immutable, redacted audit lists", async ({ page }) => { + await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 })); + await page.route("**/api/v1/admin/audit/operations**", (route) => route.fulfill({ body: JSON.stringify(wp604AuditApiFixture), contentType: "application/json", status: 200 })); + await page.route("**/api/v1/admin/audit/private-content**", (route) => route.fulfill({ body: JSON.stringify(wp604PrivateAuditApiFixture), contentType: "application/json", status: 200 })); + + await page.goto(`${webUrl}/admin/audit`); + await expect(page.getByRole("heading", { level: 2, name: "审计" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "后台操作审计" })).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("cell", { name: "user_status_change" })).toBeVisible(); + await expect(page.getByRole("button", { name: "下一页" })).toBeVisible(); + + await page.getByRole("tab", { name: "私有内容访问审计" }).click(); + await expect(page.getByRole("cell", { name: "prompt" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "00000000-0000-4000-8000-000000000644" })).toBeVisible(); + await expect(page.getByText(/private prompt body|api key|absolute path/i)).toHaveCount(0); + await expect(page.getByRole("button", { name: /删除|编辑|清空/ })).toHaveCount(0); + + const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD; + if (evidenceRoot) await page.screenshot({ fullPage: true, path: resolve(evidenceRoot, "admin-audit.png") }); +}); diff --git a/tests/fixtures/wp6-04-audit.ts b/tests/fixtures/wp6-04-audit.ts new file mode 100644 index 0000000..014615f --- /dev/null +++ b/tests/fixtures/wp6-04-audit.ts @@ -0,0 +1,52 @@ +export const wp604OperationMatrix = [ + { operation_type: "user_status_change", target_type: "user_account" }, + { operation_type: "credit_adjustment", target_type: "user_credit_account" }, + { operation_type: "invite_create", target_type: "invite" }, + { operation_type: "model_configuration_replace", target_type: "model_config_set" }, + { operation_type: "sticker_release_publish", target_type: "sticker_release" }, + { operation_type: "preview_grant_create", target_type: "preview_grant" }, + { operation_type: "service_hard_limit_update", target_type: "external_service_limit" }, + { operation_type: "gateway_balance_recovery", target_type: "gateway_balance_state" }, + { operation_type: "asset_cleanup_requested", target_type: "asset_cleanup_request" }, + { operation_type: "asset_cleanup_validated", target_type: "asset_cleanup_request" }, + { operation_type: "asset_cleanup_scheduled", target_type: "asset_cleanup_request" }, + { operation_type: "asset_cleanup_reference_denied", target_type: "asset_cleanup_request" }, + { operation_type: "asset_cleanup_physical_completed", target_type: "asset_cleanup" }, + { operation_type: "asset_cleanup_physical_failed", target_type: "asset_cleanup" }, + { operation_type: "secure_config_apply", target_type: "secure_config_revision" }, +] as const; + +export const wp604AuditApiFixture = { + generated_at: "2026-08-04T09:30:00.000Z", + items: [ + { + actor_ref: "00000000-0000-4000-8000-000000000604", + actor_type: "super_admin", + after_summary: "{\"status\":\"suspended\"}", + before_summary: "{\"status\":\"active\"}", + expires_at: "2027-01-31T09:30:00.000Z", + log_id: "00000000-0000-4000-8000-000000000641", + occurred_at: "2026-08-04T09:30:00.000Z", + operation_type: "user_status_change", + result: "succeeded", + target_ref: "00000000-0000-4000-8000-000000000642", + target_type: "user_account", + }, + ], + next_cursor: "audit_cursor_page_2", +} as const; + +export const wp604PrivateAuditApiFixture = { + generated_at: "2026-08-04T09:30:00.000Z", + items: [ + { + actor_ref: "00000000-0000-4000-8000-000000000604", + content_type: "prompt", + expires_at: "2027-01-31T09:29:00.000Z", + log_id: "00000000-0000-4000-8000-000000000643", + occurred_at: "2026-08-04T09:29:00.000Z", + target_ref: "00000000-0000-4000-8000-000000000644", + }, + ], + next_cursor: null, +} as const; diff --git a/tests/integration/wp5-05-sticker-release.test.ts b/tests/integration/wp5-05-sticker-release.test.ts index 55028f6..e06a267 100644 --- a/tests/integration/wp5-05-sticker-release.test.ts +++ b/tests/integration/wp5-05-sticker-release.test.ts @@ -1,15 +1,19 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Readable } from "node:stream"; +import type BetterSqlite3 from "better-sqlite3"; import { afterEach, describe, expect, it } from "vitest"; import { HARD_LIMIT_BYTES, ManagedStorage, StorageCapacityError, StorageUnavailableError } from "../../apps/api/src/managed-storage.js"; import { StickerReleaseService } from "../../apps/api/src/sticker-releases.js"; const now = Date.parse("2026-08-03T12:00:00.000Z"); +const require = createRequire(resolve("apps/api/package.json")); +const Database = require("better-sqlite3") as typeof BetterSqlite3; const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64"); const webp = Buffer.from("UklGRjoAAABXRUJQVlA4IC4AAADQAQCdASoGAAUAAUAmJaACdLoB+AADsAD+9IiH/pNnibPE2fJI/+Uq8Fjc3wAA", "base64"); const roots: string[] = []; @@ -38,7 +42,7 @@ function fixture() { const storage = new ManagedStorage({ dataRoot, databasePath }); const stickers = new StickerReleaseService({ clock: () => now, databasePath, storage }); closeables.push(stickers, storage); - return { dataRoot, stickers, storage }; + return { dataRoot, databasePath, stickers, storage }; } async function upload(stickers: StickerReleaseService, stableId: string, order: number, bytes = png, mimeType: "image/png" | "image/webp" = "image/png") { @@ -82,6 +86,26 @@ describe("TDD-WP5-UPL-001 upload metering", () => { expect(test.stickers.listPublic(published.release_version).items).toHaveLength(1); expect(test.stickers.readPublicAsset(published.release_version, "STK1408", "original")?.bytes).toEqual(png); expect(test.stickers.readPublicAsset(disabled.release_version, "STK1408", "original")).toBeUndefined(); + const audit = new Database(test.databasePath, { readonly: true }); + expect(audit.prepare(` + SELECT operation_type, target_ref, result FROM admin_operation_logs + WHERE operation_type IN ('sticker_release_publish', 'sticker_release_update') + ORDER BY occurred_at + `).all()).toEqual([ + { operation_type: "sticker_release_publish", result: "succeeded", target_ref: published.release_version }, + { operation_type: "sticker_release_update", result: "succeeded", target_ref: disabled.release_version }, + ]); + audit.close(); + const auditFailure = new Database(test.databasePath); + auditFailure.exec(` + CREATE TRIGGER force_sticker_release_audit_failure BEFORE INSERT ON admin_operation_logs + WHEN NEW.operation_type = 'sticker_release_update' + BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END; + `); + expect(() => test.stickers.update({ actorId: randomUUID(), enabled: true, stableId: "STK1408" })) + .toThrow("forced_audit_failure"); + expect(test.stickers.adminView()).toMatchObject({ release_version: disabled.release_version, items: [{ enabled: false }] }); + auditFailure.close(); evidence("fs-before.json", before); evidence("fs-after.json", { files: filesBelow(join(test.dataRoot, "managed-assets")), state: test.storage.getState() }); diff --git a/tests/integration/wp5-06-sticker-cleanup.test.ts b/tests/integration/wp5-06-sticker-cleanup.test.ts index b15ad1f..0cf9d29 100644 --- a/tests/integration/wp5-06-sticker-cleanup.test.ts +++ b/tests/integration/wp5-06-sticker-cleanup.test.ts @@ -136,4 +136,45 @@ describe("TDD-WP5-CLN-001 sticker history cleanup", () => { { operation_type: "asset_cleanup_physical_completed", result: "succeeded" }, ]); }); + + it("records a redacted physical failure in the same transaction and leaves the request retryable", async () => { + const test = fixture(); + const adminId = seedAdmin(test.database); + const files = await seedHistoricalPair(test); + const candidates = test.storage.listAssetCleanupCandidates(); + const intent = test.storage.createAssetCleanupIntent({ + actorId: adminId, + fileIds: [files.original.file_id, files.thumbnail.file_id], + idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`, + snapshotVersion: candidates.candidate_snapshot_version, + }); + test.storage.confirmAssetCleanupIntent({ + actorId: adminId, + confirmationToken: intent.confirmation_token, + requestId: intent.request_id, + }); + test.database.prepare(` + UPDATE file_cleanup_queue SET relative_path = '../outside-fixture' + WHERE managed_file_id = ? + `).run(files.original.file_id); + + const worker = new ProjectPurgeCleanup({ dataRoot: test.dataRoot, databasePath: test.databasePath }); + const result = worker.processFileCleanup(); + worker.close(); + expect(result).toEqual({ completed: 1, failed: 1 }); + expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "queued" }); + expect(test.database.prepare("SELECT status, last_error FROM file_cleanup_queue WHERE managed_file_id = ?").get(files.original.file_id)) + .toEqual({ last_error: "physical_file_cleanup_failed", status: "failed" }); + const failure = test.database.prepare(` + SELECT operation_type, result, before_summary, after_summary + FROM admin_operation_logs WHERE target_ref = ? AND operation_type = 'asset_cleanup_physical_failed' + `).get(intent.request_id) as { after_summary: string; before_summary: null; operation_type: string; result: string }; + expect(failure).toEqual({ + after_summary: JSON.stringify({ failed_count: 1, status: "retry_pending" }), + before_summary: null, + operation_type: "asset_cleanup_physical_failed", + result: "failed", + }); + expect(JSON.stringify(failure)).not.toMatch(/outside-fixture|relative_path|absolute_path|image|prompt|secret/i); + }); }); diff --git a/tests/integration/wp6-04-sensitive-audit.test.ts b/tests/integration/wp6-04-sensitive-audit.test.ts new file mode 100644 index 0000000..04a8b5b --- /dev/null +++ b/tests/integration/wp6-04-sensitive-audit.test.ts @@ -0,0 +1,90 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, 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"; +import { wp604OperationMatrix } from "../fixtures/wp6-04-audit.js"; + +const roots: string[] = []; +const services: RegistrationService[] = []; +const now = Date.parse("2026-08-04T09:30:00.000Z"); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "dada-wp6-04-integration-")); + roots.push(root); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0xb1), + clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0xb2), + inviteCodeGenerator: () => "fixture-invite-code", + resend: new MockResendAdapter(), + sessionPepper: Buffer.alloc(32, 0xb3), + }); + services.push(registration); + return registration; +} + +function seedSubject(registration: RegistrationService, role: "super_admin" | "user") { + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, ?, 'active', ?, ?, ?) + `).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now); + if (role === "super_admin") { + registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + } + return userId; +} + +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-WP6-AUD-001-sensitive-operations", () => { + it("writes invite and user status audits in the same transaction as the mutation", () => { + const registration = fixture(); + const adminId = seedSubject(registration, "super_admin"); + const userId = seedSubject(registration, "user"); + const adminOperations = registration as RegistrationService & { + createAdminInvite(input: { actorId: string; expiresAt: number; maxUses: number }): { code: string; inviteId: string }; + changeUserStatus(userId: string, status: "suspended" | "deleted", actorId: string): void; + }; + + const invite = adminOperations.createAdminInvite({ actorId: adminId, expiresAt: now + 86_400_000, maxUses: 1 }); + adminOperations.changeUserStatus(userId, "suspended", adminId); + expect(registration.database.prepare(` + SELECT operation_type, target_ref FROM admin_operation_logs + WHERE operation_type IN ('invite_create', 'user_status_change') ORDER BY occurred_at + `).all()).toEqual([ + { operation_type: "invite_create", target_ref: invite.inviteId }, + { operation_type: "user_status_change", target_ref: userId }, + ]); + + registration.database.exec(` + CREATE TRIGGER force_user_status_audit_failure BEFORE INSERT ON admin_operation_logs + WHEN NEW.operation_type = 'user_status_change' + BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END; + `); + expect(() => adminOperations.changeUserStatus(userId, "deleted", adminId)).toThrow("forced_audit_failure"); + expect(registration.database.prepare("SELECT status FROM users WHERE user_id = ?").get(userId)).toEqual({ status: "suspended" }); + + const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD; + if (evidenceRoot) { + mkdirSync(evidenceRoot, { recursive: true }); + writeFileSync(resolve(evidenceRoot, "operation-matrix.json"), `${JSON.stringify({ operations: wp604OperationMatrix }, null, 2)}\n`); + writeFileSync(resolve(evidenceRoot, "db-diff.json"), `${JSON.stringify({ + audit_failure_rollback: { user_status: "suspended" }, + tables: { admin_operation_logs: "append_only", private_content_access_logs: "append_only_separate" }, + }, null, 2)}\n`); + } + }); +});