Files
template-app/test/convex-profile.test.ts
2026-11-14 09:56:45 +00:00

638 lines
31 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { readFileSync } from "node:fs"
import { AppError } from "@conusai-kit/core"
import { getFunctionName } from "convex/server"
import { ConvexError } from "convex/values"
import { describe, expect, it, vi } from "vitest"
import { parse } from "yaml"
import { projectEntriesFor } from "../apps/web/src/server/project-entries.js" // kit:documents
import {
CONVEX_ACTION_EFFECT_MODULES,
CONVEX_EXCLUDED_MODULES,
CONVEX_EXCLUDED_PROCEDURES,
convexBffModules,
convexModules,
} from "../convex/lib/convexModules.mjs"
import { appModules } from "../modules/index.js"
const root = new URL("..", import.meta.url)
describe("Convex deployable module closure", () => {
// kit:documents:begin
it("keeps the documents review route visible in the Convex project navigation", () => {
expect(projectEntriesFor("convex")).toContainEqual({
href: "documents",
label: "documents.title",
module: "documents",
})
})
// kit:documents:end
it("includes server-side auth handlers and only excludes modules with an explicit runtime gap", () => {
// Modules that declare `profiles: ["postgres"]` (knowledge, agent) ship fail-closed Convex
// binding files, but the integration review (2026-10-23) kept them Postgres-only: their
// search/run effects and jobs leave the process and have no composed Convex Node actions yet
// (the reasons are in their module READMEs). The generated closure is therefore the modules
// that support the profile.
const convexBound = appModules.filter(
(module) => module.profiles === undefined || module.profiles.includes("convex"),
)
const modules = convexModules(convexBound)
expect(modules.map(({ id }) => id)).toEqual([
"tenancy",
"auth",
"files",
"proposals",
"notifications",
"branding",
"notes", // kit:notes
"account",
"documents", // kit:documents
"guides", // kit:guides
"support", // kit:support
])
// The only effects generated as actions are those of the platform-call modules (convex/lib/effects.ts).
const effects = modules.flatMap(({ id, procedures = [] }) =>
procedures.filter(({ kind }) => kind === "effect").map(({ name }) => [id, name] as const),
)
expect(effects.every(([id]) => CONVEX_ACTION_EFFECT_MODULES.has(id))).toBe(true)
const effectNames = effects.map(([, name]) => name)
// The account module's export and deletion: actions over its bound store (convex/account.ts).
const expectedEffects: string[] = [
"account.data.export",
"account.deletion.request",
"account.analytics.request",
"account.analytics.status",
"account.analytics.export",
]
// kit:guides:begin
expectedEffects.push(
"guides.builder.open",
"guides.runtime.bootstrap",
"guides.runtime.deleteFile",
"guides.runtime.record",
)
// A survey file's upload is a streamed body: not a Convex action on this profile.
expect(CONVEX_EXCLUDED_PROCEDURES.has("guides.runtime.uploadFile")).toBe(true)
// kit:guides:end
// kit:support:begin
expectedEffects.push("support.widgetSession.mint", "support.widget.config")
// kit:support:end
expect([...effectNames].sort()).toEqual(expectedEffects.sort())
const fileProcedures = modules.find(({ id }) => id === "files")?.procedures?.map(({ name }) => name)
expect(fileProcedures ?? []).toContain("files.file.get")
expect(fileProcedures ?? []).toContain("files.file.list")
expect(fileProcedures ?? []).not.toContain("files.file.delete")
expect(CONVEX_EXCLUDED_PROCEDURES.has("files.file.delete")).toBe(true)
expect([...CONVEX_EXCLUDED_MODULES.keys()]).toEqual([])
const branding = modules.find(({ id }) => id === "branding")?.procedures?.map(({ name }) => name)
expect(branding).toEqual(["branding.brand.get", "branding.brand.set"])
for (const name of ["branding.logo.get", "branding.logo.set", "branding.logo.clear"]) {
expect(CONVEX_EXCLUDED_PROCEDURES.has(name)).toBe(true)
expect(
convexBffModules(appModules)
.flatMap(({ procedures = [] }) => procedures)
.map(({ name }) => name),
).not.toContain(name)
}
expect(CONVEX_EXCLUDED_MODULES.get("auth")).toBeUndefined()
})
it("keeps composed file effects in the web BFF registry without generating Convex effect wrappers", () => {
const filtered = appModules.filter((module) => module.profiles === undefined || module.profiles.includes("convex"))
const backend = convexModules(filtered)
const bff = convexBffModules(filtered)
expect(
backend.some(
({ id, procedures = [] }) =>
!CONVEX_ACTION_EFFECT_MODULES.has(id) && procedures.some(({ kind }) => kind === "effect"),
),
).toBe(false)
const fileProcedures = bff.find(({ id }) => id === "files")?.procedures?.map(({ name }) => name)
expect(fileProcedures ?? []).toContain("files.file.beginUpload")
expect(fileProcedures ?? []).toContain("files.file.putPart")
expect(fileProcedures ?? []).toContain("files.file.completeUpload")
expect(fileProcedures ?? []).toContain("files.file.delete")
expect(bff.find(({ id }) => id === "proposals")?.procedures?.map(({ name }) => name)).toContain(
"proposals.proposal.approve",
)
// kit:documents:begin
expect(bff.find(({ id }) => id === "documents")?.procedures?.map(({ name }) => name)).toContain(
"documents.document.get",
)
// kit:documents:end
})
it("registers scheduled mutations through generated internal function references", () => {
const crons = readFileSync(new URL("convex/crons.ts", root), "utf8")
expect(crons).toContain("internal.relay.relayPass")
expect(crons).toContain("internal.files.recoverRows")
// The account module's deletion sweep, the Postgres worker's DELETION_SWEEP_JOB.
expect(crons).toContain("internal.account.sweep")
expect(crons).not.toContain('from "./relay"')
})
it("keeps audit credentials behind a root internal action", () => {
const config = readFileSync(new URL("convex/convex.config.ts", root), "utf8")
const provider = readFileSync(new URL("convex/auditCredentials.ts", root), "utf8")
const relay = readFileSync(new URL("convex/relay.ts", root), "utf8")
expect(config).not.toContain("CONUSAI_API_TOKEN")
expect(provider).toContain("export const get = internalAction(")
expect(provider).toContain("apiUrl: process.env.CONUSAI_API_URL")
expect(provider).toContain("apiToken: process.env.CONUSAI_API_TOKEN")
expect(relay).toContain("credentialsProvider: internal.auditCredentials.get")
})
it("deploys functions before web and backup with the provider's server-only credential names", () => {
const compose = parse(readFileSync(new URL("profiles/convex/compose.yaml", root), "utf8")) as {
services: Record<
string,
{
command?: string[]
environment?: Record<string, string>
restart?: string
depends_on?: Record<string, { condition: string }>
}
>
}
const profile = JSON.parse(readFileSync(new URL("profiles/convex/conusai.json", root), "utf8")) as {
services: Record<string, unknown>
}
expect(Object.keys(compose.services).sort()).toEqual(["backup", "convex-deploy", "web"])
expect(Object.keys(profile.services).sort()).toEqual(Object.keys(compose.services).sort())
expect(compose.services["convex-deploy"]?.command).toEqual(["node", "profiles/convex/deploy.mjs"])
expect(compose.services["convex-deploy"]?.restart).toBe("no")
for (const name of ["web", "backup"] as const) {
expect(compose.services[name]?.depends_on?.["convex-deploy"]).toEqual({
condition: "service_completed_successfully",
})
}
for (const name of ["web", "backup", "convex-deploy"] as const) {
const environment = compose.services[name]?.environment ?? {}
expect(environment.CONVEX_SELF_HOSTED_URL).toBe("$" + "{CONVEX_SELF_HOSTED_URL}")
expect(environment.CONVEX_SELF_HOSTED_ADMIN_KEY).toBe("$" + "{CONVEX_SELF_HOSTED_ADMIN_KEY}")
// `VITE_` is what Vite writes into the browser's bundle.
expect(Object.keys(environment).some((key) => key.startsWith("VITE_") && key.includes("ADMIN"))).toBe(false)
expect(environment.CONVEX_URL).toBeUndefined()
expect(environment.CONVEX_ADMIN_KEY).toBeUndefined()
}
expect(compose.services["convex-deploy"]?.environment).toMatchObject({
CONUSAI_API_TOKEN: expect.stringMatching(/^\$\{CONUSAI_API_TOKEN\}$/),
CONUSAI_PROJECT_ID: expect.stringMatching(/^\$\{CONUSAI_PROJECT_ID\}$/),
CONUSAI_ORG_SLUG: expect.stringMatching(/^\$\{CONUSAI_ORG_SLUG\}$/),
})
expect(compose.services.web?.environment?.CONUSAI_ORG_SLUG).toBe("$" + "{CONUSAI_ORG_SLUG}")
const deploy = readFileSync(new URL("profiles/convex/deploy.mjs", root), "utf8")
expect(deploy).toContain("await syncPlatformEnvironment({")
expect(deploy).toContain('adminKey: required("CONVEX_SELF_HOSTED_ADMIN_KEY")')
expect(deploy).toContain('"deploy", "--typecheck", "disable"')
const runtime = readFileSync(new URL("convex/lib/runtime.ts", root), "utf8")
expect(runtime).toContain('"filesActions:recoverUpload"')
})
})
// kit:guides:begin
// The deprecated deploy-time publish step of each profile (compose how-to §4a): a stack without
// guides.bundle hands the short-lived credential to the one service whose conusai.json entry declares
// `"guides": { "publish": true }`, and only when it is a one-shot job that is not public.
describe("the Guides publish step of each profile", () => {
const read = (path: string) => readFileSync(new URL(path, root), "utf8")
type Service = {
public?: unknown
guides?: { publish?: boolean }
restart?: string
build?: { dockerfile?: string; target?: string }
command?: string[]
depends_on?: Record<string, { condition?: string }>
}
const profiles = [
{
name: "postgres",
dir: "",
step: "migrate",
entry: "apps/worker/src/migrate.ts",
runs: "apps/worker/dist/migrate.js",
},
{
name: "convex",
dir: "profiles/convex/",
step: "convex-deploy",
entry: "profiles/convex/deploy.mjs",
runs: "profiles/convex/deploy.mjs",
},
] as const
for (const profile of profiles) {
it(`${profile.name}: fresh default-off requests no publisher and web waits for the private one-shot`, () => {
const config = JSON.parse(read(`${profile.dir}conusai.json`)) as { services: Record<string, Service> }
const declared = Object.entries(config.services).filter(([, service]) => service.guides?.publish === true)
expect(declared.map(([name]) => name)).toEqual([])
// Selection grants no Cloud readiness; the packed matrix separately checks explicit-on code guides.
expect(config.services[profile.step]?.public).toBeUndefined()
const composeText = read(`${profile.dir}compose.yaml`)
const compose = parse(composeText) as { services: Record<string, Service> }
const step = compose.services[profile.step]
expect(step?.restart).toBe("no")
expect(step?.build).toMatchObject({ dockerfile: "Dockerfile", target: "runtime" })
expect(step?.command).toEqual(["node", profile.runs])
expect(compose.services.web?.depends_on?.[profile.step]).toEqual({ condition: "service_completed_successfully" })
expect(composeText).not.toMatch(/CONUSAI_GUIDES_(PUBLISH_TOKEN|DEPLOYMENT)/)
})
it(`${profile.name}: the step can publish the code guides and the image's anchor catalogue`, () => {
const source = read(profile.entry)
expect(source).toContain("publishGuidesAtDeploy")
for (const name of ["CONUSAI_API_URL", "CONUSAI_GUIDES_PUBLISH_TOKEN", "CONUSAI_GUIDES_DEPLOYMENT"]) {
expect(source).toContain(`${name}: process.env.${name}`)
}
const relative = /new URL\("([^"]*guides-anchors\.json)", import\.meta\.url\)/.exec(source)?.[1]
expect(relative, `${profile.entry} reads no guides-anchors.json`).toBeDefined()
expect(new URL(relative as string, new URL(`file:///app/${profile.runs}`)).pathname).toBe(
"/app/guides-anchors.json",
)
})
}
it("the image both steps run writes the anchor catalogue with kit guides:anchors at /app", () => {
const dockerfile = read("Dockerfile")
const build = dockerfile.slice(
dockerfile.indexOf("FROM deps AS build"),
dockerfile.indexOf("FROM node:24-slim AS runtime"),
)
expect(build).toMatch(/^WORKDIR \/app$/m)
expect(build).toMatch(/^RUN pnpm kit guides:anchors --out guides-anchors\.json/m)
expect(build.indexOf("guides:anchors")).toBeGreaterThan(build.indexOf("pnpm -r run build"))
expect(build.indexOf("guides:anchors")).toBeLessThan(build.indexOf("pnpm prune --prod"))
expect(dockerfile).toContain("COPY --from=build --chown=root:root /app /app")
})
})
// kit:guides:end
describe("the account module on the Convex profile", () => {
it("binds a store whose every method is one internal query (a read) or mutation (a change)", async () => {
const { ACCOUNT_STORE_READS, ACCOUNT_STORE_WRITES, convexAccountStore } = await import("../convex/lib/effects")
const { createAccountStore } = await import("../modules/account/index.js")
const methods = Object.keys(createAccountStore({ data: {} as never, system: {} as never })).sort()
expect([...ACCOUNT_STORE_READS, ...ACCOUNT_STORE_WRITES].sort()).toEqual(methods)
const calls: Array<[string, string, unknown]> = []
const record = (kind: string) => async (reference: unknown, args: unknown) => {
calls.push([kind, getFunctionName(reference as never), args])
return null
}
const store = convexAccountStore({
auth: { getUserIdentity: async () => null },
runQuery: record("query") as never,
runMutation: record("mutation") as never,
})
await store.deletion("user-1")
await store.identities("user-1")
await store.changeDeletion("user-1", { kind: "step", step: "credentials" })
expect(calls).toEqual([
["query", "account:storeRead", { method: "deletion", args: ["user-1"] }],
["query", "account:storeRead", { method: "identities", args: ["user-1"] }],
[
"mutation",
"account:storeWrite",
{ method: "changeDeletion", args: ["user-1", { kind: "step", step: "credentials" }] },
],
])
})
it("brings a backend refusal back as the kernel's own problem", async () => {
const { convexAccountStore } = await import("../convex/lib/effects")
const store = convexAccountStore({
auth: { getUserIdentity: async () => null },
runQuery: (async () => {
throw new ConvexError({ type: "/problems/not_found", title: "Not found", status: 404, code: "not_found" })
}) as never,
runMutation: (async () => null) as never,
})
const error = await store.deletion("user-1").then(
() => null,
(thrown: unknown) => thrown,
)
expect(error).toBeInstanceOf(AppError)
expect((error as AppError).status).toBe(404)
})
it("binds the real platform privacy adapter in account actions", async () => {
const { effectContainer } = await import("../convex/lib/effects")
// The token the deployment binds: effects.ts resolves the built modules, and a token is
// matched by identity, so the source module's token would be a different one.
const { ACCOUNT_PRIVACY } = await import("../modules/dist/account/index.js")
const env = { ...process.env }
const calls: string[] = []
const response = {
id: "00000000-0000-4000-8000-000000000001",
kind: "erase",
status: "completed",
stores: {},
created_at: "2026-10-28T00:00:00Z",
}
const fetchMock = async (url: string | URL | Request) => {
calls.push(String(url))
return new Response(JSON.stringify(response), { status: 202 })
}
process.env.CONUSAI_API_URL = "https://api.example.test/api"
process.env.CONUSAI_API_TOKEN = "server-only-token"
delete process.env.CONUSAI_FAKE_PLATFORM
try {
const originalFetch = globalThis.fetch
globalThis.fetch = fetchMock as typeof fetch
try {
const container = effectContainer({
auth: { getUserIdentity: async () => null },
runQuery: (async () => null) as never,
runMutation: (async () => null) as never,
})
await container.resolve(ACCOUNT_PRIVACY).eraseSubject({ userId: "user-1" })
} finally {
globalThis.fetch = originalFetch
}
} finally {
process.env = env
}
expect(calls).toEqual(["https://api.example.test/api/v1/privacy/requests"])
})
it("reads the account credential from the BFF's identity claims, and nothing from an absent one", async () => {
const { accountCredentialOf } = await import("../convex/lib/runtime")
const identity = (claims: Record<string, unknown>) =>
({ issuer: "https://conusai.internal/bff", subject: "u", tokenIdentifier: "t", ...claims }) as never
expect(accountCredentialOf(null)).toEqual({ interactive: false })
expect(accountCredentialOf(identity({}))).toEqual({ interactive: false })
expect(accountCredentialOf(identity({ conusaiCredential: "interactive", conusaiSessionId: "s-1" }))).toEqual({
interactive: true,
sessionId: "s-1",
})
expect(
accountCredentialOf(
identity({ conusaiCredential: "interactive", conusaiSignedInAt: "2026-10-25T00:00:00.000Z" }),
),
).toEqual({ interactive: true, signedInAt: "2026-10-25T00:00:00.000Z" })
expect(accountCredentialOf(identity({ conusaiCredential: true, conusaiSessionId: "s-1" }))).toEqual({
interactive: false,
})
})
// kit:convex-better-auth:begin
it("installs the Better Auth deployment's variables in the backend only for a Better Auth app", () => {
const deploy = readFileSync(new URL("profiles/convex/deploy.mjs", root), "utf8")
expect(deploy).toContain(
'variables.push("AUTH_PROVIDER", "AUTH_IDENTITY_NAMESPACE", "BETTER_AUTH_CONVEX_URL", "BETTER_AUTH_CONVEX_ADMIN_KEY")',
)
})
// kit:convex-better-auth:end
})
/**
* The sign-out revocation outbox on the Convex profile (joint contract §14): the Postgres profile's
* own outbox (modules/account/core/revocation.ts) on Convex storage. The web records a revoked app
* session through the internal action `account:revokeSession`, which writes the row and delivers it
* at once from the backend; what a platform did not confirm the one-minute cron `account:revocationSweep`
* retries with the row's backoff, once per module, until 65 minutes have passed. Here the backend
* functions are the very store the Convex functions run, over a memory data source in place of ctx.db.
*/
describe("the revocation outbox on the Convex profile", () => {
it("keeps the outbox in the backend: every store method is one internal query or mutation", async () => {
const { REVOCATION_STORE_READS, REVOCATION_STORE_WRITES, convexRevocationStore } = await import(
"../convex/lib/effects"
)
const { createRevocationStore } = await import("../modules/account/index.js")
expect([...REVOCATION_STORE_READS, ...REVOCATION_STORE_WRITES].sort()).toEqual(
Object.keys(createRevocationStore({} as never)).sort(),
)
const calls: Array<[string, string, unknown]> = []
const record = (kind: string) => async (reference: unknown, args: unknown) => {
calls.push([kind, getFunctionName(reference as never), args])
return kind === "query" ? [] : null
}
const store = convexRevocationStore({
auth: { getUserIdentity: async () => null },
runQuery: record("query") as never,
runMutation: record("mutation") as never,
})
await store.due("2026-10-26T00:00:00.000Z", 10)
await store.purge("2026-10-25T00:00:00.000Z")
expect(calls).toEqual([
["query", "account:revocationRead", { method: "due", args: ["2026-10-26T00:00:00.000Z", 10] }],
["mutation", "account:revocationWrite", { method: "purge", args: ["2026-10-25T00:00:00.000Z"] }],
])
})
it("binds the device session a derived service session is minted for, from the BFF's identity", async () => {
const { requestSessionOf } = await import("../convex/lib/runtime")
const { effectContainer } = await import("../convex/lib/effects")
const { REQUEST_SESSION } = await import("@conusai-kit/tenancy")
const identity = (claims: Record<string, unknown>) =>
({ issuer: "https://conusai.internal/bff", subject: "u", tokenIdentifier: "t", ...claims }) as never
const fake = process.env.CONUSAI_FAKE_PLATFORM
process.env.CONUSAI_FAKE_PLATFORM = "1"
expect(requestSessionOf(null)).toBeNull()
expect(requestSessionOf(identity({}))).toBeNull()
expect(requestSessionOf(identity({ conusaiIdentitySession: 7 }))).toBeNull()
expect(requestSessionOf(identity({ conusaiIdentitySession: "4:oidc:36:s-1" }))).toBe("4:oidc:36:s-1")
const ctx = {
auth: { getUserIdentity: async () => null },
runQuery: (async () => null) as never,
runMutation: (async () => null) as never,
}
expect(effectContainer(ctx, { interactive: true }, "4:oidc:36:s-1").resolve(REQUEST_SESSION)).toBe("4:oidc:36:s-1")
expect(effectContainer(ctx).resolve(REQUEST_SESSION)).toBeNull()
if (fake === undefined) delete process.env.CONUSAI_FAKE_PLATFORM
else process.env.CONUSAI_FAKE_PLATFORM = fake
// The BFF names the device session of every call it makes for a signed-in person.
const caller = readFileSync(new URL("apps/web/src/server/convex-caller.ts", root), "utf8")
expect(caller).toContain("conusaiIdentitySession: options.identitySession")
const request = readFileSync(new URL("apps/web/src/server/request.ts", root), "utf8")
expect(request).toContain("identitySession: sessionRefKey(session.sessionRef)")
})
it("records a sign-out through an internal action and retries it on a one-minute cron", () => {
const account = readFileSync(new URL("convex/account.ts", root), "utf8")
for (const name of ["revocationRead = internalQuery", "revocationWrite = internalMutation"]) {
expect(account).toContain(`export const ${name}`)
}
expect(account).toContain("export const revokeSession = internalAction")
expect(account).toContain("export const revocationSweep = internalAction")
const crons = readFileSync(new URL("convex/crons.ts", root), "utf8")
expect(crons).toContain(
'crons.interval("account session revocations", { minutes: 1 }, internal.account.revocationSweep, {})',
)
// The web asks the backend's outbox, and falls back to the modules only when nothing was recorded.
const app = readFileSync(new URL("apps/web/src/server/app.ts", root), "utf8")
expect(app).toContain("durable: (session: string) => revokeConvexSession(convex, session)")
})
// kit:support:begin
describe("tenancy's share of an account, from an action", () => {
// An action has no database: tenancy's own share runs in the deployment's functions, as the account
// store's methods do (convex/account.ts), so a Convex export and deletion do not hold at it.
it("is read and erased by internal functions, and every other share is the module's own", async () => {
const { effectContainer } = await import("../convex/lib/effects")
const { accountSharesOf } = await import("../modules/dist/account/index.js")
const { accountDataOf } = await import("@conusai-kit/tenancy")
const { registry } = await import("../convex/lib/runtime")
const calls: unknown[] = []
const answer = (result: unknown) => async (reference: unknown, input: unknown) => {
calls.push([getFunctionName(reference as never), input])
return result
}
const ctx = {
auth: { getUserIdentity: async () => null },
runQuery: answer({ customRoles: [], projectMemberships: [], rolesAuthored: [] }) as never,
runMutation: answer(null) as never,
}
const fake = process.env.CONUSAI_FAKE_PLATFORM
process.env.CONUSAI_FAKE_PLATFORM = "1"
try {
const container = effectContainer(ctx)
const shares = accountSharesOf(container)
expect(shares.map(({ key }) => key)).toEqual(accountDataOf(registry).map(({ key }) => key))
const tenancy = shares.find(({ key }) => key === "tenancy")
await expect(tenancy?.export?.({ subject: "ada", container })).resolves.toMatchObject({ rolesAuthored: [] })
await tenancy?.erase?.({ subject: "ada", container })
expect(calls).toEqual([
["account:tenancyShareRead", { subject: "ada" }],
["account:tenancyShareWrite", { subject: "ada" }],
])
} finally {
if (fake === undefined) delete process.env.CONUSAI_FAKE_PLATFORM
else process.env.CONUSAI_FAKE_PLATFORM = fake
}
})
it("is what convex/account.ts runs in those functions, over this function's ctx.db", () => {
const account = readFileSync(new URL("convex/account.ts", root), "utf8")
for (const name of ["tenancyShareRead = internalQuery", "tenancyShareWrite = internalMutation"]) {
expect(account).toContain(`export const ${name}`)
}
})
})
describe("delivered from an action's container, against the fake platform", () => {
const START = Date.parse("2026-10-25T10:00:00.000Z")
type Platform = Awaited<ReturnType<typeof import("@conusai-kit/testing/fake-platform").startFakePlatform>>
type Clock = { at(ms: number): void }
/** One test against a fresh fake platform, the backend's deployment variables and a fixed clock. */
async function withPlatform(run: (platform: Platform, clock: Clock) => Promise<void>): Promise<void> {
const { vi } = await import("vitest")
const { startFakePlatform } = await import("@conusai-kit/testing/fake-platform")
const env = { ...process.env }
const platform = await startFakePlatform()
process.env.CONUSAI_API_URL = platform.url
process.env.CONUSAI_API_TOKEN = "cnsi_test"
process.env.CONUSAI_SUPPORT_CHANNEL = "web"
delete process.env.CONUSAI_FAKE_PLATFORM
vi.useFakeTimers({ toFake: ["Date"] })
vi.setSystemTime(START)
try {
await run(platform, { at: (ms) => vi.setSystemTime(START + ms) })
} finally {
vi.useRealTimers()
process.env = env
await platform.close()
}
}
/** The backend: `account:revocationRead`/`revocationWrite` over one store, as convex/account.ts runs them. */
async function backend() {
const { createMemoryDataSource } = await import("@conusai-kit/data")
const { accountSessionRevocations, createRevocationStore } = await import("../modules/account/index.js")
const data = createMemoryDataSource({ entities: [accountSessionRevocations] })
const store = createRevocationStore(data)
const run = (expected: string) => async (reference: unknown, input: unknown) => {
const name = getFunctionName(reference as never)
if (name !== expected) throw new Error(`unexpected backend call ${name}`)
const { method, args } = input as { method: keyof typeof store; args: unknown[] }
const call = store[method] as (...values: unknown[]) => Promise<unknown>
return JSON.parse(JSON.stringify((await call.apply(store, args)) ?? null))
}
const ctx = {
auth: { getUserIdentity: async () => null },
runQuery: run("account:revocationRead") as never,
runMutation: run("account:revocationWrite") as never,
}
const rows = async () => (await data.global(accountSessionRevocations).list({ limit: 50 })).items
return { ctx, rows }
}
const revocationsOf = async (platform: Platform, session: string) => {
const response = await fetch(new URL("/__e2e/state", platform.url), {
headers: { "x-conusai-organization": "e2e" },
})
const state = (await response.json()) as {
support: Array<{ route: string; body?: { identity_session?: string } }>
}
return state.support.filter((call) => call.route === "revoke" && call.body?.identity_session === session)
}
it("a platform down at sign-out gets the revocation once it is back, and nothing twice", () =>
withPlatform(async (platform, clock) => {
const { revokeSessionInBackend, sweepRevocationsInBackend } = await import("../convex/lib/effects")
const { ctx, rows } = await backend()
const session = "18:oidc:0199abcd-1234-7000-8000-00000000c001"
platform.outage("/v1/support/", true)
// The sign-out is not blocked: the action answers once the row is written.
await expect(revokeSessionInBackend(ctx, session)).resolves.toBeUndefined()
expect((await rows())[0]).toMatchObject({ session, state: "pending", pending: ["support"], attempts: 1 })
expect(await revocationsOf(platform, session)).toEqual([])
// Due, still down: the cron's run fails (the line an operator is alerted on), the row waits longer.
clock.at(31_000)
await expect(sweepRevocationsInBackend(ctx)).rejects.toMatchObject({ code: "account_revocations_waiting" })
expect((await rows())[0]).toMatchObject({ state: "pending", attempts: 2 })
platform.outage("/v1/support/", false)
clock.at(31_000 + 61_000)
await sweepRevocationsInBackend(ctx)
expect((await rows())[0]).toMatchObject({ state: "delivered", pending: [], delivered: ["support"] })
expect(await revocationsOf(platform, session)).toHaveLength(1)
clock.at(20 * 60_000)
await sweepRevocationsInBackend(ctx)
expect(await revocationsOf(platform, session)).toHaveLength(1)
// A second notification of the same revocation writes nothing and asks nobody.
await revokeSessionInBackend(ctx, session)
expect(await rows()).toHaveLength(1)
expect(await revocationsOf(platform, session)).toHaveLength(1)
}))
it("delivers at once when the platform is up", () =>
withPlatform(async (platform) => {
const { revokeSessionInBackend } = await import("../convex/lib/effects")
const { ctx, rows } = await backend()
const session = "18:oidc:0199abcd-1234-7000-8000-00000000c002"
await revokeSessionInBackend(ctx, session)
expect((await rows())[0]).toMatchObject({ state: "delivered", pending: [] })
expect(await revocationsOf(platform, session)).toHaveLength(1)
}))
it("lapses after 65 minutes, when no widget session it minted can still be alive", () =>
withPlatform(async (platform, clock) => {
const { revokeSessionInBackend, sweepRevocationsInBackend } = await import("../convex/lib/effects")
const { ctx, rows } = await backend()
const session = "18:oidc:0199abcd-1234-7000-8000-00000000c003"
platform.outage("/v1/support/", true)
await revokeSessionInBackend(ctx, session)
platform.outage("/v1/support/", false)
clock.at(66 * 60_000)
await sweepRevocationsInBackend(ctx)
expect((await rows())[0]).toMatchObject({ state: "lapsed" })
expect(await revocationsOf(platform, session)).toEqual([])
}))
})
// kit:support:end
})
// Exercise this closure with bounded, explicit optional admission.
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
...(await import("./support/native-readiness.js")).selectedModules(["documents", "guides", "support"]),
}))
vi.mock("../modules/dist/inclusion.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../modules/dist/inclusion.js")>()),
...(await import("./support/native-readiness.js")).selectedModules(["documents", "guides", "support"]),
}))