This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Local generated-app proof: deploy the actual functions to local Convex and exercise the
|
||||
// server-only BFF identity contract plus atomic tenancy/notes mutations.
|
||||
import assert from "node:assert/strict"
|
||||
import { createObjectStore } from "@conusai-kit/platform"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
|
||||
const url = process.env.CONVEX_SELF_HOSTED_URL
|
||||
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
||||
if (!url || !adminKey) throw new Error("local app smoke needs the self-hosted Convex URL and admin key")
|
||||
const ref = (name) => makeFunctionReference(name)
|
||||
const client = new ConvexHttpClient(url)
|
||||
client.setAdminAuth(adminKey)
|
||||
|
||||
const ready = await client.query(ref("system:ready"), {})
|
||||
assert.equal(ready.ready, true)
|
||||
assert.deepEqual(await client.action(ref("filesActions:readiness"), {}), { ready: true })
|
||||
for (const name of ["auth", "files", "notes", "tenancy", "branding"])
|
||||
assert(ready.modules.includes(name), `missing module ${name}`)
|
||||
|
||||
await assert.rejects(
|
||||
() => client.mutation(ref("generated/tenancy:tenancy_org_create"), { input: { slug: "qa-org", name: "QA" } }),
|
||||
(error) => error?.data?.code === "unauthenticated",
|
||||
"unauthenticated requests must not run a public tenancy mutation",
|
||||
)
|
||||
|
||||
const subject = `local-smoke-${Date.now()}`
|
||||
const issuer = "https://conusai.internal/test"
|
||||
// The BFF's first-login transaction (system:appUserResolve replaced system:userInsert).
|
||||
const user = await client.mutation(ref("system:appUserResolve"), {
|
||||
claims: {
|
||||
issuer,
|
||||
subject,
|
||||
email: `${subject}@example.test`,
|
||||
displayName: "Local smoke",
|
||||
locale: "en",
|
||||
},
|
||||
create: true,
|
||||
})
|
||||
const identity = {
|
||||
issuer: "https://conusai.internal/bff",
|
||||
subject: user.id,
|
||||
tokenIdentifier: `https://conusai.internal/bff|${user.id}`,
|
||||
conusaiOrgId: undefined,
|
||||
}
|
||||
client.setAdminAuth(adminKey, identity)
|
||||
|
||||
const organization = await client.mutation(ref("generated/tenancy:tenancy_org_create"), {
|
||||
input: { slug: "smoke-org", name: "Local smoke organization" },
|
||||
})
|
||||
const project = await client.mutation(ref("generated/tenancy:tenancy_project_create"), {
|
||||
input: {
|
||||
org: organization.slug,
|
||||
workspace: organization.workspaceSlug,
|
||||
slug: "smoke-project",
|
||||
name: "Local smoke project",
|
||||
moduleKey: "notes",
|
||||
},
|
||||
})
|
||||
identity.conusaiOrgId = organization.orgId
|
||||
client.setAdminAuth(adminKey, identity)
|
||||
|
||||
const brand = await client.mutation(ref("generated/branding:branding_brand_set"), {
|
||||
input: { org: organization.slug, displayName: "Convex brand smoke", primary: "#1a1a8c", accent: "#f4c430" },
|
||||
})
|
||||
assert.equal(brand.displayName, "Convex brand smoke")
|
||||
assert.equal(brand.primary, "#1a1a8c")
|
||||
assert.deepEqual(
|
||||
await client.query(ref("generated/branding:branding_brand_get"), {
|
||||
input: { org: organization.slug },
|
||||
}),
|
||||
brand,
|
||||
)
|
||||
|
||||
const scope = { org: organization.slug, workspace: organization.workspaceSlug, project: project.slug }
|
||||
const note = await client.mutation(ref("generated/notes:notes_note_create"), {
|
||||
input: { ...scope, title: "Convex generated-app smoke", body: "One atomic Convex mutation." },
|
||||
})
|
||||
const apiUrl = process.env.CONUSAI_API_URL
|
||||
if (!apiUrl) throw new Error("local app smoke needs the fake platform API URL")
|
||||
const blob = createObjectStore({
|
||||
CONUSAI_API_URL: apiUrl,
|
||||
CONUSAI_API_TOKEN: process.env.CONUSAI_API_TOKEN ?? "dev-token",
|
||||
CONUSAI_PROJECT_ID: process.env.CONUSAI_PROJECT_ID ?? "1",
|
||||
})
|
||||
const bytes = new TextEncoder().encode("%PDF-1.4\nConvex local file action proof\n%%EOF\n")
|
||||
const upload = await client.action(ref("filesActions:begin"), {
|
||||
input: { ...scope, filename: "convex-action-proof.pdf", contentType: "application/pdf", size: bytes.length },
|
||||
})
|
||||
const partInfo = await client.query(ref("files:partInfo"), {
|
||||
input: { ...scope, fileId: upload.fileId, part: 1 },
|
||||
})
|
||||
const sent = await blob.uploadContent(partInfo.uploadId, bytes, { part: 1, size: bytes.length })
|
||||
const stored = await client.action(ref("filesActions:complete"), {
|
||||
input: { ...scope, fileId: upload.fileId, parts: [{ number: 1, etag: sent.etag }] },
|
||||
})
|
||||
assert.equal(stored.size, bytes.length, "the action must finalize the bytes Blob actually accepted")
|
||||
assert.equal(typeof stored.sha256, "string")
|
||||
const firstRelay = await client.mutation(ref("relay:relayPass"), {})
|
||||
assert.equal(firstRelay.failed, 0, "the Convex file event subscriber must enqueue its OCR job successfully")
|
||||
let extracted
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
const listing = await client.query(ref("generated/documents:documents_document_list"), { input: scope })
|
||||
extracted = listing.documents.find((document) => document.fileId === upload.fileId)
|
||||
if (extracted?.status === "extracted") break
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
assert.equal(extracted?.status, "extracted", "the stored-file event must dispatch the Convex OCR action")
|
||||
assert(extracted?.pending >= 1, "the OCR action must persist review proposals")
|
||||
const document = await client.query(ref("generated/documents:documents_document_get"), {
|
||||
input: { ...scope, fileId: upload.fileId },
|
||||
})
|
||||
assert.equal(document.lines[0]?.quote, "Reference: QR-2026-04")
|
||||
assert.equal(document.lines[0]?.status, "pending")
|
||||
await client.mutation(ref("generated/proposals:proposals_proposal_approve"), {
|
||||
input: {
|
||||
...scope,
|
||||
proposalId: document.lines[0].lineId,
|
||||
expectedVersion: document.lines[0].expectedVersion,
|
||||
},
|
||||
})
|
||||
const reviewed = await client.query(ref("generated/documents:documents_document_get"), {
|
||||
input: { ...scope, fileId: upload.fileId },
|
||||
})
|
||||
assert.equal(reviewed.document.approved, 1, "a reviewed proposal must write an approved document line atomically")
|
||||
assert.equal(reviewed.lines[0]?.status, "approved")
|
||||
const attachment = await client.mutation(ref("generated/notes:notes_attachment_add"), {
|
||||
input: { ...scope, noteId: note.noteId, fileId: upload.fileId },
|
||||
})
|
||||
const withAttachment = await client.query(ref("generated/notes:notes_note_get"), {
|
||||
input: { ...scope, noteId: note.noteId },
|
||||
})
|
||||
assert.equal(withAttachment.attachmentsEnabled, true)
|
||||
assert.equal(withAttachment.attachments[0]?.attachmentId, attachment.attachmentId)
|
||||
await client.mutation(ref("generated/notes:notes_attachment_remove"), {
|
||||
input: { ...scope, noteId: note.noteId, attachmentId: attachment.attachmentId },
|
||||
})
|
||||
await client.action(ref("filesActions:remove"), { input: { ...scope, fileId: upload.fileId } })
|
||||
await assert.rejects(
|
||||
() => client.query(ref("generated/files:files_file_get"), { input: { ...scope, fileId: upload.fileId } }),
|
||||
/no such file/i,
|
||||
"delete must remove the Blob object before finalizing the file row",
|
||||
)
|
||||
const listed = await client.query(ref("generated/notes:notes_note_list"), { input: scope })
|
||||
assert(
|
||||
listed.notes.some((item) => item.noteId === note.noteId),
|
||||
"notes mutation must be readable through Convex",
|
||||
)
|
||||
const details = await client.query(ref("generated/notes:notes_note_get"), { input: { ...scope, noteId: note.noteId } })
|
||||
assert.equal(details.attachmentsEnabled, true, "the composed file workflow enables notes attachments")
|
||||
assert.equal(details.attachments.length, 0, "removing an attachment must detach the existing file link")
|
||||
// Effect procedures of guides and support are generated Convex actions (convex/lib/effects.ts): the
|
||||
// kernel resolves the member's scope and permission in an internal query, runs the nested
|
||||
// `tenancy.me.get` as its generated query, and calls the platform through the module's port.
|
||||
const effectState = async () => {
|
||||
const response = await fetch(new URL("/__e2e/state", apiUrl), { headers: { "x-conusai-organization": "e2e" } })
|
||||
return response.json()
|
||||
}
|
||||
// The widget calls these as a signed-in member: the BFF pins an organization only for an API key
|
||||
// (convex-caller.ts), and a pinned credential has no user scope for the nested tenancy.me.get.
|
||||
const memberIdentity = { ...identity }
|
||||
delete memberIdentity.conusaiOrgId
|
||||
client.setAdminAuth(adminKey, memberIdentity)
|
||||
// A procedure without input, called the way the BFF calls it (input undefined, dropped on the wire).
|
||||
const me = await client.query(ref("generated/tenancy:tenancy_me_get"), {})
|
||||
assert(
|
||||
me.organizations.some((entry) => entry.slug === organization.slug && entry.role === "owner"),
|
||||
"tenancy.me.get answers a call without input",
|
||||
)
|
||||
// kit:guides:begin
|
||||
const bootstrap = await client.action(ref("generated/guides:guides_runtime_bootstrap"), {
|
||||
input: { org: organization.slug, path: "/notes", locale: "en" },
|
||||
})
|
||||
assert.deepEqual(bootstrap, { mode: "platform", etag: '"fake-guides"', guides: [] })
|
||||
const eventId = globalThis.crypto.randomUUID()
|
||||
const recorded = await client.action(ref("generated/guides:guides_runtime_record"), {
|
||||
input: {
|
||||
org: organization.slug,
|
||||
idempotencyKey: "convex-smoke-batch",
|
||||
events: [
|
||||
{
|
||||
schema_version: 1,
|
||||
event_id: eventId,
|
||||
type: "guide.started",
|
||||
guide_key: "notes.first-note",
|
||||
guide_version: 1,
|
||||
expected_progress_revision: 0,
|
||||
progress_epoch: 0,
|
||||
occurred_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
assert.equal(recorded.outcomes[0]?.event_id, eventId)
|
||||
assert.equal(recorded.outcomes[0]?.status, "applied")
|
||||
const guideCalls = (await effectState()).guides
|
||||
const sentBootstrap = guideCalls.find((call) => call.route === "bootstrap")
|
||||
assert.equal(sentBootstrap?.body?.identity?.subject, user.id, "the subject is the app's own user id")
|
||||
assert.equal(
|
||||
sentBootstrap?.body?.identity?.app_tenant,
|
||||
organization.orgId,
|
||||
"the app tenant is the resolved organization",
|
||||
)
|
||||
assert.equal(
|
||||
sentBootstrap?.body?.context?.traits?.role,
|
||||
"owner",
|
||||
"the role trait came from the nested tenancy.me.get query",
|
||||
)
|
||||
assert(guideCalls.some((call) => call.route === "record" && call.body?.events?.[0]?.event_id === eventId))
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
assert.deepEqual(
|
||||
await client.action(ref("generated/support:support_widget_config"), { input: { org: organization.slug } }),
|
||||
{ mode: "platform", channel: "convex-smoke-channel" },
|
||||
)
|
||||
const supportSession = await client.action(ref("generated/support:support_widgetSession_mint"), {
|
||||
input: { org: organization.slug },
|
||||
})
|
||||
assert(supportSession.token.startsWith("sws_"), "a minted support session")
|
||||
const minted = (await effectState()).support.find((call) => call.route === "mint")
|
||||
assert.equal(minted?.body?.subject, user.id)
|
||||
assert.equal(minted?.body?.app_tenant, organization.orgId)
|
||||
assert.equal(minted?.body?.display_name, "Local smoke", "the display name came from the nested tenancy.me.get query")
|
||||
// kit:support:end
|
||||
// A member of no organization: the action's kernel refuses before any platform call. The user is
|
||||
// created with plain admin auth, as the first one was (system:appUserResolve is internal).
|
||||
client.setAdminAuth(adminKey)
|
||||
const stranger = await client.mutation(ref("system:appUserResolve"), {
|
||||
claims: {
|
||||
issuer,
|
||||
subject: `${subject}-stranger`,
|
||||
email: `${subject}-stranger@example.test`,
|
||||
displayName: "Stranger",
|
||||
locale: "en",
|
||||
},
|
||||
create: true,
|
||||
})
|
||||
const strangerIdentity = {
|
||||
...identity,
|
||||
subject: stranger.id,
|
||||
tokenIdentifier: `https://conusai.internal/bff|${stranger.id}`,
|
||||
}
|
||||
delete strangerIdentity.conusaiOrgId
|
||||
client.setAdminAuth(adminKey, strangerIdentity)
|
||||
const callsBefore = await effectState()
|
||||
const strangerCalls = []
|
||||
strangerCalls.push(["generated/guides:guides_runtime_bootstrap", { org: organization.slug, path: "/notes" }]) // kit:guides
|
||||
strangerCalls.push(["generated/support:support_widgetSession_mint", { org: organization.slug }]) // kit:support
|
||||
for (const [reference, input] of strangerCalls) {
|
||||
await assert.rejects(
|
||||
() => client.action(ref(reference), { input }),
|
||||
(error) => error?.data?.code === "not_found",
|
||||
`${reference}: a stranger to the organization gets the not-found of a missing scope`,
|
||||
)
|
||||
}
|
||||
const callsAfter = await effectState()
|
||||
assert.equal(callsAfter.guides.length, callsBefore.guides.length, "no platform call for a stranger")
|
||||
assert.equal(callsAfter.support.length, callsBefore.support.length, "no platform call for a stranger")
|
||||
client.setAdminAuth(adminKey, identity)
|
||||
identity.conusaiOrgId = "another-org-id"
|
||||
client.setAdminAuth(adminKey, identity)
|
||||
await assert.rejects(
|
||||
() => client.query(ref("generated/branding:branding_brand_get"), { input: { org: organization.slug } }),
|
||||
/scope|organization|tenant|membership|not found/i,
|
||||
"branding must refuse the same mismatched organization identity as notes",
|
||||
)
|
||||
await assert.rejects(
|
||||
() => client.query(ref("generated/notes:notes_note_list"), { input: scope }),
|
||||
/scope|organization|tenant|membership|not found/i,
|
||||
"a BFF identity pinned to another organization must not cross the tenant wall",
|
||||
)
|
||||
// kit:guides:begin
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.action(ref("generated/guides:guides_runtime_bootstrap"), {
|
||||
input: { org: organization.slug, path: "/notes" },
|
||||
}),
|
||||
(error) => error?.data?.code === "not_found",
|
||||
"an effect action refuses an identity pinned to another organization",
|
||||
)
|
||||
// kit:guides:end
|
||||
client.setAdminAuth(adminKey)
|
||||
const relayResult = await client.mutation(ref("relay:relayPass"), {})
|
||||
assert.equal(relayResult.failed, 0, "outbox subscribers must complete without failure")
|
||||
const platformHeaders = {
|
||||
authorization: "Bearer dev-token",
|
||||
"x-conusai-organization": "fixture-org",
|
||||
}
|
||||
const auditHeaders = { ...platformHeaders, "x-conusai-organization": "e2e" }
|
||||
const stateUrl = new URL("/__e2e/state", apiUrl)
|
||||
const auditUrl = new URL("/_conusai/audit/events", apiUrl)
|
||||
let delivered = false
|
||||
let usageCount = 0
|
||||
let auditCount = 0
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const stateResponse = await fetch(stateUrl, { headers: platformHeaders })
|
||||
const state = await stateResponse.json()
|
||||
const auditResponse = await fetch(auditUrl, { headers: auditHeaders })
|
||||
const audit = await auditResponse.json()
|
||||
usageCount = state.usage?.length ?? 0
|
||||
auditCount = audit.items?.length ?? 0
|
||||
if (state.usage?.some((event) => event.meter === "notes_created") && audit.items?.length) {
|
||||
delivered = true
|
||||
break
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
assert(
|
||||
delivered,
|
||||
`outbox relay must reach fake platform audit and usage sinks (usage=${usageCount}, audit=${auditCount})`,
|
||||
)
|
||||
// The account module on Convex (convex/account.ts): export my data and delete my account through the
|
||||
// generated actions, with the ledger, the order and the partial-failure export of the Postgres profile.
|
||||
// Every step is checked against the real backend's rows and what the fake platform received.
|
||||
client.setAdminAuth(adminKey)
|
||||
const outage = async (prefix, down) => {
|
||||
const response = await fetch(new URL("/__e2e/outage", apiUrl), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ prefix, down }),
|
||||
})
|
||||
assert.equal(response.status, 204, `the fake platform takes ${prefix} ${down ? "down" : "up"}`)
|
||||
}
|
||||
const storeRead = (method, ...args) => client.query(ref("account:storeRead"), { method, args })
|
||||
const iso = (ms) => new Date(ms).toISOString()
|
||||
// The identity of each person: under the test issuer, or -- in a Better Auth app, whose deletions
|
||||
// refuse an identity under any other namespace (`provider-account:not_mapped`) and end the account
|
||||
// at Better Auth -- a real Better Auth account under the app's namespace.
|
||||
const accounts = { issuer, subjectFor: async (name) => `${subject}-${name}` }
|
||||
// kit:convex-better-auth:begin
|
||||
const betterAuthSite = process.env.SMOKE_BETTER_AUTH_SITE_URL
|
||||
const namespace = process.env.SMOKE_AUTH_IDENTITY_NAMESPACE
|
||||
const appOrigin = process.env.SMOKE_PUBLIC_URL
|
||||
if (!betterAuthSite || !namespace || !appOrigin)
|
||||
throw new Error("the Better Auth smoke needs its deployment's site URL")
|
||||
const betterAuth = (path, body) =>
|
||||
fetch(`${betterAuthSite}/api/auth${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: appOrigin },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const password = `convex-smoke-${Date.now()}-password`
|
||||
const signUpAt = async (name, email) => {
|
||||
const signUp = await betterAuth("/sign-up/email", { name, email, password })
|
||||
assert.equal(signUp.status, 200, "a person signs up at the Better Auth deployment")
|
||||
return (await signUp.json()).user.id
|
||||
}
|
||||
accounts.issuer = namespace
|
||||
accounts.subjectFor = (name) => signUpAt(name, `${subject}-${name}@example.test`)
|
||||
// kit:convex-better-auth:end
|
||||
/** A person signed in with a kit session row, and the BFF identity of that device. */
|
||||
const signedIn = async (name) => {
|
||||
const personSubject = await accounts.subjectFor(name)
|
||||
client.setAdminAuth(adminKey)
|
||||
const person = await client.mutation(ref("system:appUserResolve"), {
|
||||
claims: {
|
||||
issuer: accounts.issuer,
|
||||
subject: personSubject,
|
||||
email: `${subject}-${name}@example.test`,
|
||||
displayName: name,
|
||||
locale: "en",
|
||||
},
|
||||
create: true,
|
||||
})
|
||||
const now = Date.now()
|
||||
const device = await client.mutation(ref("system:sessionInsert"), {
|
||||
input: {
|
||||
userId: person.id,
|
||||
issuer: accounts.issuer,
|
||||
subject: personSubject,
|
||||
handleHash: Array.from(globalThis.crypto.getRandomValues(new Uint8Array(32)), (b) =>
|
||||
b.toString(16).padStart(2, "0"),
|
||||
).join(""),
|
||||
tokens: "sealed-tokens-of-the-smoke",
|
||||
accessTokenExpiresAt: iso(now + 3_600_000),
|
||||
lastSeenAt: iso(now),
|
||||
absoluteExpiresAt: iso(now + 86_400_000),
|
||||
revokedAt: null,
|
||||
},
|
||||
})
|
||||
const browser = {
|
||||
issuer: "https://conusai.internal/bff",
|
||||
subject: person.id,
|
||||
tokenIdentifier: `https://conusai.internal/bff|${person.id}`,
|
||||
conusaiCredential: "interactive",
|
||||
conusaiSessionId: device.id,
|
||||
}
|
||||
return { person, device, browser, email: `${subject}-${name}@example.test` }
|
||||
}
|
||||
const ada = await signedIn("account")
|
||||
client.setAdminAuth(adminKey, ada.browser)
|
||||
const accountOrg = await client.mutation(ref("generated/tenancy:tenancy_org_create"), {
|
||||
input: { slug: "account-smoke", name: "Account smoke" },
|
||||
})
|
||||
// The support module knows the person once a widget session was minted for them.
|
||||
await client.action(ref("generated/support:support_widgetSession_mint"), { input: { org: accountOrg.slug } }) // kit:support
|
||||
const exportAs = () => client.action(ref("generated/account:account_data_export"), { input: {} })
|
||||
// An API credential acting as the person is refused: the export is their own, in full.
|
||||
const { conusaiCredential: _interactive, conusaiSessionId: _session, ...apiIdentity } = ada.browser
|
||||
client.setAdminAuth(adminKey, apiIdentity)
|
||||
await assert.rejects(exportAs, (error) => error?.data?.code === "forbidden", "an API credential cannot export")
|
||||
client.setAdminAuth(adminKey, ada.browser)
|
||||
const exported = await exportAs()
|
||||
assert.equal(
|
||||
exported.complete,
|
||||
true,
|
||||
`every installed module exported its share: ${JSON.stringify(exported.failed)} ${JSON.stringify(Object.fromEntries(Object.entries(exported.sections).map(([key, section]) => [key, section.status === "failed" ? section : section.status])))}`,
|
||||
)
|
||||
assert.equal(exported.subject, ada.person.id)
|
||||
assert.equal(exported.sections.account?.status, "ok")
|
||||
assert.equal(exported.sections.account.data.profile.email, ada.email, "this app's own rows, from the Convex tables")
|
||||
assert.deepEqual(
|
||||
exported.sections.account.data.organizations.map((org) => org.slug),
|
||||
["account-smoke"],
|
||||
)
|
||||
// A module whose service is down is a failed section, named, never left out.
|
||||
await outage("/v1/support/", true) // kit:support
|
||||
const partial = await exportAs()
|
||||
// kit:support:begin
|
||||
assert.equal(partial.complete, false)
|
||||
assert.deepEqual(partial.failed, ["support"])
|
||||
assert.equal(partial.sections.support?.status, "failed")
|
||||
assert.equal(partial.sections.support.retryable, true)
|
||||
// kit:support:end
|
||||
assert.equal(partial.sections.account?.status, "ok")
|
||||
const status = await client.query(ref("generated/account:account_deletion_status"), {})
|
||||
assert.equal(status.deletion, null)
|
||||
assert.equal(status.interactive, true)
|
||||
assert.equal(status.recentSignIn, true, "the sign-in of the session row the BFF named is recent")
|
||||
assert.deepEqual(
|
||||
status.onlyMemberOf.map((org) => org.slug),
|
||||
["account-smoke"],
|
||||
)
|
||||
const deleteAs = (confirm) => client.action(ref("generated/account:account_deletion_request"), { input: { confirm } })
|
||||
await assert.rejects(
|
||||
() => deleteAs("someone-else@example.test"),
|
||||
(error) => error?.data?.code === "confirmation_mismatch",
|
||||
"a deletion is confirmed with the account's own address",
|
||||
)
|
||||
assert.equal(await storeRead("deletion", ada.person.id), null, "a refused deletion writes no ledger row")
|
||||
const supportBefore = (await effectState()).support.length
|
||||
const held = await deleteAs(ada.email.toUpperCase())
|
||||
const ledger = await storeRead("deletion", ada.person.id)
|
||||
// kit:support:begin
|
||||
assert.equal(held.state, "erasing", "the support module is down: the deletion holds")
|
||||
assert.deepEqual(held.pending, ["support"], `held at ${ledger.lastFailure} after ${JSON.stringify(ledger.steps)}`)
|
||||
assert.match(ledger.lastFailure, /^support:/, "the ledger names the module that held it, by its problem code")
|
||||
// The steps before the erasures completed, except the widget-session revocation the platform refused.
|
||||
assert(!ledger.steps.includes("service-sessions"))
|
||||
// kit:support:end
|
||||
assert(
|
||||
["credentials", "identity"].every((step) => ledger.steps.includes(step)),
|
||||
"the ledger records the steps",
|
||||
)
|
||||
assert.equal((await client.query(ref("system:sessionById"), { id: ada.device.id }))?.revokedAt === null, false)
|
||||
assert.equal(await storeRead("emailOf", ada.person.id), `${ada.person.id}@deleted.invalid`, "the identity is detached")
|
||||
assert.deepEqual(await storeRead("identities", ada.person.id), [], "no sign-in reaches the account")
|
||||
// kit:guides:begin
|
||||
assert(
|
||||
(await effectState()).guides.some((call) => call.route === "erase" && call.subject === ada.person.id),
|
||||
"the guides module erased its share by the subject while the user row still existed",
|
||||
)
|
||||
// kit:guides:end
|
||||
client.setAdminAuth(adminKey)
|
||||
// kit:support:begin
|
||||
await assert.rejects(
|
||||
() => client.action(ref("account:sweep"), {}),
|
||||
(error) => error?.data?.code === "account_deletion_waiting",
|
||||
"the sweep fails while a deletion waits: the line an operator is alerted on",
|
||||
)
|
||||
// kit:support:end
|
||||
await outage("/v1/support/", false) // kit:support
|
||||
await client.action(ref("account:sweep"), {})
|
||||
const done = await storeRead("deletion", ada.person.id)
|
||||
assert.equal(done.state, "completed", "the sweep finished the deletion once the module was back")
|
||||
assert.deepEqual(done.pending, [])
|
||||
assert.equal(await storeRead("emailOf", ada.person.id), null, "the user row goes last")
|
||||
assert.equal(await client.query(ref("system:sessionById"), { id: ada.device.id }), null)
|
||||
// kit:support:begin
|
||||
const supportCalls = (await effectState()).support.slice(supportBefore)
|
||||
const revokedAt = supportCalls.findIndex((call) => call.route === "revoke" && call.body?.subject === ada.person.id)
|
||||
const erasedAt = supportCalls.findIndex((call) => call.route === "erase" && call.subject === ada.person.id)
|
||||
assert(revokedAt >= 0 && erasedAt > revokedAt, "the widget sessions were revoked before the contact was erased")
|
||||
assert(done.steps.includes("service-sessions"), "the resumption repeated the refused revocation")
|
||||
assert.deepEqual(
|
||||
[...done.erased].sort(),
|
||||
["guides", "support"].filter((key) => status.modules.includes(key)),
|
||||
)
|
||||
// kit:support:end
|
||||
// Asked again, the deletion that has completed answers its state: one deletion, however often asked.
|
||||
client.setAdminAuth(adminKey, ada.browser)
|
||||
assert.equal((await deleteAs(ada.email)).state, "completed")
|
||||
// The identity provider's account events (Better Auth's relay, with the admin key): a ban revokes
|
||||
// every credential and erases nothing; a deletion at the provider erases through the same ledger.
|
||||
const grace = await signedIn("provider-event")
|
||||
client.setAdminAuth(adminKey)
|
||||
await client.action(ref("account:providerEvent"), { reason: "user-disabled", userId: grace.person.id })
|
||||
assert.notEqual((await client.query(ref("system:sessionById"), { id: grace.device.id }))?.revokedAt, null)
|
||||
assert.equal(await storeRead("deletion", grace.person.id), null, "a ban erases nothing")
|
||||
await client.action(ref("account:providerEvent"), { reason: "account-deleted", userId: grace.person.id })
|
||||
await client.action(ref("account:providerEvent"), { reason: "account-deleted", userId: grace.person.id })
|
||||
assert.equal((await storeRead("deletion", grace.person.id))?.state, "completed")
|
||||
assert.equal(await storeRead("emailOf", grace.person.id), null)
|
||||
// kit:support:begin
|
||||
// The sign-out revocation outbox (convex/account.ts): a widget session minted for one device's app
|
||||
// session is ended by that device's sign-out. The web's `account:revokeSession` records it in the
|
||||
// backend and answers while the support platform is down; the cron's `account:revocationSweep`
|
||||
// retries it once due, delivers it once, and a revocation older than 65 minutes lapses unasked.
|
||||
const revocationRead = (method, ...args) => client.query(ref("account:revocationRead"), { method, args })
|
||||
const revocationWrite = (method, ...args) => client.mutation(ref("account:revocationWrite"), { method, args })
|
||||
const pendingRevocation = async (session) =>
|
||||
(await revocationRead("due", iso(Date.now() + 3_600_000), 100)).find((row) => row.session === session)
|
||||
const revokesOf = async (session) =>
|
||||
(await effectState()).support.filter((call) => call.route === "revoke" && call.body?.identity_session === session)
|
||||
// A person under the test issuer: no Better Auth sign-up (its sign-up rate limit is for the rows below).
|
||||
client.setAdminAuth(adminKey)
|
||||
const leaver = await client.mutation(ref("system:appUserResolve"), {
|
||||
claims: {
|
||||
issuer,
|
||||
subject: `${subject}-sign-out`,
|
||||
email: `${subject}-sign-out@example.test`,
|
||||
displayName: "Sign-out",
|
||||
locale: "en",
|
||||
},
|
||||
create: true,
|
||||
})
|
||||
const leaving = {
|
||||
browser: {
|
||||
issuer: "https://conusai.internal/bff",
|
||||
subject: leaver.id,
|
||||
tokenIdentifier: `https://conusai.internal/bff|${leaver.id}`,
|
||||
},
|
||||
}
|
||||
const deviceSession = `4:oidc:${leaver.id.length}:${leaver.id}`
|
||||
client.setAdminAuth(adminKey, leaving.browser)
|
||||
const leavingOrg = await client.mutation(ref("generated/tenancy:tenancy_org_create"), {
|
||||
input: { slug: "revocation-smoke", name: "Revocation smoke" },
|
||||
})
|
||||
client.setAdminAuth(adminKey, { ...leaving.browser, conusaiIdentitySession: deviceSession })
|
||||
await client.action(ref("generated/support:support_widgetSession_mint"), { input: { org: leavingOrg.slug } })
|
||||
assert.equal(
|
||||
(await effectState()).support.filter((call) => call.route === "mint" && call.body?.identity_session === deviceSession)
|
||||
.length,
|
||||
1,
|
||||
"the widget session names the device's app session it was minted for",
|
||||
)
|
||||
client.setAdminAuth(adminKey)
|
||||
await outage("/v1/support/", true)
|
||||
await client.action(ref("account:revokeSession"), { session: deviceSession })
|
||||
const waiting = await pendingRevocation(deviceSession)
|
||||
assert.equal(waiting?.state, "pending", "the sign-out answered and the revocation is recorded in the backend")
|
||||
assert.deepEqual(waiting.pending, ["support"])
|
||||
assert.equal(waiting.attempts, 1)
|
||||
assert.match(waiting.lastFailure, /^support:/)
|
||||
assert.deepEqual(await revokesOf(deviceSession), [])
|
||||
await outage("/v1/support/", false)
|
||||
// Not due yet (the row's 30 s backoff): the cron's run asks nobody.
|
||||
await client.action(ref("account:revocationSweep"), {})
|
||||
assert.deepEqual(await revokesOf(deviceSession), [], "a retry waits for its backoff")
|
||||
// Due: the retry brought forward rather than waited out.
|
||||
await revocationWrite("change", waiting, { nextAttemptAt: iso(Date.now()) })
|
||||
await client.action(ref("account:revocationSweep"), {})
|
||||
const revocations = await revokesOf(deviceSession)
|
||||
assert.equal(revocations.length, 1, "the retry delivered the revocation")
|
||||
assert.equal(revocations[0].revoked, 1, "the device's widget session ended")
|
||||
assert.equal(await pendingRevocation(deviceSession), undefined)
|
||||
await client.action(ref("account:revocationSweep"), {})
|
||||
await client.action(ref("account:revokeSession"), { session: deviceSession })
|
||||
assert.equal((await revokesOf(deviceSession)).length, 1, "delivered once: a second sweep or notification asks nobody")
|
||||
const stale = `${deviceSession}:stale`
|
||||
const longAgo = iso(Date.now() - 66 * 60_000)
|
||||
await revocationWrite("record", { session: stale, modules: ["support"], at: longAgo, claimedUntil: longAgo })
|
||||
await client.action(ref("account:revocationSweep"), {})
|
||||
assert.equal(await pendingRevocation(stale), undefined, "a revocation older than 65 minutes lapses")
|
||||
assert.deepEqual(await revokesOf(stale), [], "and nobody is asked")
|
||||
// kit:support:end
|
||||
// kit:convex-better-auth:begin
|
||||
// With Better Auth, a deletion ends the person's account in the app's Better Auth deployment (a real
|
||||
// backend here), found by the identity mapping, before the identity is detached.
|
||||
const linusEmail = `${subject}-better-auth@example.test`
|
||||
const betterAuthUser = await signUpAt("Linus", linusEmail)
|
||||
client.setAdminAuth(adminKey)
|
||||
const linus = await client.mutation(ref("system:appUserResolve"), {
|
||||
claims: { issuer: namespace, subject: betterAuthUser, email: linusEmail, displayName: "Linus", locale: "en" },
|
||||
create: true,
|
||||
})
|
||||
client.setAdminAuth(adminKey, {
|
||||
issuer: "https://conusai.internal/bff",
|
||||
subject: linus.id,
|
||||
tokenIdentifier: `https://conusai.internal/bff|${linus.id}`,
|
||||
conusaiCredential: "interactive",
|
||||
// A Better Auth session has no kit row: the BFF names the sign-in time Better Auth reported.
|
||||
conusaiSignedInAt: iso(Date.now() - 1000),
|
||||
})
|
||||
assert.equal((await deleteAs(linusEmail)).state, "completed")
|
||||
const linusLedger = await storeRead("deletion", linus.id)
|
||||
assert.equal(linusLedger.provider, "convex-better-auth", "the deletion recorded the provider it ends the account at")
|
||||
assert(linusLedger.steps.includes("provider-account"))
|
||||
assert.notEqual(
|
||||
(await betterAuth("/sign-in/email", { email: linusEmail, password })).status,
|
||||
200,
|
||||
"the Better Auth account is gone: its password signs nobody in",
|
||||
)
|
||||
// kit:convex-better-auth:end
|
||||
console.log(
|
||||
"local Convex app smoke green: deployed functions, BFF identity, tenancy, project, file Blob actions, OCR action, proposal approval, notes read/write, guides/support effect actions, account export/deletion/sweep/provider events, sign-out revocation outbox",
|
||||
)
|
||||
Reference in New Issue
Block a user