// 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[0]) => accountDataOf(registry) .filter((contribution) => contribution.erase) .map((contribution) => contribution.key) async function orgOf(person: Person, slug: string): Promise { 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 const statusOf = (person: Person) => person.call().callByName("account.deletion.status", undefined) as Promise const deleteAs = (person: Person, confirm = person.email) => person.call().callByName("account.deletion.request", { confirm }) as Promise const problemOf = async (run: Promise) => { 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, 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[] = [] 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 } = {} 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[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) => (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() }) })