ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+748
View File
@@ -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()
})
})
+248
View File
@@ -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)
})
})
+13
View File
@@ -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",
}
+223
View File
@@ -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
}
}
+58
View File
@@ -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")
})
})
+49
View File
@@ -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/)
})
})