Files

249 lines
11 KiB
TypeScript

// 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<string, AccountPrivacyRequest & { userId: string }>()
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<AnalyticsView>
const statusAs = (person: Person, ticket: string) =>
person.call().callByName("account.analytics.status", { ticket }) as Promise<AnalyticsView>
const exportAs = (person: Person, ticket: string) =>
person.call().callByName("account.analytics.export", { ticket }) as Promise<{ document: Record<string, unknown> }>
const problemOf = async (run: Promise<unknown>) => {
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, unknown> }): 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 `<id>.<mac>` 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<ArrayBuffer>) => {
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)
})
})