467 lines
24 KiB
TypeScript
467 lines
24 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
|
|
import { readFileSync } from "node:fs"
|
|
import { AppError, buildRegistry } from "@conusai-kit/core"
|
|
import { generateConvex } from "@conusai-kit/data-convex"
|
|
import { getFunctionName } from "convex/server"
|
|
import { ConvexError } from "convex/values"
|
|
import { describe, expect, it } from "vitest"
|
|
import { parse } from "yaml"
|
|
import { projectEntriesFor } from "../apps/web/src/ui/projects.js"
|
|
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", () => {
|
|
it("commits generated wrappers that forward explicit idempotency keys to the kernel", () => {
|
|
// The same profile filter as below: the Postgres-only modules are not Convex-deployed yet.
|
|
const convexBound = appModules.filter(
|
|
(module) => module.profiles === undefined || module.profiles.includes("convex"),
|
|
)
|
|
const registry = buildRegistry([...convexModules(convexBound)])
|
|
const generated = generateConvex(registry, {
|
|
header: "// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n",
|
|
})
|
|
for (const file of generated) {
|
|
expect(readFileSync(new URL(`convex/${file.path}`, root), "utf8"), file.path).toBe(file.content)
|
|
}
|
|
const proposal = generated.find(({ path }) => path === "generated/proposals.ts")
|
|
expect(proposal?.content).toContain(
|
|
'ctx.run("proposals.proposal.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey })',
|
|
)
|
|
})
|
|
|
|
it("keeps the documents review route visible in the Convex project navigation", () => {
|
|
expect(projectEntriesFor("convex")).toContainEqual({
|
|
href: "documents",
|
|
label: "documents.title",
|
|
module: "documents",
|
|
})
|
|
})
|
|
|
|
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)
|
|
const closure = ["tenancy", "auth", "files", "proposals"]
|
|
closure.push("guides") // kit:guides -- its effect procedures are generated as Convex actions
|
|
closure.push("support") // kit:support -- its effect procedures are generated as Convex actions
|
|
expect(modules.map(({ id }) => id)).toEqual([...closure, "branding", "documents", "notes", "account"])
|
|
// 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"]
|
|
// 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",
|
|
)
|
|
expect(bff.find(({ id }) => id === "documents")?.procedures?.map(({ name }) => name)).toContain(
|
|
"documents.document.get",
|
|
)
|
|
})
|
|
|
|
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}")
|
|
expect(Object.keys(environment).some((key) => key.startsWith("NEXT_PUBLIC_") && 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('"env", "set", "--from-file", envFile')
|
|
expect(deploy).toContain("mode: 0o600")
|
|
expect(deploy).toContain('"deploy", "--typecheck", "disable"')
|
|
const runtime = readFileSync(new URL("convex/lib/runtime.ts", root), "utf8")
|
|
expect(runtime).toContain('"filesActions:recoverUpload"')
|
|
})
|
|
})
|
|
|
|
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("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("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
|
|
})
|