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()
})
})