// The account page's platform analytics erasure and export (ADR-049) over a fake privacy port: the // subject is always the signed-in user, a repeated request answers the open job, a ticket reaches // only its own user's job, the export downloads when completed, and nothing runs for an API // credential or without the platform service. import { AppError } from "@conusai-kit/core" import { describe, expect, it } from "vitest" import { requestIdOf, TICKET_KEY_INFO, TICKET_LIFETIME_MS, TICKET_VERSION, ticketFor } from "../core/analytics.js" import type { AccountPrivacy, AccountPrivacyRequest, AnalyticsView } from "../index.js" import { makeApp, type Person } from "./harness.js" /** A privacy service like the platform's: one open job per kind and user, settled by `finish`. */ function fakePrivacy() { const jobs = new Map() const calls: { op: string; userId?: string; id?: string }[] = [] let next = 1 const open = (kind: "erase" | "export", userId: string) => { calls.push({ op: kind, userId }) for (const job of jobs.values()) { if (job.kind === kind && job.userId === userId && (job.status === "pending" || job.status === "running")) { return { request: job, created: false } } } const id = `00000000-0000-4000-8000-${String(next++).padStart(12, "0")}` const job = { id, kind, userId, status: "pending" as const, stores: {}, created_at: "2026-10-26T08:00:00Z" } jobs.set(id, job) return { request: job, created: true } } const privacy: AccountPrivacy = { ticketKey: "dt_fixture_secret", eraseSubject: async ({ userId }) => open("erase", userId), exportSubject: async ({ userId }) => open("export", userId), getRequest: async (id) => { calls.push({ op: "get", id }) const job = jobs.get(id) if (!job) throw Object.assign(new Error("not found"), { status: 404 }) return job }, downloadExport: async (id) => { calls.push({ op: "download", id }) const job = jobs.get(id) if (!job) throw Object.assign(new Error("not found"), { status: 404 }) if (job.kind !== "export" || job.status !== "completed") throw Object.assign(new Error("not ready"), { status: 409 }) return { request_id: id, stores: { analytics: { events: [] } } } }, } const finish = (id: string) => { const job = jobs.get(id) if (!job) throw new Error("no such job") jobs.set(id, { ...job, status: "completed", finished_at: "2026-10-26T08:05:00Z", stores: { analytics: { status: "completed", deleted: 2, anonymized: 40 }, logs: { status: "not_keyed" } }, }) } return { privacy, calls, finish, jobs } } const requestAs = (person: Person, kind: "erase" | "export") => person.call().callByName("account.analytics.request", { kind }) as Promise const statusAs = (person: Person, ticket: string) => person.call().callByName("account.analytics.status", { ticket }) as Promise const exportAs = (person: Person, ticket: string) => person.call().callByName("account.analytics.export", { ticket }) as Promise<{ document: Record }> const problemOf = async (run: Promise) => { const error = await run.then( () => undefined, (thrown: unknown) => thrown, ) expect(error).toBeInstanceOf(AppError) return (error as AppError).problem } /** The id of the one job the fake platform holds; fails the test when there is none. */ function onlyJob(fake: { jobs: Map }): string { const [id] = fake.jobs.keys() if (id === undefined) throw new Error("the fake platform holds no job") return id } describe("account.analytics tickets", () => { const T0 = Date.parse("2026-10-26T08:00:00Z") const notFound = { problem: { status: 404 } } it("name the request for one user only, and refuse a forged or swapped one", async () => { const ticket = await ticketFor("key", "user-a", "req-1", T0) await expect(requestIdOf("key", "user-a", ticket, T0)).resolves.toBe("req-1") await expect(requestIdOf("key", "user-b", ticket, T0)).rejects.toMatchObject(notFound) await expect(requestIdOf("key", "user-a", ticket.replace(".req-1.", ".req-2."), T0)).rejects.toMatchObject(notFound) await expect(requestIdOf("key", "user-a", "no-dot", T0)).rejects.toMatchObject(notFound) // A flipped MAC digit, and a ticket of the old `.` shape, are forgeries too. const last = ticket.at(-1) === "0" ? "1" : "0" await expect(requestIdOf("key", "user-a", `${ticket.slice(0, -1)}${last}`, T0)).rejects.toMatchObject(notFound) await expect(requestIdOf("key", "user-a", `req-1.${ticket.split(".").at(-1)}`, T0)).rejects.toMatchObject(notFound) }) it("carry the version and an expiry inside the signed payload", async () => { const ticket = await ticketFor("key", "user-a", "req-1", T0) const [version, requestId, expiresAt, mac] = ticket.split(".") expect(version).toBe(TICKET_VERSION) expect(requestId).toBe("req-1") expect(Number(expiresAt)).toBe(T0 + TICKET_LIFETIME_MS) expect(mac).toMatch(/^[0-9a-f]{64}$/) // Extending the expiry, or claiming another version, breaks the MAC. const extended = [version, requestId, String(T0 + 10 * TICKET_LIFETIME_MS), mac].join(".") await expect(requestIdOf("key", "user-a", extended, T0)).rejects.toMatchObject(notFound) const otherVersion = ["v2", requestId, expiresAt, mac].join(".") await expect(requestIdOf("key", "user-a", otherVersion, T0)).rejects.toMatchObject(notFound) }) it("expire after a day, with the same NotFound as a forged ticket", async () => { const ticket = await ticketFor("key", "user-a", "req-1", T0) await expect(requestIdOf("key", "user-a", ticket, T0 + TICKET_LIFETIME_MS - 1)).resolves.toBe("req-1") const expired = await problemOf(requestIdOf("key", "user-a", ticket, T0 + TICKET_LIFETIME_MS)) const forged = await problemOf(requestIdOf("key", "user-a", `${ticket.slice(0, -4)}beef`, T0)) expect(expired).toEqual(forged) expect(expired.status).toBe(404) }) it("are MAC'd under a key derived from the secret, never under the secret itself", async () => { const ticket = await ticketFor("key", "user-a", "req-1", T0) const [version, requestId, expiresAt, mac] = ticket.split(".") as [string, string, string, string] const payload = `${version}|${expiresAt}|${"user-a".length}:user-a|${requestId}` const sign = async (raw: Uint8Array) => { const key = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]) const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload))) return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("") } expect(mac).not.toBe(await sign(new TextEncoder().encode("key"))) const ikm = await crypto.subtle.importKey("raw", new TextEncoder().encode("key"), "HKDF", false, ["deriveBits"]) const derived = await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode(TICKET_KEY_INFO) }, ikm, 256, ) expect(TICKET_KEY_INFO).toBe("conusai-template/account-privacy-ticket/v1") expect(mac).toBe(await sign(new Uint8Array(derived))) }) it("stop verifying when the deployment token rotates; asking again answers the open job with a fresh ticket", async () => { const fake = fakePrivacy() const app = makeApp({ privacy: fake.privacy }) const ada = await app.signUp("ada@example.test") const before = await requestAs(ada, "export") // Rotation (documented in core/analytics.ts): the old ticket is NotFound, nothing breaks. ;(fake.privacy as { ticketKey: string }).ticketKey = "dt_rotated_secret" expect((await problemOf(statusAs(ada, before.ticket))).status).toBe(404) const again = await requestAs(ada, "export") expect(again.created).toBe(false) expect(fake.jobs.size).toBe(1) expect((await statusAs(ada, again.ticket)).status).toBe("pending") }) }) describe("account.analytics.request", () => { it("erases the signed-in user's data by their user id, and a repeated click answers the open job", async () => { const fake = fakePrivacy() const app = makeApp({ privacy: fake.privacy }) const ada = await app.signUp("ada@example.test") const first = await requestAs(ada, "erase") const again = await requestAs(ada, "erase") expect(first).toMatchObject({ kind: "erase", status: "pending", created: true }) expect(again.created).toBe(false) // The same job; each answer carries its own freshly dated ticket for it. const key = fake.privacy.ticketKey const [firstId, againId] = await Promise.all( [first, again].map((view) => requestIdOf(key, ada.actor.userId, view.ticket, Date.now())), ) expect(againId).toBe(firstId) expect(fake.calls.filter((call) => call.op === "erase")).toEqual([ { op: "erase", userId: ada.actor.userId }, { op: "erase", userId: ada.actor.userId }, ]) expect(fake.jobs.size).toBe(1) }) it("follows the job to completed with its per-store counts", async () => { const fake = fakePrivacy() const app = makeApp({ privacy: fake.privacy }) const ada = await app.signUp("ada@example.test") const started = await requestAs(ada, "erase") fake.finish(onlyJob(fake)) const done = await statusAs(ada, started.ticket) expect(done.status).toBe("completed") expect(done.stores.analytics).toEqual({ status: "completed", deleted: 2, anonymized: 40, exported: 0 }) expect(done.stores.logs?.status).toBe("not_keyed") }) it("is refused to an API credential acting as the person", async () => { const fake = fakePrivacy() const app = makeApp({ privacy: fake.privacy }) const ada = await app.signUp("ada@example.test") const problem = await problemOf( ada.call({ interactive: false }).callByName("account.analytics.request", { kind: "erase" }), ) expect(problem.status).toBe(403) expect(fake.calls).toEqual([]) }) it("answers unavailable when the deployment has no privacy service (the fake platform)", async () => { const app = makeApp() const ada = await app.signUp("ada@example.test") const problem = await problemOf(requestAs(ada, "export")) expect(problem.status).toBe(503) }) }) describe("account.analytics.export", () => { it("is a conflict until the export completed, then the document", async () => { const fake = fakePrivacy() const app = makeApp({ privacy: fake.privacy }) const ada = await app.signUp("ada@example.test") const started = await requestAs(ada, "export") expect((await problemOf(exportAs(ada, started.ticket))).status).toBe(409) fake.finish(onlyJob(fake)) const downloaded = await exportAs(ada, started.ticket) expect(downloaded.document).toMatchObject({ stores: { analytics: { events: [] } } }) }) it("never reaches another person's export: their ticket is NotFound and no platform call is made", async () => { const fake = fakePrivacy() const app = makeApp({ privacy: fake.privacy }) const ada = await app.signUp("ada@example.test") const bob = await app.signUp("bob@example.test") const started = await requestAs(ada, "export") fake.finish(onlyJob(fake)) const before = fake.calls.length expect((await problemOf(exportAs(bob, started.ticket))).status).toBe(404) expect((await problemOf(statusAs(bob, started.ticket))).status).toBe(404) expect(fake.calls.length).toBe(before) }) })