This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
// The account module end to end over the memory adapter: the export composes every installed
|
||||
// module's share and names a failed one; the deletion keeps the erasure reference before anything is
|
||||
// removed, revokes sessions first, is idempotent and resumable, and survives a module's removal.
|
||||
|
||||
import { apiKeys, sessions } from "@conusai-kit/auth"
|
||||
import { AppError, buildRegistry } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, entitiesOf } from "@conusai-kit/data"
|
||||
import {
|
||||
accountDataOf,
|
||||
appUserIdentities,
|
||||
findAppUser,
|
||||
organizations,
|
||||
orgMemberships,
|
||||
requireAppUser,
|
||||
users,
|
||||
} from "@conusai-kit/tenancy"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { AccountExport, DeletionStatus, DeletionView } from "../index.js"
|
||||
import {
|
||||
ACCOUNT_IDENTITY_PROVIDER,
|
||||
accountDeletions,
|
||||
RECENT_SIGN_IN_SECONDS,
|
||||
recordProviderAccountEvent,
|
||||
} from "../index.js"
|
||||
import {
|
||||
failingSessions,
|
||||
flakyModule,
|
||||
handleHashOf,
|
||||
ISSUER,
|
||||
makeApp,
|
||||
modulesWithout,
|
||||
newFlaky,
|
||||
type Person,
|
||||
} from "./harness.js"
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const withFlaky = (flaky = newFlaky()) => ({ flaky, app: makeApp({ modules: [...appModules, flakyModule(flaky)] }) })
|
||||
|
||||
/** The modules of this app that erase something besides its own rows, as the registry has them. */
|
||||
const erasing = (registry: Parameters<typeof accountDataOf>[0]) =>
|
||||
accountDataOf(registry)
|
||||
.filter((contribution) => contribution.erase)
|
||||
.map((contribution) => contribution.key)
|
||||
|
||||
async function orgOf(person: Person, slug: string): Promise<string> {
|
||||
const created = (await person.call().callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
|
||||
return created.orgId
|
||||
}
|
||||
|
||||
const exportOf = (person: Person) => person.call().callByName("account.data.export", {}) as Promise<AccountExport>
|
||||
const statusOf = (person: Person) =>
|
||||
person.call().callByName("account.deletion.status", undefined) as Promise<DeletionStatus>
|
||||
const deleteAs = (person: Person, confirm = person.email) =>
|
||||
person.call().callByName("account.deletion.request", { confirm }) as Promise<DeletionView>
|
||||
|
||||
const problemOf = async (run: Promise<unknown>) => {
|
||||
const error = await run.then(
|
||||
() => undefined,
|
||||
(thrown: unknown) => thrown,
|
||||
)
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
return (error as AppError).problem
|
||||
}
|
||||
|
||||
describe("account.data.export", () => {
|
||||
it("is one section per installed module's share, this app's own rows among them, and complete", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await orgOf(ada, "acme")
|
||||
const exported = await exportOf(ada)
|
||||
|
||||
expect(Object.keys(exported.sections).sort()).toEqual(
|
||||
accountDataOf(app.registry)
|
||||
.map((c) => c.key)
|
||||
.sort(),
|
||||
)
|
||||
expect(exported).toMatchObject({
|
||||
format: "conusai.account-export.v1",
|
||||
subject: ada.actor.userId,
|
||||
complete: true,
|
||||
failed: [],
|
||||
})
|
||||
expect(exported.sections.account).toMatchObject({
|
||||
status: "ok",
|
||||
data: {
|
||||
profile: { userId: ada.actor.userId, email: "ada@example.test" },
|
||||
identities: [{ issuer: ISSUER, subject: ada.subject }],
|
||||
organizations: [{ slug: "acme", role: "owner" }],
|
||||
},
|
||||
})
|
||||
expect(exported.sections.flaky).toEqual({ status: "ok", data: { subject: ada.actor.userId, items: 1 } })
|
||||
// No credential of the person is in it: not the session handle's hash, not the sealed tokens.
|
||||
const text = JSON.stringify(exported)
|
||||
expect(text).not.toContain(handleHashOf(ada.email))
|
||||
expect(text).not.toContain("sealed-tokens-of-the-test")
|
||||
})
|
||||
|
||||
// kit:guides:begin
|
||||
it("includes the guides module's export, asked for the signed-in subject only", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.sections.guides).toEqual({
|
||||
status: "ok",
|
||||
data: { end_user: null, contexts: [], progress: [], events: [] },
|
||||
})
|
||||
expect(app.guides.calls).toEqual([{ method: "exportEndUser", args: [{ subject: ada.actor.userId }] }])
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
it("includes the support module's export, asked for the signed-in subject only", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.sections.support).toMatchObject({ status: "ok", data: { processing_purposes: ["support"] } })
|
||||
expect(app.support.calls).toEqual([{ method: "exportContact", subject: { subject: ada.actor.userId } }])
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("reports a module whose export failed, by name, instead of leaving it out", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.exportFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.complete).toBe(false)
|
||||
expect(exported.failed).toEqual(["flaky"])
|
||||
expect(exported.sections.flaky).toEqual({ status: "failed", code: "flaky_unavailable", retryable: true })
|
||||
// The other sections are still there.
|
||||
expect(exported.sections.account?.status).toBe("ok")
|
||||
})
|
||||
|
||||
it("is the person's own: an API credential acting as them is refused", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const problem = await problemOf(ada.call({ interactive: false }).callByName("account.data.export", {}))
|
||||
expect(problem.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the modules' shares of an account are server-internal", () => {
|
||||
// The account procedures refuse an API credential (above); the modules' own export and erasure are
|
||||
// reachable only through the account flow (tenancy.account), never as procedures a bearer token,
|
||||
// an API key or even the person's browser could call by name.
|
||||
const hooks: string[] = []
|
||||
hooks.push("guides.account.export", "guides.account.erase") // kit:guides
|
||||
hooks.push("support.account.export", "support.account.erase") // kit:support
|
||||
|
||||
it("answers no procedure of that name to a bearer or API credential, or to the person", async () => {
|
||||
const app = makeApp()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
for (const name of hooks) {
|
||||
expect(app.registry.procedures.has(name), name).toBe(false)
|
||||
for (const credential of [{ interactive: false }, { interactive: true, sessionId: ada.sessionId }]) {
|
||||
const problem = await problemOf(ada.call(credential).callByName(name, undefined))
|
||||
expect(problem.status, `${name} as ${credential.interactive ? "the person" : "a bearer"}`).toBe(404)
|
||||
}
|
||||
}
|
||||
expect(app.guides.calls).toEqual([]) // kit:guides
|
||||
expect(app.support.calls).toEqual([]) // kit:support
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.status", () => {
|
||||
it("names the modules a deletion erases, a recent sign-in, and the organizations it affects", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
await orgOf(ada, "solo")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
const status = await statusOf(ada)
|
||||
expect(status).toMatchObject({
|
||||
deletion: null,
|
||||
interactive: true,
|
||||
recentSignIn: true,
|
||||
recentSignInSeconds: RECENT_SIGN_IN_SECONDS,
|
||||
lastOwnerOf: [{ slug: "shared" }],
|
||||
onlyMemberOf: [{ slug: "solo" }],
|
||||
modules: erasing(app.registry),
|
||||
})
|
||||
expect(status.modules).toContain("flaky")
|
||||
expect(status.modules).not.toContain("account")
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: what it refuses", () => {
|
||||
const untouched = async (app: ReturnType<typeof makeApp>, person: Person) => {
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toEqual([])
|
||||
expect((await app.data.global(sessions).get(person.sessionId))?.revokedAt).toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: person.subject })).not.toBeNull()
|
||||
}
|
||||
|
||||
it("an address that is not the account's", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await problemOf(deleteAs(ada, "grace@example.test"))).code).toBe("confirmation_mismatch")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("a sign-in older than the recent-sign-in window, until the person signs in again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
vi.setSystemTime(Date.now() + (RECENT_SIGN_IN_SECONDS + 60) * 1000)
|
||||
expect((await statusOf(ada)).recentSignIn).toBe(false)
|
||||
expect((await problemOf(deleteAs(ada))).code).toBe("reauthentication_required")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("a credential with no person behind it, and a session of somebody else", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const asKey = ada.call({ interactive: false }).callByName("account.deletion.request", { confirm: ada.email })
|
||||
expect((await problemOf(asKey)).status).toBe(403)
|
||||
const borrowed = ada
|
||||
.call({ interactive: true, sessionId: grace.sessionId })
|
||||
.callByName("account.deletion.request", { confirm: ada.email })
|
||||
expect((await problemOf(borrowed)).code).toBe("reauthentication_required")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("the last owner of an organization others belong to", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
const problem = await problemOf(deleteAs(ada))
|
||||
expect(problem).toMatchObject({ status: 409, code: "last_owner" })
|
||||
expect(problem.detail).toContain("shared")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: the order", () => {
|
||||
it("keeps the erasure reference and revokes every session before any module erases, and the user row until all did", async () => {
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const orgId = await orgOf(ada, "solo")
|
||||
const key = (await ada.call().callByName("auth.apiKey.create", { org: "solo", name: "ci" })) as {
|
||||
key: { id: string }
|
||||
}
|
||||
// A second device of the same person.
|
||||
const first = await app.data.global(sessions).require(ada.sessionId)
|
||||
const second = await app.data.global(sessions).insert({
|
||||
userId: first.userId,
|
||||
issuer: first.issuer,
|
||||
subject: first.subject,
|
||||
handleHash: handleHashOf("second-device"),
|
||||
tokens: first.tokens,
|
||||
accessTokenExpiresAt: first.accessTokenExpiresAt,
|
||||
lastSeenAt: first.lastSeenAt,
|
||||
absoluteExpiresAt: first.absoluteExpiresAt,
|
||||
revokedAt: null,
|
||||
})
|
||||
|
||||
const seen: Record<string, unknown>[] = []
|
||||
flaky.onErase = async (subject) => {
|
||||
const ledger = (await app.data.global(accountDeletions).list({})).items
|
||||
seen.push({
|
||||
subject,
|
||||
ledger: ledger.map((row) => ({ userId: row.userId, state: row.state, pending: row.pending })),
|
||||
sessionsRevoked: (await app.data.global(sessions).list({})).items.every((row) => row.revokedAt !== null),
|
||||
keyRevoked:
|
||||
(await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(apiKeys).get(key.key.id))
|
||||
?.revokedAt !== null,
|
||||
mapping: (await app.data.global(appUserIdentities).list({})).items.length,
|
||||
signIn: await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject }),
|
||||
userRow: (await app.data.global(users).get(ada.actor.userId))?.email ?? null,
|
||||
member: (await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(orgMemberships).count()) === 1,
|
||||
})
|
||||
}
|
||||
|
||||
const view = await deleteAs(ada)
|
||||
expect(view).toMatchObject({ state: "completed", pending: [], abandoned: [], erased: erasing(app.registry) })
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
subject: ada.actor.userId,
|
||||
ledger: [{ userId: ada.actor.userId, state: "erasing", pending: expect.arrayContaining(["flaky"]) }],
|
||||
sessionsRevoked: true,
|
||||
keyRevoked: true,
|
||||
mapping: 0,
|
||||
signIn: null,
|
||||
// The row is still there, under the same id, holding no address of the person's.
|
||||
userRow: `${ada.actor.userId}@deleted.invalid`,
|
||||
member: true,
|
||||
},
|
||||
])
|
||||
expect(second.id).not.toBe(ada.sessionId)
|
||||
|
||||
// Afterwards: the user row, the memberships and the session rows are gone; the organization that
|
||||
// had no other member is suspended; the ledger row stays, with the opaque id and module names only.
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
expect(await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(orgMemberships).count()).toBe(0)
|
||||
expect((await app.data.global(organizations).get(orgId))?.status).toBe("suspended")
|
||||
expect((await app.data.global(sessions).list({})).items).toEqual([])
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row).toMatchObject({ userId: ada.actor.userId, state: "completed", lastFailure: null })
|
||||
expect(JSON.stringify(row)).not.toContain("ada@example.test")
|
||||
})
|
||||
|
||||
// kit:guides:begin
|
||||
it("erases the guides module's data by the subject, while the user row still exists", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(app.guides.calls).toEqual([{ method: "eraseEndUser", args: [{ subject: ada.actor.userId }] }])
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
it("revokes the subject's widget sessions server-side first, then erases the support contact", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(app.support.calls).toEqual([
|
||||
{ method: "revokeWidgetSessions", subject: { subject: ada.actor.userId } },
|
||||
{ method: "eraseContact", subject: { subject: ada.actor.userId } },
|
||||
])
|
||||
})
|
||||
|
||||
it("revokes the widget sessions even while the support erasure is held up, and not again once confirmed", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const client = app.support.client as { eraseContact: unknown }
|
||||
const erase = client.eraseContact
|
||||
client.eraseContact = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
expect((await deleteAs(ada)).pending).toContain("support")
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions"])
|
||||
client.eraseContact = erase
|
||||
await app.sweep()
|
||||
// The ledger recorded the revocation as done: the sweep resumes at the erasure.
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions", "eraseContact"])
|
||||
})
|
||||
|
||||
it("repeats a widget-session revocation the platform refused, on the next resumption, without holding the deletion", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const client = app.support.client as { revokeWidgetSessions: unknown; eraseContact: unknown }
|
||||
const [revoke, erase] = [client.revokeWidgetSessions, client.eraseContact]
|
||||
const down = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
client.revokeWidgetSessions = down
|
||||
client.eraseContact = down
|
||||
const held = await deleteAs(ada)
|
||||
expect(held.pending).toEqual(["support"])
|
||||
client.revokeWidgetSessions = revoke
|
||||
client.eraseContact = erase
|
||||
await app.sweep()
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions", "eraseContact"])
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row?.state).toBe("completed")
|
||||
expect(row?.steps).toContain("service-sessions")
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("lets the same provider identity sign up again as a new, unrelated user", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
const again = await requireAppUser(app.data, {
|
||||
issuer: ISSUER,
|
||||
subject: ada.subject,
|
||||
email: ada.email,
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
expect(again.id).not.toBe(ada.actor.userId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: an unavailable module", () => {
|
||||
it("holds the deletion with the person signed out, and the worker's sweep finishes it once the module is back", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
|
||||
const held = await deleteAs(ada)
|
||||
expect(held).toMatchObject({ state: "erasing", pending: ["flaky"] })
|
||||
expect(held.erased).toEqual(erasing(app.registry).filter((key) => key !== "flaky"))
|
||||
// Signed out everywhere and unreachable by a sign-in, but the subject is kept for the retry.
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).not.toBeNull()
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row).toMatchObject({ attempts: 1, lastFailure: "flaky:flaky_unavailable" })
|
||||
// A first sign-in with the same provider identity now is a new person: the held row's legacy
|
||||
// issuer/subject columns were cleared with the mapping, so the resolver cannot map it back.
|
||||
const stranger = await requireAppUser(app.data, {
|
||||
issuer: ISSUER,
|
||||
subject: ada.subject,
|
||||
email: ada.email,
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
expect(stranger.id).not.toBe(ada.actor.userId)
|
||||
expect((await app.data.global(appUserIdentities).list({})).items.map((alias) => alias.userId)).not.toContain(
|
||||
ada.actor.userId,
|
||||
)
|
||||
|
||||
// The sweep while the module is still down: it retries, fails loudly, and changes nothing else.
|
||||
await expect(app.sweep()).rejects.toMatchObject({ status: 503 })
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({ state: "erasing", attempts: 2 })
|
||||
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({
|
||||
state: "completed",
|
||||
pending: [],
|
||||
erased: expect.arrayContaining(["flaky"]),
|
||||
})
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
|
||||
// Idempotent from here on: another sweep, or the person's own retry, changes nothing.
|
||||
await app.sweep()
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
})
|
||||
|
||||
it("is one deletion however often it is asked for, even at once", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const both = await Promise.all([deleteAs(ada), deleteAs(ada)])
|
||||
expect(both.map((view) => view.state)).toEqual(["erasing", "erasing"])
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toHaveLength(1)
|
||||
// A retry resumes, whatever is typed: the account's address is no longer on the row.
|
||||
flaky.eraseFails = false
|
||||
expect((await deleteAs(ada, "anything")).state).toBe("completed")
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not wait for a module removed from the app after the deletion began, and keeps its name for the operator", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await deleteAs(ada)).pending).toEqual(["flaky"])
|
||||
|
||||
// The same database, the app redeployed without the module.
|
||||
const redeployed = makeApp({ data: app.data })
|
||||
await redeployed.sweep()
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({
|
||||
state: "completed",
|
||||
pending: [],
|
||||
abandoned: ["flaky"],
|
||||
})
|
||||
expect(flaky.erased).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("with an opt-in module removed", () => {
|
||||
for (const removed of ["guides", "support"]) {
|
||||
it(`without ${removed}, neither the export nor the deletion names it`, async () => {
|
||||
const app = makeApp({ modules: modulesWithout(removed) })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(Object.keys(exported.sections)).not.toContain(removed)
|
||||
expect(exported.complete).toBe(true)
|
||||
expect((await statusOf(ada)).modules).not.toContain(removed)
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
})
|
||||
}
|
||||
|
||||
it("with only this app's own rows, the export is the account section and the deletion completes", async () => {
|
||||
const app = makeApp({ modules: modulesWithout("guides", "support") })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect(Object.keys((await exportOf(ada)).sections)).toEqual(["account"])
|
||||
expect(await deleteAs(ada)).toMatchObject({ state: "completed", erased: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("an external provider's session and account events (Better Auth, joint contract §14)", () => {
|
||||
/** A browser session of an external provider: no kit row, the sign-in time the provider reported. */
|
||||
const external = (signedInAt: number) => ({ interactive: true, signedInAt: new Date(signedInAt).toISOString() })
|
||||
|
||||
it("judges a recent sign-in by the time the provider reported, without a kit session row", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const stale = ada.call(external(Date.now() - (RECENT_SIGN_IN_SECONDS + 60) * 1000))
|
||||
expect(((await stale.callByName("account.deletion.status", undefined)) as DeletionStatus).recentSignIn).toBe(false)
|
||||
expect((await problemOf(stale.callByName("account.deletion.request", { confirm: ada.email }))).code).toBe(
|
||||
"reauthentication_required",
|
||||
)
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
const fresh = ada.call(external(Date.now() - 1000))
|
||||
expect(((await fresh.callByName("account.deletion.request", { confirm: ada.email })) as DeletionView).state).toBe(
|
||||
"completed",
|
||||
)
|
||||
})
|
||||
|
||||
it("ends the account at the provider, found by the identity mapping, before the identity is detached", async () => {
|
||||
const ended: Array<{ providerSubject: string; mapped: boolean }> = []
|
||||
const flaky = newFlaky()
|
||||
const holder: { app?: ReturnType<typeof makeApp> } = {}
|
||||
const app = makeApp({
|
||||
modules: [...appModules, flakyModule(flaky)],
|
||||
identityProvider: {
|
||||
id: "test-provider",
|
||||
issuer: ISSUER,
|
||||
async endAccount(providerSubject) {
|
||||
const mapped = await findAppUser(holder.app?.data as never, { issuer: ISSUER, subject: providerSubject })
|
||||
ended.push({ providerSubject, mapped: mapped !== null })
|
||||
},
|
||||
},
|
||||
})
|
||||
holder.app = app
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(ended).toEqual([{ providerSubject: ada.subject, mapped: true }])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row?.provider).toBe("test-provider")
|
||||
expect(row?.steps).toEqual(["credentials", "service-sessions", "provider-account", "identity"])
|
||||
})
|
||||
|
||||
it("erases an account the provider deleted, without a confirmation, a recent sign-in or a hand-over, once", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
vi.setSystemTime(Date.now() + (RECENT_SIGN_IN_SECONDS + 60) * 1000)
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
const ledger = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(ledger.map((row) => [row.userId, row.state])).toEqual([[ada.actor.userId, "completed"]])
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
// The other person's account is untouched.
|
||||
expect((await app.data.global(sessions).get(grace.sessionId))?.revokedAt).toBeNull()
|
||||
})
|
||||
|
||||
it("leaves a failed module to the sweep, as a deletion the person asked for", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.pending).toContain("flaky")
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("holds the deletion, rather than detach the identity, when no identity maps under the provider", async () => {
|
||||
const ended: string[] = []
|
||||
const app = makeApp({
|
||||
identityProvider: {
|
||||
id: "test-provider",
|
||||
issuer: "another-namespace",
|
||||
endAccount: async (s) => void ended.push(s),
|
||||
},
|
||||
})
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
// A namespace that differs between processes must not end nothing and go on.
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.lastFailure).toBe("provider-account:not_mapped")
|
||||
expect(ended).toEqual([])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
})
|
||||
|
||||
it("revokes every credential of an account the provider disabled, and erases nothing", async () => {
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "user-disabled", userId: ada.actor.userId })
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(flaky.erased).toEqual([])
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toEqual([])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
// kit:support:begin
|
||||
expect(app.support.calls).toEqual([{ method: "revokeWidgetSessions", subject: { subject: ada.actor.userId } }])
|
||||
// kit:support:end
|
||||
})
|
||||
})
|
||||
|
||||
describe("the deletion ledger records each step, so a resumption runs exactly the ones left", () => {
|
||||
/** A provider whose `endAccount` records the subjects it ended and can be made to fail. */
|
||||
const provider = () => {
|
||||
const state = { ended: [] as string[], fails: false }
|
||||
return {
|
||||
state,
|
||||
provider: {
|
||||
id: "convex-better-auth",
|
||||
issuer: ISSUER,
|
||||
async endAccount(providerSubject: string) {
|
||||
if (state.fails) throw new TypeError("fetch failed")
|
||||
state.ended.push(providerSubject)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const failingApp = (options: Parameters<typeof makeApp>[0] = {}) => {
|
||||
const modules = [...appModules]
|
||||
const memory = createMemoryDataSource({ entities: entitiesOf(buildRegistry(modules), "postgres") })
|
||||
const { data, failing } = failingSessions(memory)
|
||||
return { app: makeApp({ ...options, modules, data }), failing }
|
||||
}
|
||||
const ledgerOf = async (app: ReturnType<typeof makeApp>) => (await app.data.global(accountDeletions).list({})).items
|
||||
|
||||
it("ends the provider account on the worker's retry of a deletion that failed before that step", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const { app, failing } = failingApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
failing.sessions = true
|
||||
await expect(deleteAs(ada)).rejects.toThrow()
|
||||
const [held] = await ledgerOf(app)
|
||||
expect([held?.state, held?.provider, held?.steps ?? []]).toEqual(["erasing", "convex-better-auth", []])
|
||||
expect(state.ended).toEqual([])
|
||||
failing.sessions = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
const [done] = await ledgerOf(app)
|
||||
expect(done?.state).toBe("completed")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
})
|
||||
|
||||
it("waits at the provider step in a process with no provider bound, rather than detach the identity", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const { app, failing } = failingApp({ requestIdentityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
failing.sessions = true
|
||||
await expect(deleteAs(ada)).rejects.toThrow()
|
||||
failing.sessions = false
|
||||
// The sweep runs in the worker's container, which binds no provider in this app.
|
||||
await expect(app.sweep()).rejects.toThrow(/wait/)
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.state).toBe("erasing")
|
||||
expect(held?.lastFailure).toBe("provider-account:provider_unavailable")
|
||||
expect(held?.steps).toEqual(["credentials", "service-sessions"])
|
||||
// The identity the account is found by is still mapped, and the account is not ended yet.
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
expect(state.ended).toEqual([])
|
||||
// The person's retry, from a process that binds the provider, finishes it.
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
})
|
||||
|
||||
it("holds at the provider step while the provider is down, and resumes there", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const app = makeApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
state.fails = true
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.lastFailure).toBe("provider-account:unavailable")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
state.fails = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("does not end the provider account again when a later step held the deletion up", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const app = makeApp({ modules: [...appModules, flakyModule(flaky)], identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await deleteAs(ada)).pending).toEqual(["flaky"])
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
})
|
||||
|
||||
it("ends the account for a ledger row written before steps and the provider were recorded", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const app = makeApp({ modules: [...appModules, flakyModule(flaky)], identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
// The row as the previous ledger wrote it: no steps, no provider, a module held up.
|
||||
await app.data.global(accountDeletions).insert({
|
||||
userId: ada.actor.userId,
|
||||
state: "erasing",
|
||||
pending: erasing(app.registry),
|
||||
erased: [],
|
||||
abandoned: [],
|
||||
attempts: 1,
|
||||
lastFailure: "flaky:flaky_unavailable",
|
||||
requestedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
steps: null,
|
||||
provider: null,
|
||||
})
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("ends every account the person has under the provider's namespace", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const app = makeApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await app.data.global(appUserIdentities).insert({
|
||||
userId: ada.actor.userId,
|
||||
issuer: ISSUER,
|
||||
subject: "second-account-of-ada",
|
||||
migrationId: null,
|
||||
})
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
expect([...state.ended].sort()).toEqual([ada.subject, "second-account-of-ada"].sort())
|
||||
})
|
||||
|
||||
it("holds a deletion whose provider cannot be built, and exports all the same", async () => {
|
||||
const app = makeApp()
|
||||
app.root.register(ACCOUNT_IDENTITY_PROVIDER, () => {
|
||||
throw new Error("BETTER_AUTH_CONVEX_ADMIN_KEY is not set")
|
||||
})
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await exportOf(ada)).sections.account?.status).toBe("ok")
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.lastFailure).toBe("provider-account:provider_unavailable")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// Why the account module's procedures need no tenant-isolation fixture, for the generated suite's
|
||||
// `exempt` list (test/isolation.test.ts): all three are user-scoped and take no organization and no id.
|
||||
|
||||
export const accountIsolationExempt: Readonly<Record<string, string>> = {
|
||||
"account.data.export": "it takes no organization and no id: the only account it exports is the signed-in user's",
|
||||
"account.deletion.status": "it takes no organization and no id: it answers only about the signed-in user's account",
|
||||
"account.deletion.request": "it takes no organization and no id: the only account it deletes is the signed-in user's",
|
||||
"account.analytics.request": "it takes no organization and no id: the only subject it names is the signed-in user",
|
||||
"account.analytics.status":
|
||||
"it takes no organization; its ticket is signed for the signed-in user, and another's answers NotFound",
|
||||
"account.analytics.export":
|
||||
"it takes no organization; its ticket is signed for the signed-in user, and another's answers NotFound",
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// The app wired the way apps/web and apps/worker wire the account module -- the registry from the
|
||||
// manifests, a memory data source, the tenancy hooks, the account ports and the opt-in modules'
|
||||
// recording platforms -- plus `flaky`, a module whose share of an account can be made to fail, so
|
||||
// the partial-failure and resume paths are proven whichever opt-in modules this app kept.
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { sessions } from "@conusai-kit/auth"
|
||||
import {
|
||||
type Actor,
|
||||
AppError,
|
||||
buildRegistry,
|
||||
Container,
|
||||
contribute,
|
||||
createCaller,
|
||||
defineModule,
|
||||
type ModuleManifest,
|
||||
type Registry,
|
||||
type TransportCaller,
|
||||
} from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA, toInstant } from "@conusai-kit/data"
|
||||
import { GUIDES_PLATFORM, GUIDES_REGISTRY } from "@conusai-kit/guides" // kit:guides
|
||||
import { recordingGuidesPlatform } from "@conusai-kit/guides/testing" // kit:guides
|
||||
import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support
|
||||
import { recordingSupportPlatform } from "@conusai-kit/support/testing" // kit:support
|
||||
import { accountData, createTenancyHooks, requireAppUser, TENANCY } from "@conusai-kit/tenancy"
|
||||
import { appModules } from "../../index.js"
|
||||
import {
|
||||
ACCOUNT_CREDENTIAL,
|
||||
ACCOUNT_IDENTITY_PROVIDER,
|
||||
ACCOUNT_PRIVACY,
|
||||
ACCOUNT_REGISTRY,
|
||||
type AccountCredential,
|
||||
type AccountIdentityProvider,
|
||||
type AccountPrivacy,
|
||||
} from "../index.js"
|
||||
|
||||
export const ISSUER = "https://id.example.test"
|
||||
|
||||
/** A stand-in for the hash of a person's session handle, distinct per person as the real one is. */
|
||||
export const handleHashOf = (email: string): string => createHash("sha256").update(email).digest("hex")
|
||||
|
||||
/**
|
||||
* The app's modules without `removed`, as `kit template:prune --without` leaves them: the module, and
|
||||
* every other module's contribution to an extension point the removed module declared.
|
||||
*/
|
||||
export function modulesWithout(...removed: string[]): ModuleManifest[] {
|
||||
return appModules
|
||||
.filter((module) => !removed.includes(module.id))
|
||||
.map((module) => ({
|
||||
...module,
|
||||
extends: (module.extends ?? []).filter(
|
||||
(contribution) => !removed.includes(contribution.point.id.split(".")[0] ?? ""),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
/** What `flaky` does when asked, and what it saw. */
|
||||
export interface FlakyState {
|
||||
exportFails: boolean
|
||||
eraseFails: boolean
|
||||
readonly exported: string[]
|
||||
readonly erased: string[]
|
||||
/** Called inside each erasure, before it answers: a test looks at the stored rows at that moment. */
|
||||
onErase?: (subject: string) => Promise<void>
|
||||
}
|
||||
|
||||
const unavailable = () =>
|
||||
new AppError({ type: "/problems/flaky_unavailable", title: "Unavailable", status: 503, code: "flaky_unavailable" })
|
||||
|
||||
export function flakyModule(state: FlakyState): ModuleManifest {
|
||||
return defineModule({
|
||||
id: "flaky",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
extends: [
|
||||
contribute(accountData, [
|
||||
{
|
||||
key: "flaky",
|
||||
async export({ subject }) {
|
||||
if (state.exportFails) throw unavailable()
|
||||
state.exported.push(subject)
|
||||
return { subject, items: 1 }
|
||||
},
|
||||
async erase({ subject }) {
|
||||
await state.onErase?.(subject)
|
||||
if (state.eraseFails) throw unavailable()
|
||||
state.erased.push(subject)
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory data source, with the session table's listing made to fail while `failing.sessions` is
|
||||
* set: the first step of a deletion (revoking the person's live sessions) then throws, as a database
|
||||
* outage would, after the ledger row was written.
|
||||
*/
|
||||
export function failingSessions(data: MemoryData): { data: MemoryData; failing: { sessions: boolean } } {
|
||||
const failing = { sessions: false }
|
||||
const outage = () => {
|
||||
throw new AppError({ type: "/problems/unavailable", title: "Unavailable", status: 503, code: "unavailable" })
|
||||
}
|
||||
const wrapped = new Proxy(data, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver)
|
||||
if (property !== "global") return typeof value === "function" ? value.bind(target) : value
|
||||
return (entity: Parameters<MemoryData["global"]>[0]) => {
|
||||
const repository = target.global(entity)
|
||||
if ((entity as unknown) !== sessions) return repository
|
||||
return new Proxy(repository, {
|
||||
get(repo, name, r) {
|
||||
const method = Reflect.get(repo, name, r)
|
||||
if (typeof method !== "function") return method
|
||||
return (...args: unknown[]) => (failing.sessions && name === "list" ? outage() : method.apply(repo, args))
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
return { data: wrapped, failing }
|
||||
}
|
||||
|
||||
export const newFlaky = (): FlakyState => ({ exportFails: false, eraseFails: false, exported: [], erased: [] })
|
||||
|
||||
export type MemoryData = ReturnType<typeof createMemoryDataSource>
|
||||
|
||||
export interface Person {
|
||||
readonly actor: Actor & { readonly userId: string }
|
||||
readonly email: string
|
||||
readonly subject: string
|
||||
readonly sessionId: string
|
||||
/** The person's caller, as the web binds it for a browser session (or the credential given). */
|
||||
call(credential?: AccountCredential): TransportCaller
|
||||
}
|
||||
|
||||
export function makeApp(
|
||||
options: {
|
||||
modules?: readonly ModuleManifest[]
|
||||
data?: MemoryData
|
||||
/** Bound in the process container: every request and the worker's sweep have it. */
|
||||
identityProvider?: AccountIdentityProvider
|
||||
/** Bound in each request's container only: a sweep in this app runs without it. */
|
||||
requestIdentityProvider?: AccountIdentityProvider
|
||||
/** The platform's privacy service (ADR-049), bound in the process container. */
|
||||
privacy?: AccountPrivacy
|
||||
} = {},
|
||||
) {
|
||||
const registry: Registry = buildRegistry([...(options.modules ?? appModules)])
|
||||
const data = options.data ?? createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(ACCOUNT_REGISTRY, registry)
|
||||
if (options.identityProvider) root.value(ACCOUNT_IDENTITY_PROVIDER, options.identityProvider)
|
||||
if (options.privacy) root.value(ACCOUNT_PRIVACY, options.privacy)
|
||||
const guides = recordingGuidesPlatform() // kit:guides
|
||||
root.value(GUIDES_PLATFORM, guides) // kit:guides
|
||||
root.value(GUIDES_REGISTRY, registry) // kit:guides
|
||||
const support = recordingSupportPlatform() // kit:support
|
||||
root.value(SUPPORT_PLATFORM, support) // kit:support
|
||||
|
||||
const callerFor = (actor: Actor, credential: AccountCredential): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
container.value(ACCOUNT_CREDENTIAL, credential)
|
||||
if (options.requestIdentityProvider) container.value(ACCOUNT_IDENTITY_PROVIDER, options.requestIdentityProvider)
|
||||
return createCaller({ registry, container, tenancy }, { actor })
|
||||
}
|
||||
|
||||
/** A first sign-in, as kit-auth's callback makes it: the user, the identity mapping, a session row. */
|
||||
const signUp = async (email: string): Promise<Person> => {
|
||||
const subject = `sub-${email}`
|
||||
const user = await requireAppUser(data, {
|
||||
issuer: ISSUER,
|
||||
subject,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
const now = Date.now()
|
||||
const session = await data.global(sessions).insert({
|
||||
userId: user.id,
|
||||
issuer: ISSUER,
|
||||
subject,
|
||||
handleHash: handleHashOf(email),
|
||||
tokens: "sealed-tokens-of-the-test",
|
||||
accessTokenExpiresAt: toInstant(now + 3600_000),
|
||||
lastSeenAt: toInstant(now),
|
||||
absoluteExpiresAt: toInstant(now + 14 * 86400_000),
|
||||
revokedAt: null,
|
||||
})
|
||||
const actor = { userId: user.id }
|
||||
const browser: AccountCredential = { interactive: true, sessionId: session.id }
|
||||
return {
|
||||
actor,
|
||||
email,
|
||||
subject,
|
||||
sessionId: session.id,
|
||||
call: (credential = browser) => callerFor(actor, credential),
|
||||
}
|
||||
}
|
||||
|
||||
/** The worker's run of the sweep job: a scopeless job, in a container with no credential. */
|
||||
const sweep = async (): Promise<void> => {
|
||||
const job = registry.jobs["account.deletion.sweep"]
|
||||
if (!job) throw new Error("the account module declares no sweep job")
|
||||
await job.def.handler(null, { container: root.child(), correlationId: "test-sweep", attempt: 1 })
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
data,
|
||||
root,
|
||||
signUp,
|
||||
sweep,
|
||||
guides, // kit:guides
|
||||
support, // kit:support
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Every string the account page shows is a message in English and in Lithuanian, the label builders
|
||||
// ask for no key either language lacks, and no message is left that nothing asks for.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { analyticsLabels, deletionLabels, exportLabels, pageLabels } from "../ui/labels.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const en = catalogues.en?.account ?? {}
|
||||
const lt = catalogues.lt?.account ?? {}
|
||||
|
||||
/** The keys the builders ask for, recorded by running them with a lookup that notes each one. */
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
pageLabels(t)
|
||||
exportLabels(t, "failed")
|
||||
analyticsLabels(t, "failed")
|
||||
deletionLabels(
|
||||
t,
|
||||
"failed",
|
||||
{ modules: ["guides"], lastOwnerOf: [{ slug: "a", name: "A" }], onlyMemberOf: [{ slug: "b", name: "B" }] },
|
||||
"ada@example.test",
|
||||
)
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the account catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian, none of them empty", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the labels ask for, and none that nothing asks for", () => {
|
||||
const asked = new Set(keysUsed())
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing account.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing account.${key}`).toBeTruthy()
|
||||
}
|
||||
expect(Object.keys(en).filter((key) => !asked.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("keep the placeholders the components fill in themselves", () => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => {
|
||||
let text = String(en[key as keyof typeof en])
|
||||
for (const [name, value] of Object.entries(values ?? {})) text = text.replace(`{${name}}`, String(value))
|
||||
return text
|
||||
}
|
||||
expect(exportLabels(t, "x").partial).toContain("{sections}")
|
||||
expect(deletionLabels(t, "x", { modules: [], lastOwnerOf: [], onlyMemberOf: [] }, "a@b.test").erasing).toContain(
|
||||
"{modules}",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// The module's migrations are its own files, each exactly `schemaStatements()` over the entities it
|
||||
// added (decision AT-D20: the entity definition is the one source of a column). Forward-only: the
|
||||
// first file stays the ledger's as first shipped, the revocation outbox is the second, and the
|
||||
// ledger's step record (`steps`, `provider`) is the third, two added columns. All global tables: an
|
||||
// account and an app session belong to no organization, so they carry no tenant column and no policy.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { ACCOUNT_ENTITIES, accountDeletions, accountModule, accountSessionRevocations } from "../index.js"
|
||||
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
describe("the migration of the account module", () => {
|
||||
const migration = (name: string) => readFileSync(new URL(`../migrations/${name}`, import.meta.url), "utf8")
|
||||
|
||||
// A migration that has run is never edited: the columns added since are a file of their own, and
|
||||
// the files together are exactly the entity.
|
||||
it("is its own file, the ledger table as first shipped, and a third file adds the step record", () => {
|
||||
const added = ',\n "steps" jsonb,\n "provider" text\n'
|
||||
const generated = tablesOf([accountDeletions])
|
||||
expect(generated).toContain(added)
|
||||
expect(generated.replace(added, "\n")).toBe(migration("0001_account.sql"))
|
||||
expect(migration("0003_deletion_steps.sql")).toBe(
|
||||
'alter table "account_deletion" add column "steps" jsonb;\n\nalter table "account_deletion" add column "provider" text;\n',
|
||||
)
|
||||
})
|
||||
|
||||
it("adds the revocation outbox in a second file, exactly its table", async () => {
|
||||
await expect(tablesOf([accountSessionRevocations])).toMatchFileSnapshot("../migrations/0002_session_revocation.sql")
|
||||
expect(ACCOUNT_ENTITIES).toEqual([accountDeletions, accountSessionRevocations])
|
||||
const sql = readFileSync(new URL("../migrations/0002_session_revocation.sql", import.meta.url), "utf8")
|
||||
expect(sql).not.toContain("org_id")
|
||||
})
|
||||
|
||||
it("is declared by the manifest for Postgres; on Convex the schema comes from the same entities", () => {
|
||||
expect(accountModule.migrations).toEqual({ postgres: "migrations" })
|
||||
expect(accountModule.profiles).toEqual(["postgres", "convex"])
|
||||
const sql = readFileSync(new URL("../migrations/0001_account.sql", import.meta.url), "utf8")
|
||||
expect(sql).toContain('create table "account_deletion"')
|
||||
expect(sql).not.toContain("org_id")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// The analytics panel's markup, rendered on the server the way the account page renders it, in both
|
||||
// languages: both actions are named buttons, the irreversible one is marked, and no confirmation,
|
||||
// status or table shows before the person asked for anything.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { TransportProvider } from "@conusai-kit/ui"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { AccountAnalyticsPanel, analyticsLabels } from "../ui/index.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
|
||||
const render = (locale: "en" | "lt") => {
|
||||
const i18n = createI18n(locale, { [locale]: { account: catalogues[locale]?.account ?? {} } })
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`account.${key}`, values)
|
||||
const labels = analyticsLabels(t, "That did not work: {detail}")
|
||||
const html = renderToStaticMarkup(
|
||||
<TransportProvider call={async () => undefined} describe={() => "x"}>
|
||||
<AccountAnalyticsPanel labels={labels} />
|
||||
</TransportProvider>,
|
||||
)
|
||||
return { html, labels }
|
||||
}
|
||||
|
||||
describe("the analytics panel", () => {
|
||||
it.each(["en", "lt"] as const)("offers the download and the erasure as named buttons (%s)", (locale) => {
|
||||
const { html, labels } = render(locale)
|
||||
expect(html).toContain(`<h2 id=`)
|
||||
expect(html).toContain(labels.heading)
|
||||
expect(html).toContain(labels.exportSubmit)
|
||||
expect(html).toContain(labels.erase)
|
||||
expect(html).toMatch(/<button[^>]*class="app-danger"[^>]*>/)
|
||||
})
|
||||
|
||||
it("asks for nothing yet: no confirmation, no status and no table before a click", () => {
|
||||
const { html, labels } = render("en")
|
||||
expect(html).not.toContain(labels.confirmErase)
|
||||
expect(html).not.toContain('role="alertdialog"')
|
||||
expect(html).not.toContain('role="status"')
|
||||
expect(html).not.toContain("kit-table")
|
||||
})
|
||||
|
||||
it("says the erasure cannot be undone in its confirmation, in both languages", () => {
|
||||
expect(render("en").labels.confirmErase).toMatch(/cannot be undone/)
|
||||
expect(render("lt").labels.confirmErase).toMatch(/atšaukti negalima/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user