This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user