This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
// The account module end to end over the memory adapter: the export composes every installed
|
||||
// module's share and names a failed one; the deletion keeps the erasure reference before anything is
|
||||
// removed, revokes sessions first, is idempotent and resumable, and survives a module's removal.
|
||||
|
||||
import { apiKeys, sessions } from "@conusai-kit/auth"
|
||||
import { AppError, buildRegistry } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, entitiesOf } from "@conusai-kit/data"
|
||||
import {
|
||||
accountDataOf,
|
||||
appUserIdentities,
|
||||
findAppUser,
|
||||
organizations,
|
||||
orgMemberships,
|
||||
requireAppUser,
|
||||
users,
|
||||
} from "@conusai-kit/tenancy"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { AccountExport, DeletionStatus, DeletionView } from "../index.js"
|
||||
import {
|
||||
ACCOUNT_IDENTITY_PROVIDER,
|
||||
accountDeletions,
|
||||
RECENT_SIGN_IN_SECONDS,
|
||||
recordProviderAccountEvent,
|
||||
} from "../index.js"
|
||||
import {
|
||||
failingSessions,
|
||||
flakyModule,
|
||||
handleHashOf,
|
||||
ISSUER,
|
||||
makeApp,
|
||||
modulesWithout,
|
||||
newFlaky,
|
||||
type Person,
|
||||
} from "./harness.js"
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const withFlaky = (flaky = newFlaky()) => ({ flaky, app: makeApp({ modules: [...appModules, flakyModule(flaky)] }) })
|
||||
|
||||
/** The modules of this app that erase something besides its own rows, as the registry has them. */
|
||||
const erasing = (registry: Parameters<typeof accountDataOf>[0]) =>
|
||||
accountDataOf(registry)
|
||||
.filter((contribution) => contribution.erase)
|
||||
.map((contribution) => contribution.key)
|
||||
|
||||
async function orgOf(person: Person, slug: string): Promise<string> {
|
||||
const created = (await person.call().callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
|
||||
return created.orgId
|
||||
}
|
||||
|
||||
const exportOf = (person: Person) => person.call().callByName("account.data.export", {}) as Promise<AccountExport>
|
||||
const statusOf = (person: Person) =>
|
||||
person.call().callByName("account.deletion.status", undefined) as Promise<DeletionStatus>
|
||||
const deleteAs = (person: Person, confirm = person.email) =>
|
||||
person.call().callByName("account.deletion.request", { confirm }) as Promise<DeletionView>
|
||||
|
||||
const problemOf = async (run: Promise<unknown>) => {
|
||||
const error = await run.then(
|
||||
() => undefined,
|
||||
(thrown: unknown) => thrown,
|
||||
)
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
return (error as AppError).problem
|
||||
}
|
||||
|
||||
describe("account.data.export", () => {
|
||||
it("is one section per installed module's share, this app's own rows among them, and complete", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await orgOf(ada, "acme")
|
||||
const exported = await exportOf(ada)
|
||||
|
||||
expect(Object.keys(exported.sections).sort()).toEqual(
|
||||
accountDataOf(app.registry)
|
||||
.map((c) => c.key)
|
||||
.sort(),
|
||||
)
|
||||
expect(exported).toMatchObject({
|
||||
format: "conusai.account-export.v1",
|
||||
subject: ada.actor.userId,
|
||||
complete: true,
|
||||
failed: [],
|
||||
})
|
||||
expect(exported.sections.account).toMatchObject({
|
||||
status: "ok",
|
||||
data: {
|
||||
profile: { userId: ada.actor.userId, email: "ada@example.test" },
|
||||
identities: [{ issuer: ISSUER, subject: ada.subject }],
|
||||
organizations: [{ slug: "acme", role: "owner" }],
|
||||
},
|
||||
})
|
||||
expect(exported.sections.flaky).toEqual({ status: "ok", data: { subject: ada.actor.userId, items: 1 } })
|
||||
// No credential of the person is in it: not the session handle's hash, not the sealed tokens.
|
||||
const text = JSON.stringify(exported)
|
||||
expect(text).not.toContain(handleHashOf(ada.email))
|
||||
expect(text).not.toContain("sealed-tokens-of-the-test")
|
||||
})
|
||||
|
||||
// kit:guides:begin
|
||||
it("includes the guides module's export, asked for the signed-in subject only", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.sections.guides).toEqual({
|
||||
status: "ok",
|
||||
data: { end_user: null, contexts: [], progress: [], events: [] },
|
||||
})
|
||||
expect(app.guides.calls).toEqual([{ method: "exportEndUser", args: [{ subject: ada.actor.userId }] }])
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
it("includes the support module's export, asked for the signed-in subject only", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.sections.support).toMatchObject({ status: "ok", data: { processing_purposes: ["support"] } })
|
||||
expect(app.support.calls).toEqual([{ method: "exportContact", subject: { subject: ada.actor.userId } }])
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("reports a module whose export failed, by name, instead of leaving it out", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.exportFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(exported.complete).toBe(false)
|
||||
expect(exported.failed).toEqual(["flaky"])
|
||||
expect(exported.sections.flaky).toEqual({ status: "failed", code: "flaky_unavailable", retryable: true })
|
||||
// The other sections are still there.
|
||||
expect(exported.sections.account?.status).toBe("ok")
|
||||
})
|
||||
|
||||
it("is the person's own: an API credential acting as them is refused", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const problem = await problemOf(ada.call({ interactive: false }).callByName("account.data.export", {}))
|
||||
expect(problem.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the modules' shares of an account are server-internal", () => {
|
||||
// The account procedures refuse an API credential (above); the modules' own export and erasure are
|
||||
// reachable only through the account flow (tenancy.account), never as procedures a bearer token,
|
||||
// an API key or even the person's browser could call by name.
|
||||
const hooks: string[] = []
|
||||
hooks.push("guides.account.export", "guides.account.erase") // kit:guides
|
||||
hooks.push("support.account.export", "support.account.erase") // kit:support
|
||||
|
||||
it("answers no procedure of that name to a bearer or API credential, or to the person", async () => {
|
||||
const app = makeApp()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
for (const name of hooks) {
|
||||
expect(app.registry.procedures.has(name), name).toBe(false)
|
||||
for (const credential of [{ interactive: false }, { interactive: true, sessionId: ada.sessionId }]) {
|
||||
const problem = await problemOf(ada.call(credential).callByName(name, undefined))
|
||||
expect(problem.status, `${name} as ${credential.interactive ? "the person" : "a bearer"}`).toBe(404)
|
||||
}
|
||||
}
|
||||
expect(app.guides.calls).toEqual([]) // kit:guides
|
||||
expect(app.support.calls).toEqual([]) // kit:support
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.status", () => {
|
||||
it("names the modules a deletion erases, a recent sign-in, and the organizations it affects", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
await orgOf(ada, "solo")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
const status = await statusOf(ada)
|
||||
expect(status).toMatchObject({
|
||||
deletion: null,
|
||||
interactive: true,
|
||||
recentSignIn: true,
|
||||
recentSignInSeconds: RECENT_SIGN_IN_SECONDS,
|
||||
lastOwnerOf: [{ slug: "shared" }],
|
||||
onlyMemberOf: [{ slug: "solo" }],
|
||||
modules: erasing(app.registry),
|
||||
})
|
||||
expect(status.modules).toContain("flaky")
|
||||
expect(status.modules).not.toContain("account")
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: what it refuses", () => {
|
||||
const untouched = async (app: ReturnType<typeof makeApp>, person: Person) => {
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toEqual([])
|
||||
expect((await app.data.global(sessions).get(person.sessionId))?.revokedAt).toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: person.subject })).not.toBeNull()
|
||||
}
|
||||
|
||||
it("an address that is not the account's", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await problemOf(deleteAs(ada, "grace@example.test"))).code).toBe("confirmation_mismatch")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("a sign-in older than the recent-sign-in window, until the person signs in again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
vi.setSystemTime(Date.now() + (RECENT_SIGN_IN_SECONDS + 60) * 1000)
|
||||
expect((await statusOf(ada)).recentSignIn).toBe(false)
|
||||
expect((await problemOf(deleteAs(ada))).code).toBe("reauthentication_required")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("a credential with no person behind it, and a session of somebody else", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const asKey = ada.call({ interactive: false }).callByName("account.deletion.request", { confirm: ada.email })
|
||||
expect((await problemOf(asKey)).status).toBe(403)
|
||||
const borrowed = ada
|
||||
.call({ interactive: true, sessionId: grace.sessionId })
|
||||
.callByName("account.deletion.request", { confirm: ada.email })
|
||||
expect((await problemOf(borrowed)).code).toBe("reauthentication_required")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
|
||||
it("the last owner of an organization others belong to", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
const problem = await problemOf(deleteAs(ada))
|
||||
expect(problem).toMatchObject({ status: 409, code: "last_owner" })
|
||||
expect(problem.detail).toContain("shared")
|
||||
await untouched(app, ada)
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: the order", () => {
|
||||
it("keeps the erasure reference and revokes every session before any module erases, and the user row until all did", async () => {
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const orgId = await orgOf(ada, "solo")
|
||||
const key = (await ada.call().callByName("auth.apiKey.create", { org: "solo", name: "ci" })) as {
|
||||
key: { id: string }
|
||||
}
|
||||
// A second device of the same person.
|
||||
const first = await app.data.global(sessions).require(ada.sessionId)
|
||||
const second = await app.data.global(sessions).insert({
|
||||
userId: first.userId,
|
||||
issuer: first.issuer,
|
||||
subject: first.subject,
|
||||
handleHash: handleHashOf("second-device"),
|
||||
tokens: first.tokens,
|
||||
accessTokenExpiresAt: first.accessTokenExpiresAt,
|
||||
lastSeenAt: first.lastSeenAt,
|
||||
absoluteExpiresAt: first.absoluteExpiresAt,
|
||||
revokedAt: null,
|
||||
})
|
||||
|
||||
const seen: Record<string, unknown>[] = []
|
||||
flaky.onErase = async (subject) => {
|
||||
const ledger = (await app.data.global(accountDeletions).list({})).items
|
||||
seen.push({
|
||||
subject,
|
||||
ledger: ledger.map((row) => ({ userId: row.userId, state: row.state, pending: row.pending })),
|
||||
sessionsRevoked: (await app.data.global(sessions).list({})).items.every((row) => row.revokedAt !== null),
|
||||
keyRevoked:
|
||||
(await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(apiKeys).get(key.key.id))
|
||||
?.revokedAt !== null,
|
||||
mapping: (await app.data.global(appUserIdentities).list({})).items.length,
|
||||
signIn: await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject }),
|
||||
userRow: (await app.data.global(users).get(ada.actor.userId))?.email ?? null,
|
||||
member: (await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(orgMemberships).count()) === 1,
|
||||
})
|
||||
}
|
||||
|
||||
const view = await deleteAs(ada)
|
||||
expect(view).toMatchObject({ state: "completed", pending: [], abandoned: [], erased: erasing(app.registry) })
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
subject: ada.actor.userId,
|
||||
ledger: [{ userId: ada.actor.userId, state: "erasing", pending: expect.arrayContaining(["flaky"]) }],
|
||||
sessionsRevoked: true,
|
||||
keyRevoked: true,
|
||||
mapping: 0,
|
||||
signIn: null,
|
||||
// The row is still there, under the same id, holding no address of the person's.
|
||||
userRow: `${ada.actor.userId}@deleted.invalid`,
|
||||
member: true,
|
||||
},
|
||||
])
|
||||
expect(second.id).not.toBe(ada.sessionId)
|
||||
|
||||
// Afterwards: the user row, the memberships and the session rows are gone; the organization that
|
||||
// had no other member is suspended; the ledger row stays, with the opaque id and module names only.
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
expect(await app.data.forScope({ level: "org", orgId, actor: ada.actor }).of(orgMemberships).count()).toBe(0)
|
||||
expect((await app.data.global(organizations).get(orgId))?.status).toBe("suspended")
|
||||
expect((await app.data.global(sessions).list({})).items).toEqual([])
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row).toMatchObject({ userId: ada.actor.userId, state: "completed", lastFailure: null })
|
||||
expect(JSON.stringify(row)).not.toContain("ada@example.test")
|
||||
})
|
||||
|
||||
// kit:guides:begin
|
||||
it("erases the guides module's data by the subject, while the user row still exists", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(app.guides.calls).toEqual([{ method: "eraseEndUser", args: [{ subject: ada.actor.userId }] }])
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
it("revokes the subject's widget sessions server-side first, then erases the support contact", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(app.support.calls).toEqual([
|
||||
{ method: "revokeWidgetSessions", subject: { subject: ada.actor.userId } },
|
||||
{ method: "eraseContact", subject: { subject: ada.actor.userId } },
|
||||
])
|
||||
})
|
||||
|
||||
it("revokes the widget sessions even while the support erasure is held up, and not again once confirmed", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const client = app.support.client as { eraseContact: unknown }
|
||||
const erase = client.eraseContact
|
||||
client.eraseContact = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
expect((await deleteAs(ada)).pending).toContain("support")
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions"])
|
||||
client.eraseContact = erase
|
||||
await app.sweep()
|
||||
// The ledger recorded the revocation as done: the sweep resumes at the erasure.
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions", "eraseContact"])
|
||||
})
|
||||
|
||||
it("repeats a widget-session revocation the platform refused, on the next resumption, without holding the deletion", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const client = app.support.client as { revokeWidgetSessions: unknown; eraseContact: unknown }
|
||||
const [revoke, erase] = [client.revokeWidgetSessions, client.eraseContact]
|
||||
const down = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
client.revokeWidgetSessions = down
|
||||
client.eraseContact = down
|
||||
const held = await deleteAs(ada)
|
||||
expect(held.pending).toEqual(["support"])
|
||||
client.revokeWidgetSessions = revoke
|
||||
client.eraseContact = erase
|
||||
await app.sweep()
|
||||
expect(app.support.calls.map((call) => call.method)).toEqual(["revokeWidgetSessions", "eraseContact"])
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row?.state).toBe("completed")
|
||||
expect(row?.steps).toContain("service-sessions")
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("lets the same provider identity sign up again as a new, unrelated user", async () => {
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
const again = await requireAppUser(app.data, {
|
||||
issuer: ISSUER,
|
||||
subject: ada.subject,
|
||||
email: ada.email,
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
expect(again.id).not.toBe(ada.actor.userId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("account.deletion.request: an unavailable module", () => {
|
||||
it("holds the deletion with the person signed out, and the worker's sweep finishes it once the module is back", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
|
||||
const held = await deleteAs(ada)
|
||||
expect(held).toMatchObject({ state: "erasing", pending: ["flaky"] })
|
||||
expect(held.erased).toEqual(erasing(app.registry).filter((key) => key !== "flaky"))
|
||||
// Signed out everywhere and unreachable by a sign-in, but the subject is kept for the retry.
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).not.toBeNull()
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row).toMatchObject({ attempts: 1, lastFailure: "flaky:flaky_unavailable" })
|
||||
// A first sign-in with the same provider identity now is a new person: the held row's legacy
|
||||
// issuer/subject columns were cleared with the mapping, so the resolver cannot map it back.
|
||||
const stranger = await requireAppUser(app.data, {
|
||||
issuer: ISSUER,
|
||||
subject: ada.subject,
|
||||
email: ada.email,
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
expect(stranger.id).not.toBe(ada.actor.userId)
|
||||
expect((await app.data.global(appUserIdentities).list({})).items.map((alias) => alias.userId)).not.toContain(
|
||||
ada.actor.userId,
|
||||
)
|
||||
|
||||
// The sweep while the module is still down: it retries, fails loudly, and changes nothing else.
|
||||
await expect(app.sweep()).rejects.toMatchObject({ status: 503 })
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({ state: "erasing", attempts: 2 })
|
||||
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({
|
||||
state: "completed",
|
||||
pending: [],
|
||||
erased: expect.arrayContaining(["flaky"]),
|
||||
})
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
|
||||
// Idempotent from here on: another sweep, or the person's own retry, changes nothing.
|
||||
await app.sweep()
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
})
|
||||
|
||||
it("is one deletion however often it is asked for, even at once", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const both = await Promise.all([deleteAs(ada), deleteAs(ada)])
|
||||
expect(both.map((view) => view.state)).toEqual(["erasing", "erasing"])
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toHaveLength(1)
|
||||
// A retry resumes, whatever is typed: the account's address is no longer on the row.
|
||||
flaky.eraseFails = false
|
||||
expect((await deleteAs(ada, "anything")).state).toBe("completed")
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not wait for a module removed from the app after the deletion began, and keeps its name for the operator", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await deleteAs(ada)).pending).toEqual(["flaky"])
|
||||
|
||||
// The same database, the app redeployed without the module.
|
||||
const redeployed = makeApp({ data: app.data })
|
||||
await redeployed.sweep()
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]).toMatchObject({
|
||||
state: "completed",
|
||||
pending: [],
|
||||
abandoned: ["flaky"],
|
||||
})
|
||||
expect(flaky.erased).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("with an opt-in module removed", () => {
|
||||
for (const removed of ["guides", "support"]) {
|
||||
it(`without ${removed}, neither the export nor the deletion names it`, async () => {
|
||||
const app = makeApp({ modules: modulesWithout(removed) })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const exported = await exportOf(ada)
|
||||
expect(Object.keys(exported.sections)).not.toContain(removed)
|
||||
expect(exported.complete).toBe(true)
|
||||
expect((await statusOf(ada)).modules).not.toContain(removed)
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
})
|
||||
}
|
||||
|
||||
it("with only this app's own rows, the export is the account section and the deletion completes", async () => {
|
||||
const app = makeApp({ modules: modulesWithout("guides", "support") })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect(Object.keys((await exportOf(ada)).sections)).toEqual(["account"])
|
||||
expect(await deleteAs(ada)).toMatchObject({ state: "completed", erased: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("an external provider's session and account events (Better Auth, joint contract §14)", () => {
|
||||
/** A browser session of an external provider: no kit row, the sign-in time the provider reported. */
|
||||
const external = (signedInAt: number) => ({ interactive: true, signedInAt: new Date(signedInAt).toISOString() })
|
||||
|
||||
it("judges a recent sign-in by the time the provider reported, without a kit session row", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const { app } = withFlaky()
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const stale = ada.call(external(Date.now() - (RECENT_SIGN_IN_SECONDS + 60) * 1000))
|
||||
expect(((await stale.callByName("account.deletion.status", undefined)) as DeletionStatus).recentSignIn).toBe(false)
|
||||
expect((await problemOf(stale.callByName("account.deletion.request", { confirm: ada.email }))).code).toBe(
|
||||
"reauthentication_required",
|
||||
)
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
const fresh = ada.call(external(Date.now() - 1000))
|
||||
expect(((await fresh.callByName("account.deletion.request", { confirm: ada.email })) as DeletionView).state).toBe(
|
||||
"completed",
|
||||
)
|
||||
})
|
||||
|
||||
it("ends the account at the provider, found by the identity mapping, before the identity is detached", async () => {
|
||||
const ended: Array<{ providerSubject: string; mapped: boolean }> = []
|
||||
const flaky = newFlaky()
|
||||
const holder: { app?: ReturnType<typeof makeApp> } = {}
|
||||
const app = makeApp({
|
||||
modules: [...appModules, flakyModule(flaky)],
|
||||
identityProvider: {
|
||||
id: "test-provider",
|
||||
issuer: ISSUER,
|
||||
async endAccount(providerSubject) {
|
||||
const mapped = await findAppUser(holder.app?.data as never, { issuer: ISSUER, subject: providerSubject })
|
||||
ended.push({ providerSubject, mapped: mapped !== null })
|
||||
},
|
||||
},
|
||||
})
|
||||
holder.app = app
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await deleteAs(ada)
|
||||
expect(ended).toEqual([{ providerSubject: ada.subject, mapped: true }])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
const [row] = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(row?.provider).toBe("test-provider")
|
||||
expect(row?.steps).toEqual(["credentials", "service-sessions", "provider-account", "identity"])
|
||||
})
|
||||
|
||||
it("erases an account the provider deleted, without a confirmation, a recent sign-in or a hand-over, once", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
const grace = await app.signUp("grace@example.test")
|
||||
const shared = await orgOf(ada, "shared")
|
||||
await app.data.forScope({ level: "org", orgId: shared, actor: ada.actor }).of(orgMemberships).insert({
|
||||
userId: grace.actor.userId,
|
||||
role: "member",
|
||||
})
|
||||
vi.setSystemTime(Date.now() + (RECENT_SIGN_IN_SECONDS + 60) * 1000)
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
const ledger = (await app.data.global(accountDeletions).list({})).items
|
||||
expect(ledger.map((row) => [row.userId, row.state])).toEqual([[ada.actor.userId, "completed"]])
|
||||
expect(flaky.erased).toEqual([ada.actor.userId])
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
// The other person's account is untouched.
|
||||
expect((await app.data.global(sessions).get(grace.sessionId))?.revokedAt).toBeNull()
|
||||
})
|
||||
|
||||
it("leaves a failed module to the sweep, as a deletion the person asked for", async () => {
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "account-deleted", userId: ada.actor.userId })
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.pending).toContain("flaky")
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("holds the deletion, rather than detach the identity, when no identity maps under the provider", async () => {
|
||||
const ended: string[] = []
|
||||
const app = makeApp({
|
||||
identityProvider: {
|
||||
id: "test-provider",
|
||||
issuer: "another-namespace",
|
||||
endAccount: async (s) => void ended.push(s),
|
||||
},
|
||||
})
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
// A namespace that differs between processes must not end nothing and go on.
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
expect((await app.data.global(accountDeletions).list({})).items[0]?.lastFailure).toBe("provider-account:not_mapped")
|
||||
expect(ended).toEqual([])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
})
|
||||
|
||||
it("revokes every credential of an account the provider disabled, and erases nothing", async () => {
|
||||
const flaky = newFlaky()
|
||||
const { app } = withFlaky(flaky)
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await recordProviderAccountEvent(app.root.child(), { reason: "user-disabled", userId: ada.actor.userId })
|
||||
expect((await app.data.global(sessions).get(ada.sessionId))?.revokedAt).not.toBeNull()
|
||||
expect(flaky.erased).toEqual([])
|
||||
expect((await app.data.global(accountDeletions).list({})).items).toEqual([])
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
// kit:support:begin
|
||||
expect(app.support.calls).toEqual([{ method: "revokeWidgetSessions", subject: { subject: ada.actor.userId } }])
|
||||
// kit:support:end
|
||||
})
|
||||
})
|
||||
|
||||
describe("the deletion ledger records each step, so a resumption runs exactly the ones left", () => {
|
||||
/** A provider whose `endAccount` records the subjects it ended and can be made to fail. */
|
||||
const provider = () => {
|
||||
const state = { ended: [] as string[], fails: false }
|
||||
return {
|
||||
state,
|
||||
provider: {
|
||||
id: "convex-better-auth",
|
||||
issuer: ISSUER,
|
||||
async endAccount(providerSubject: string) {
|
||||
if (state.fails) throw new TypeError("fetch failed")
|
||||
state.ended.push(providerSubject)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const failingApp = (options: Parameters<typeof makeApp>[0] = {}) => {
|
||||
const modules = [...appModules]
|
||||
const memory = createMemoryDataSource({ entities: entitiesOf(buildRegistry(modules), "postgres") })
|
||||
const { data, failing } = failingSessions(memory)
|
||||
return { app: makeApp({ ...options, modules, data }), failing }
|
||||
}
|
||||
const ledgerOf = async (app: ReturnType<typeof makeApp>) => (await app.data.global(accountDeletions).list({})).items
|
||||
|
||||
it("ends the provider account on the worker's retry of a deletion that failed before that step", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const { app, failing } = failingApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
failing.sessions = true
|
||||
await expect(deleteAs(ada)).rejects.toThrow()
|
||||
const [held] = await ledgerOf(app)
|
||||
expect([held?.state, held?.provider, held?.steps ?? []]).toEqual(["erasing", "convex-better-auth", []])
|
||||
expect(state.ended).toEqual([])
|
||||
failing.sessions = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
const [done] = await ledgerOf(app)
|
||||
expect(done?.state).toBe("completed")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).toBeNull()
|
||||
})
|
||||
|
||||
it("waits at the provider step in a process with no provider bound, rather than detach the identity", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const { app, failing } = failingApp({ requestIdentityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
failing.sessions = true
|
||||
await expect(deleteAs(ada)).rejects.toThrow()
|
||||
failing.sessions = false
|
||||
// The sweep runs in the worker's container, which binds no provider in this app.
|
||||
await expect(app.sweep()).rejects.toThrow(/wait/)
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.state).toBe("erasing")
|
||||
expect(held?.lastFailure).toBe("provider-account:provider_unavailable")
|
||||
expect(held?.steps).toEqual(["credentials", "service-sessions"])
|
||||
// The identity the account is found by is still mapped, and the account is not ended yet.
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
expect(state.ended).toEqual([])
|
||||
// The person's retry, from a process that binds the provider, finishes it.
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
})
|
||||
|
||||
it("holds at the provider step while the provider is down, and resumes there", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const app = makeApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
state.fails = true
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.lastFailure).toBe("provider-account:unavailable")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
state.fails = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("does not end the provider account again when a later step held the deletion up", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const app = makeApp({ modules: [...appModules, flakyModule(flaky)], identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await deleteAs(ada)).pending).toEqual(["flaky"])
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
})
|
||||
|
||||
it("ends the account for a ledger row written before steps and the provider were recorded", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const flaky = newFlaky()
|
||||
flaky.eraseFails = true
|
||||
const app = makeApp({ modules: [...appModules, flakyModule(flaky)], identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
// The row as the previous ledger wrote it: no steps, no provider, a module held up.
|
||||
await app.data.global(accountDeletions).insert({
|
||||
userId: ada.actor.userId,
|
||||
state: "erasing",
|
||||
pending: erasing(app.registry),
|
||||
erased: [],
|
||||
abandoned: [],
|
||||
attempts: 1,
|
||||
lastFailure: "flaky:flaky_unavailable",
|
||||
requestedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
steps: null,
|
||||
provider: null,
|
||||
})
|
||||
flaky.eraseFails = false
|
||||
await app.sweep()
|
||||
expect(state.ended).toEqual([ada.subject])
|
||||
expect((await ledgerOf(app))[0]?.state).toBe("completed")
|
||||
})
|
||||
|
||||
it("ends every account the person has under the provider's namespace", async () => {
|
||||
const { state, provider: bound } = provider()
|
||||
const app = makeApp({ identityProvider: bound })
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
await app.data.global(appUserIdentities).insert({
|
||||
userId: ada.actor.userId,
|
||||
issuer: ISSUER,
|
||||
subject: "second-account-of-ada",
|
||||
migrationId: null,
|
||||
})
|
||||
expect((await deleteAs(ada)).state).toBe("completed")
|
||||
expect([...state.ended].sort()).toEqual([ada.subject, "second-account-of-ada"].sort())
|
||||
})
|
||||
|
||||
it("holds a deletion whose provider cannot be built, and exports all the same", async () => {
|
||||
const app = makeApp()
|
||||
app.root.register(ACCOUNT_IDENTITY_PROVIDER, () => {
|
||||
throw new Error("BETTER_AUTH_CONVEX_ADMIN_KEY is not set")
|
||||
})
|
||||
const ada = await app.signUp("ada@example.test")
|
||||
expect((await exportOf(ada)).sections.account?.status).toBe("ok")
|
||||
expect((await deleteAs(ada)).state).toBe("erasing")
|
||||
const [held] = await ledgerOf(app)
|
||||
expect(held?.lastFailure).toBe("provider-account:provider_unavailable")
|
||||
expect(await findAppUser(app.data, { issuer: ISSUER, subject: ada.subject })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user