638 lines
31 KiB
TypeScript
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"]),
|
|
}))
|