@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
//
|
||||
// The coding-agent contract this template ships: CLAUDE.md imports AGENTS.md instead of repeating it, the
|
||||
// committed Claude Code settings keep secrets unreadable and a push behind a person, the support-report skill is
|
||||
// readable by its tool, and the format-on-edit hook formats only this repository's own source files.
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { fileToFormat } from "../scripts/agent-format.mjs"
|
||||
|
||||
const APP_ROOT = new URL("..", import.meta.url).pathname
|
||||
const read = (path: string) => readFileSync(join(APP_ROOT, path), "utf8")
|
||||
|
||||
const made: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("the coding-agent contract", () => {
|
||||
it("has CLAUDE.md import AGENTS.md, the one source of the rules", () => {
|
||||
expect(read("CLAUDE.md")).toMatch(/^@AGENTS\.md$/m)
|
||||
expect(read("AGENTS.md")).toContain("## Work that starts from a support report")
|
||||
})
|
||||
|
||||
it("commits Claude Code settings that refuse .env reads and ask before a push or a migration run", () => {
|
||||
const settings = JSON.parse(read(".claude/settings.json"))
|
||||
expect(settings.permissions.deny).toEqual(expect.arrayContaining(["Read(/.env)", "Read(/.env.local)"]))
|
||||
expect(settings.permissions.ask).toEqual(expect.arrayContaining(["Bash(git push *)", "Bash(pnpm migrate *)"]))
|
||||
expect(settings.permissions.allow).toContain("Bash(pnpm gate)")
|
||||
// Nothing that publishes or deploys is pre-approved.
|
||||
for (const rule of settings.permissions.allow) expect(rule).not.toMatch(/push|migrate|deploy/)
|
||||
expect(read(".gitignore")).toContain(".claude/settings.local.json")
|
||||
})
|
||||
|
||||
it("ships the support-report skill with the frontmatter its tool reads first", () => {
|
||||
const skill = read(".claude/skills/fix-support-report/SKILL.md")
|
||||
expect(skill.startsWith("---\nname: fix-support-report\ndescription: ")).toBe(true)
|
||||
expect(skill).toMatch(/Reproduce it before changing code/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the format-on-edit hook", () => {
|
||||
it("names a repository source file Biome formats, and nothing else", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "agent-format-"))
|
||||
made.push(root)
|
||||
writeFileSync(join(root, "a.ts"), "export const a = 1\n")
|
||||
writeFileSync(join(root, "notes.md"), "# notes\n")
|
||||
expect(fileToFormat({ tool_input: { file_path: join(root, "a.ts") } }, root)).toBe("a.ts")
|
||||
expect(fileToFormat({ tool_input: { file_path: "a.ts" } }, root)).toBe("a.ts")
|
||||
expect(fileToFormat({ tool_input: { file_path: "notes.md" } }, root)).toBeNull()
|
||||
expect(fileToFormat({ tool_input: { file_path: "missing.ts" } }, root)).toBeNull()
|
||||
expect(fileToFormat({ tool_input: { file_path: "../outside.ts" } }, root)).toBeNull()
|
||||
expect(fileToFormat({ tool_input: {} }, root)).toBeNull()
|
||||
expect(fileToFormat(null, root)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// `agent.run.events` through the kernel's caller, the way the HTTP mount calls it: a project's
|
||||
// reader gets the run's AG-UI events as Server-Sent Events, resumes strictly after an offset, and
|
||||
// the stream closes after the run's terminal event; another organization's run is not found, as
|
||||
// `agent.run.status` answers, and an offset of another run is refused.
|
||||
|
||||
import { AppError, type TransportCaller } from "@conusai-kit/core"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { makeNotesApp, type NotesApp, type SeededProject } from "./support/notes-app.js"
|
||||
|
||||
const PLAN = [{ key: "read", label: "Read", tool: "documents.read", args: { fileId: "f-1" } }]
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return error
|
||||
throw error
|
||||
}
|
||||
throw new Error("expected a refusal")
|
||||
}
|
||||
|
||||
interface Frame {
|
||||
id: string | undefined
|
||||
data: { type: string; [field: string]: unknown }
|
||||
}
|
||||
|
||||
const framesOf = (text: string): Frame[] =>
|
||||
text
|
||||
.split("\n\n")
|
||||
.filter((frame) => frame.trim() !== "" && !frame.startsWith(":"))
|
||||
.map((frame) => ({
|
||||
id: /^id: (.*)$/m.exec(frame)?.[1],
|
||||
data: JSON.parse(/^data: (.*)$/m.exec(frame)?.[1] ?? "null") as Frame["data"],
|
||||
}))
|
||||
|
||||
/** The first chunk of a stream that is still open (a run that has not ended), then the stream is let go. */
|
||||
async function firstChunk(response: Response): Promise<string> {
|
||||
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
||||
const { value } = await reader.read()
|
||||
await reader.cancel()
|
||||
return new TextDecoder().decode(value)
|
||||
}
|
||||
|
||||
async function startRun(app: NotesApp, project: SeededProject) {
|
||||
const started = (await project.call.callByName("agent.run.start", {
|
||||
...project.slugs,
|
||||
goal: "Review",
|
||||
plan: PLAN,
|
||||
})) as {
|
||||
runId: string
|
||||
}
|
||||
// The resume event names the run's project by id: what the engine would write under.
|
||||
const record = (await app.data.outbox.pending(50)).find(
|
||||
(entry) =>
|
||||
entry.name === "agent.runResumeRequested" && (entry.payload as { runId: string }).runId === started.runId,
|
||||
)
|
||||
const ids = record?.payload as { orgId: string; workspaceId: string; projectId: string }
|
||||
const scope = { level: "project" as const, actor: project.owner, ...ids }
|
||||
/** Ends the run as the engine would. */
|
||||
const finish = async () => {
|
||||
const entity = app.entities.find((candidate) => candidate.table === "agent_run")
|
||||
if (entity?.tenancy !== "project") throw new Error("the agent module declares no project run table")
|
||||
const scoped = app.data.forScope(scope)
|
||||
const table = scoped.of(entity as Parameters<typeof scoped.of>[0])
|
||||
const row = (await table.get(started.runId)) as { version: number } | null
|
||||
if (!row) throw new Error("the run is gone")
|
||||
await table.update(started.runId, { status: "cancelled" }, row.version)
|
||||
}
|
||||
return { runId: started.runId, finish }
|
||||
}
|
||||
|
||||
const events = (call: TransportCaller, input: Record<string, unknown>) =>
|
||||
call.callByName("agent.run.events", input) as Promise<Response>
|
||||
|
||||
describe("agent.run.events", () => {
|
||||
it("streams a reader the run's events, resumes after an offset and closes after the terminal event", async () => {
|
||||
const app = makeNotesApp()
|
||||
const project = await app.seedProject("acme", "owner@acme.test")
|
||||
const run = await startRun(app, project)
|
||||
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
|
||||
|
||||
const open = await events(viewer.call, { ...project.slugs, runId: run.runId })
|
||||
expect(open.headers.get("content-type")).toContain("text/event-stream")
|
||||
expect(open.headers.get("cache-control")).toBe("no-store")
|
||||
expect(framesOf(await firstChunk(open))).toEqual([
|
||||
{
|
||||
id: `agk1:${run.runId}:0`,
|
||||
data: { type: "RUN_STARTED", threadId: `agent-run:${run.runId}`, runId: run.runId },
|
||||
},
|
||||
])
|
||||
|
||||
await run.finish()
|
||||
const rest = framesOf(
|
||||
await (await events(viewer.call, { ...project.slugs, runId: run.runId, after: `agk1:${run.runId}:0` })).text(),
|
||||
)
|
||||
expect(rest).toEqual([
|
||||
{
|
||||
id: `agk1:${run.runId}:1`,
|
||||
data: {
|
||||
type: "RUN_FINISHED",
|
||||
threadId: `agent-run:${run.runId}`,
|
||||
runId: run.runId,
|
||||
outcome: { type: "success" },
|
||||
result: { status: "cancelled" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(await (await events(viewer.call, { ...project.slugs, runId: run.runId, after: "1" })).text()).toBe("")
|
||||
})
|
||||
|
||||
it("answers another organization's run as not found, and refuses another run's offset", async () => {
|
||||
const app = makeNotesApp()
|
||||
const acme = await app.seedProject("acme", "owner@acme.test")
|
||||
const globex = await app.seedProject("globex", "owner@globex.test")
|
||||
const theirs = await startRun(app, acme)
|
||||
const ours = await startRun(app, globex)
|
||||
|
||||
// Globex's member names Acme's run in Globex's own project: not found, as agent.run.status says.
|
||||
const asStatus = await refusal(globex.call.callByName("agent.run.status", { ...globex.slugs, runId: theirs.runId }))
|
||||
const asEvents = await refusal(events(globex.call, { ...globex.slugs, runId: theirs.runId }))
|
||||
expect(asEvents.status).toBe(404)
|
||||
expect(asEvents.code).toBe(asStatus.code)
|
||||
// ... and in Acme's project, of which they are no member: refused before anything is read.
|
||||
const outside = await refusal(events(globex.call, { ...acme.slugs, runId: theirs.runId }))
|
||||
expect([403, 404]).toContain(outside.status)
|
||||
|
||||
const foreignOffset = await refusal(
|
||||
events(globex.call, { ...globex.slugs, runId: ours.runId, after: `agk1:${theirs.runId}:0` }),
|
||||
)
|
||||
expect(foreignOffset.status).toBe(422)
|
||||
const malformed = await refusal(events(globex.call, { ...globex.slugs, runId: ours.runId, after: "../0" }))
|
||||
expect(malformed.status).toBe(422)
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
|
||||
...(await import("./support/native-readiness.js")).selectedModules(["agent"]),
|
||||
}))
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The one agent setting of the app (G3j): how many days a finished run is kept before the hourly sweep
|
||||
// deletes it. A whole number of days from 1 to 3650, 90 by default; anything else is refused with the name
|
||||
// of the variable, never its value.
|
||||
|
||||
import { agentRunRetentionDaysOf } from "@app/config"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("AGENT_RUN_RETENTION_DAYS", () => {
|
||||
it("is 90 days when unset or blank, and the value it is set to otherwise", () => {
|
||||
expect(agentRunRetentionDaysOf({})).toBe(90)
|
||||
expect(agentRunRetentionDaysOf({ AGENT_RUN_RETENTION_DAYS: " " })).toBe(90)
|
||||
expect(agentRunRetentionDaysOf({ AGENT_RUN_RETENTION_DAYS: " 30 " })).toBe(30)
|
||||
expect(agentRunRetentionDaysOf({ AGENT_RUN_RETENTION_DAYS: "1" })).toBe(1)
|
||||
expect(agentRunRetentionDaysOf({ AGENT_RUN_RETENTION_DAYS: "3650" })).toBe(3650)
|
||||
})
|
||||
|
||||
it.each(["0", "3651", "1.5", "-3", "ninety"])(
|
||||
"refuses %s with a message that names the variable and not the value",
|
||||
(value) => {
|
||||
// The message is fixed text: it cannot echo what was set, so it cannot leak a value into a log.
|
||||
expect(() => agentRunRetentionDaysOf({ AGENT_RUN_RETENTION_DAYS: value })).toThrow(
|
||||
new Error("AGENT_RUN_RETENTION_DAYS must be a whole number of days from 1 to 3650"),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The deployment contract per process (decision AT-D20): which Postgres role each process may
|
||||
// hold, and what happens when it is handed one it may not.
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { loadAppEnv, roleOf } from "../config/src/env.js"
|
||||
import { assertBoundByRowLevelSecurity, assertOwnsSchema } from "../config/src/postgres.js"
|
||||
|
||||
const base = {
|
||||
CONUSAI_API_URL: "https://api.example.test/api",
|
||||
CONUSAI_API_TOKEN: "token",
|
||||
CONUSAI_PROJECT_ID: "42",
|
||||
CONUSAI_ORG_ID: "9",
|
||||
CONUSAI_ORG_SLUG: "acme",
|
||||
POSTGRES_URL: "postgresql://app_app:secret@db:5432/app",
|
||||
POSTGRES_READER_URL: "postgresql://app_reader:secret@db:5432/app",
|
||||
}
|
||||
|
||||
describe("the deployment contract", () => {
|
||||
it("gives web and worker the runtime role and the global reader", () => {
|
||||
for (const service of ["web", "worker"] as const) {
|
||||
const env = loadAppEnv(service, { ...base })
|
||||
expect(env.postgres?.runtime).toContain("app_app")
|
||||
expect(env.postgres?.reader).toContain("app_reader")
|
||||
expect(env.postgres?.owner).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses to start web or worker with the owner's URL", () => {
|
||||
for (const service of ["web", "worker"] as const) {
|
||||
expect(() =>
|
||||
loadAppEnv(service, { ...base, POSTGRES_MIGRATE_URL: "postgresql://app_owner:s@db:5432/app" }),
|
||||
).toThrow(/POSTGRES_MIGRATE_URL is set for the \w+ process/)
|
||||
}
|
||||
})
|
||||
|
||||
it("requires the platform organization slug for production web and worker usage billing, but not migrate", () => {
|
||||
const { CONUSAI_ORG_SLUG: _slug, ...withoutSlug } = base
|
||||
for (const service of ["web", "worker"] as const) {
|
||||
expect(() => loadAppEnv(service, { ...withoutSlug })).toThrow(/CONUSAI_ORG_SLUG/)
|
||||
}
|
||||
expect(() =>
|
||||
loadAppEnv("migrate", {
|
||||
...withoutSlug,
|
||||
POSTGRES_MIGRATE_URL: "postgresql://app_owner:secret@db:5432/app",
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it("refuses the migrate job without the owner's URL, naming postgres.migrate", () => {
|
||||
expect(() => loadAppEnv("migrate", { ...base })).toThrow(/declares postgres\.migrate/)
|
||||
})
|
||||
|
||||
it("refuses the migrate job when two of the three URLs name one role", () => {
|
||||
expect(() => loadAppEnv("migrate", { ...base, POSTGRES_MIGRATE_URL: base.POSTGRES_URL })).toThrow(
|
||||
/three separate logins/,
|
||||
)
|
||||
})
|
||||
|
||||
it("names the Postgres service to link when a URL is missing", () => {
|
||||
const { POSTGRES_URL: _dropped, ...without } = base
|
||||
expect(() => loadAppEnv("web", without)).toThrow(/link a Postgres service/)
|
||||
})
|
||||
|
||||
it("refuses a runtime URL and a reader URL that name one login", () => {
|
||||
expect(() => loadAppEnv("web", { ...base, POSTGRES_READER_URL: base.POSTGRES_URL })).toThrow(/name the same role/)
|
||||
})
|
||||
|
||||
it("refuses a role name that is not a plain identifier", () => {
|
||||
expect(roleOf("postgresql://app_reader:s@db:5432/app")).toBe("app_reader")
|
||||
expect(() => roleOf("postgresql://app%2Dreader:s@db:5432/app")).toThrow(/not a plain identifier/)
|
||||
})
|
||||
|
||||
it("loads Convex server credentials and refuses missing or misplaced credentials", () => {
|
||||
const convex = {
|
||||
...base,
|
||||
CONVEX_SELF_HOSTED_URL: "http://convex:3210",
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY: "server-only-test-key",
|
||||
}
|
||||
const loaded = loadAppEnv("web", convex, "convex")
|
||||
expect(loaded.convex).toEqual({ url: "http://convex:3210", adminKey: "server-only-test-key" })
|
||||
expect(loaded).not.toHaveProperty("postgres")
|
||||
const { CONVEX_SELF_HOSTED_ADMIN_KEY: _key, ...missingKey } = convex
|
||||
expect(() => loadAppEnv("web", missingKey, "convex")).toThrow(/CONVEX_SELF_HOSTED_ADMIN_KEY/)
|
||||
expect(() => loadAppEnv("migrate", convex, "convex")).toThrow(/not supported by the Convex app runtime/)
|
||||
})
|
||||
|
||||
it("gives every process of the mariadb profile the one MARIADB_URL, and web and worker the queue's REDIS_URL", () => {
|
||||
const { POSTGRES_URL: _p, POSTGRES_READER_URL: _r, ...platform } = base
|
||||
const mariadb = {
|
||||
...platform,
|
||||
MARIADB_URL: "mysql://app:secret@mariadb:3306/app",
|
||||
DATABASE_URL: "mysql://app:secret@mariadb:3306/app",
|
||||
REDIS_URL: "redis://valkey:6379",
|
||||
}
|
||||
for (const service of ["web", "worker"] as const) {
|
||||
const loaded = loadAppEnv(service, mariadb, "mariadb")
|
||||
expect(loaded.mariadb).toEqual({ url: mariadb.MARIADB_URL })
|
||||
expect(loaded.redis).toEqual({ url: "redis://valkey:6379" })
|
||||
expect(loaded).not.toHaveProperty("postgres")
|
||||
const { REDIS_URL: _q, ...noQueue } = mariadb
|
||||
expect(() => loadAppEnv(service, noQueue, "mariadb")).toThrow(/REDIS_URL is not set/)
|
||||
}
|
||||
const migrate = loadAppEnv("migrate", { ...mariadb, REDIS_URL: "" }, "mariadb")
|
||||
expect(migrate.mariadb?.url).toBe(mariadb.MARIADB_URL)
|
||||
expect(migrate).not.toHaveProperty("redis")
|
||||
const { MARIADB_URL: _m, ...noStore } = mariadb
|
||||
expect(() => loadAppEnv("web", noStore, "mariadb")).toThrow(/MARIADB_URL is not set: link a MariaDB service/)
|
||||
})
|
||||
|
||||
it("gives every process of the mongodb profile the one MONGODB_URL and refuses one without it", () => {
|
||||
const { POSTGRES_URL: _p, POSTGRES_READER_URL: _r, ...platform } = base
|
||||
const mongodb = {
|
||||
...platform,
|
||||
MONGODB_URL: "mongodb://app:secret@mongodb:27017/app?directConnection=true",
|
||||
REDIS_URL: "redis://valkey:6379",
|
||||
}
|
||||
const web = loadAppEnv("web", mongodb, "mongodb")
|
||||
expect(web.mongodb).toEqual({ url: mongodb.MONGODB_URL })
|
||||
expect(web).not.toHaveProperty("mariadb")
|
||||
expect(loadAppEnv("migrate", mongodb, "mongodb")).not.toHaveProperty("redis")
|
||||
const { MONGODB_URL: _m, ...noStore } = mongodb
|
||||
expect(() => loadAppEnv("worker", noStore, "mongodb")).toThrow(/MONGODB_URL/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the role the database answers with", () => {
|
||||
const facts = {
|
||||
role: "app_app",
|
||||
ownedTables: 0,
|
||||
bypassesRowLevelSecurity: false,
|
||||
isSuperuser: false,
|
||||
ownsSchema: false,
|
||||
}
|
||||
|
||||
it("accepts a role that owns nothing and bypasses nothing", () => {
|
||||
expect(assertBoundByRowLevelSecurity(facts, "POSTGRES_URL")).toBe(facts)
|
||||
})
|
||||
|
||||
it("refuses a connection that owns a table, bypasses row-level security or is a superuser", () => {
|
||||
expect(() => assertBoundByRowLevelSecurity({ ...facts, ownedTables: 3 }, "POSTGRES_URL")).toThrow(/owns 3 table/)
|
||||
expect(() => assertBoundByRowLevelSecurity({ ...facts, bypassesRowLevelSecurity: true }, "POSTGRES_URL")).toThrow(
|
||||
/BYPASSRLS/,
|
||||
)
|
||||
expect(() => assertBoundByRowLevelSecurity({ ...facts, isSuperuser: true }, "POSTGRES_READER_URL")).toThrow(
|
||||
/superuser/,
|
||||
)
|
||||
})
|
||||
|
||||
it("refuses a migrate job that does not own schema public", () => {
|
||||
expect(() => assertOwnsSchema(facts, "POSTGRES_MIGRATE_URL")).toThrow(/does not own schema public/)
|
||||
expect(assertOwnsSchema({ ...facts, role: "app_owner", ownsSchema: true }, "POSTGRES_MIGRATE_URL").ownsSchema).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("names the variable whose connection is wrong", () => {
|
||||
expect(() => assertBoundByRowLevelSecurity({ ...facts, ownedTables: 1 }, "POSTGRES_READER_URL")).toThrow(
|
||||
/POSTGRES_READER_URL/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { afterEach, expect, it, vi } from "vitest"
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
/** The build's real configuration, evaluated with this environment. */
|
||||
const viteConfig = async () => {
|
||||
vi.resetModules()
|
||||
const { default: config } = await import("../apps/web/vite.config")
|
||||
return config as {
|
||||
base?: string
|
||||
envPrefix?: unknown
|
||||
define?: unknown
|
||||
build?: { assetsDir?: string; outDir?: string }
|
||||
}
|
||||
}
|
||||
|
||||
it("keeps compiled chunk URLs stable when the platform injects a deployment asset prefix", async () => {
|
||||
// Compose serves the image's assets itself. Vite writes its chunk URLs into the build, so a base read at
|
||||
// startup would leave the browser waiting for chunks under a name nothing serves: one image, every
|
||||
// environment, and no `base` in the build at all.
|
||||
vi.stubEnv("CONUSAI_ASSET_PREFIX", "/_conusai/assets/app-42")
|
||||
const config = await viteConfig()
|
||||
expect(config.base ?? "").toBe("")
|
||||
})
|
||||
|
||||
it("writes the build where server.mjs serves it from, under the folder RESERVED_SLUGS keeps from organizations", async () => {
|
||||
const config = await viteConfig()
|
||||
expect(config.build?.outDir).toBe("dist")
|
||||
// Vite's default; test/reserved-routes.test.ts reserves `assets` because of it.
|
||||
expect(config.build?.assetsDir ?? "assets").toBe("assets")
|
||||
})
|
||||
|
||||
it("hands the browser's bundle no variable: Vite's `VITE_` prefix stays the only way in, and nothing uses it", async () => {
|
||||
// A wider `envPrefix` or a `define` is how a server variable would reach a chunk (spec §6.4: the server
|
||||
// writes what the browser needs into the page, and there is no build-time public value).
|
||||
const config = await viteConfig()
|
||||
expect(config.envPrefix).toBeUndefined()
|
||||
expect(config.define).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The words for each sign-in failure's code. Which code a failure gets, and where it may redirect to,
|
||||
// are `@conusai-kit/host/postgres`'s, with their tests (kit-host `auth-errors.test.ts`).
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { AUTH_ERROR_MESSAGES, AUTH_ERRORS } from "../apps/web/src/server/auth-errors.js"
|
||||
import { appCatalogues } from "../apps/web/src/ui/messages.js"
|
||||
|
||||
describe("the words for each code", () => {
|
||||
it("exist in English and in Lithuanian for every code", () => {
|
||||
for (const code of AUTH_ERRORS) {
|
||||
const [module, ...rest] = (AUTH_ERROR_MESSAGES[code] as string).split(".")
|
||||
const key = rest.join(".")
|
||||
expect(appCatalogues.en?.[module as string]?.[key], `en ${code}`).toBeTruthy()
|
||||
expect(appCatalogues.lt?.[module as string]?.[key], `lt ${code}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The page's unload path (apps/web/src/ui/client.ts `beacon`): a call made as the page goes away is
|
||||
// a same-origin keep-alive POST to the procedure's mount with the page's CSRF token, and the
|
||||
// server's real CSRF wall admits exactly that request for this session and refuses it for another.
|
||||
// The browser half (keep-alive after a navigation and a closed tab) is proven in a real browser by
|
||||
// @conusai-kit/guides' Playwright suite (test-browser/beacon.spec.ts).
|
||||
|
||||
import type { Principal } from "@conusai-kit/auth"
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
const ORIGIN = "https://app.example.test"
|
||||
const session = (sessionId: string): Principal => ({
|
||||
actor: { userId: "11111111-1111-1111-1111-111111111111" },
|
||||
method: "session",
|
||||
sessionId,
|
||||
})
|
||||
|
||||
let csrfToken: (sessionId: string) => string
|
||||
let requireCsrf: (request: Request, principal: Principal | null) => void
|
||||
let beacon: (procedure: string, input: unknown) => boolean
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.AUTH_SESSION_SECRET = "0123456789abcdef0123456789abcdef"
|
||||
process.env.CONUSAI_PUBLIC_URL = ORIGIN
|
||||
process.env.OIDC_ISSUER = "https://id.example.test"
|
||||
process.env.OIDC_WEB_CLIENT_ID = "web"
|
||||
process.env.OIDC_AUDIENCE = "api"
|
||||
const module = await import("@conusai-kit/host/http")
|
||||
csrfToken = module.csrfToken
|
||||
requireCsrf = module.requireCsrf
|
||||
// Imported before any page global is stubbed: the beacon reads the page at call time, and the UI
|
||||
// kit's dependencies set up browser listeners on import when they see a window.
|
||||
beacon = (await import("../apps/web/src/ui/client")).beacon
|
||||
})
|
||||
|
||||
const globals = globalThis as unknown as { window?: unknown; document?: unknown; fetch: typeof fetch }
|
||||
const realFetch = globals.fetch
|
||||
afterEach(() => {
|
||||
globals.fetch = realFetch
|
||||
delete globals.window
|
||||
delete globals.document
|
||||
})
|
||||
|
||||
/** A page of this app holding `token` in its CSRF meta tag; returns what the page's fetch was handed. */
|
||||
function page(token: string): Array<[URL, RequestInit]> {
|
||||
const sent: Array<[URL, RequestInit]> = []
|
||||
globals.window = { location: { href: `${ORIGIN}/acme/main/notes/notes`, origin: ORIGIN } }
|
||||
globals.document = {
|
||||
querySelector: (selector: string) =>
|
||||
selector === 'meta[name="csrf-token"]'
|
||||
? { getAttribute: (name: string) => (name === "content" ? token : null) }
|
||||
: null,
|
||||
}
|
||||
globals.fetch = ((url: URL, init: RequestInit) => {
|
||||
sent.push([url, init])
|
||||
return Promise.resolve(new Response(null, { status: 200 }))
|
||||
}) as typeof fetch
|
||||
return sent
|
||||
}
|
||||
|
||||
describe("the page's beacon", () => {
|
||||
it("is a same-origin keep-alive POST to the procedure that the CSRF wall admits for this session only", () => {
|
||||
const sent = page(csrfToken("session-1"))
|
||||
const input = {
|
||||
org: "acme",
|
||||
idempotencyKey: "guides:1",
|
||||
events: [{ schema_version: 1, event_id: "e1", type: "track" }],
|
||||
}
|
||||
expect(beacon("guides.runtime.record", input)).toBe(true)
|
||||
expect(sent).toHaveLength(1)
|
||||
const [url, init] = sent[0] as [URL, RequestInit]
|
||||
expect(url.href).toBe(`${ORIGIN}/api/v1/guides.runtime.record`)
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
// What the server receives: the page's headers, plus the fetch metadata the browser adds to a
|
||||
// request of this page's own origin.
|
||||
const request = new Request(url, {
|
||||
method: "POST",
|
||||
headers: { ...(init.headers as Record<string, string>), "sec-fetch-site": "same-origin" },
|
||||
body: init.body ?? null,
|
||||
})
|
||||
expect(() => requireCsrf(request, session("session-1"))).not.toThrow()
|
||||
expect(() => requireCsrf(request, session("session-2"))).toThrow(/x-csrf-token/)
|
||||
})
|
||||
|
||||
it("is declined when the body would not fit the keep-alive budget, so the engine keeps the batch", () => {
|
||||
const sent = page(csrfToken("session-1"))
|
||||
expect(beacon("guides.runtime.record", { padding: "x".repeat(70_000) })).toBe(false)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the browser's half of the API path", () => {
|
||||
it("types a call by its contract's own schemas", async () => {
|
||||
// A compile-time property, checked here by construction: `callContract` takes the contract's
|
||||
// input type and answers its output type. If either drifted, this file would not typecheck.
|
||||
const { callContract } = await import("../apps/web/src/ui/client")
|
||||
const contract = { name: "notes.note.create", input: { _input: { title: "" } }, output: { _output: { id: "" } } }
|
||||
expect(typeof callContract).toBe("function")
|
||||
expect(contract.name).toBe("notes.note.create")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,400 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex Better Auth adapter of this app (AUTH_PROVIDER=convex-better-auth, joint contract §14),
|
||||
// over fake transports: the deployment contract, the sign-out (derived service sessions first, then
|
||||
// Better Auth, then every cookie), the account's end and the deletion's Better Auth step bound in the
|
||||
// web and the worker, the relay's account events, the form's own password rule, and the locally
|
||||
// installed component schema with the admin plugin's fields, which is
|
||||
// generated from the app's Better Auth options. A real Better Auth session on the platform's pinned
|
||||
// self-hosted Convex runtime, in a browser, is e2e/better-auth (gate J-auth); this file proves no
|
||||
// live integration. `kit template:prune --auth oidc` removes it.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { loadAppEnv, platformClients } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import { ACCOUNT_IDENTITY_PROVIDER } from "@app/modules/account"
|
||||
import { buildRegistry, Container } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, entitiesOf } from "@conusai-kit/data"
|
||||
import { requireAppUser, tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
||||
import { convexAdapter } from "@convex-dev/better-auth"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { parse } from "yaml"
|
||||
import { bindModulePorts } from "../apps/worker/src/ports.js"
|
||||
import { betterAuthOptions, MIN_PASSWORD_LENGTH as OPTIONS_MIN } from "../better-auth/functions/betterAuth/options"
|
||||
|
||||
// The composition root is not under test here; better-auth.ts reaches it only to build its singleton.
|
||||
vi.mock("../apps/web/src/server/app", () => ({
|
||||
webRuntime: () => {
|
||||
throw new Error("not in this test")
|
||||
},
|
||||
sessionRevoked: async () => undefined,
|
||||
}))
|
||||
|
||||
const {
|
||||
betterAuthEnv,
|
||||
betterAuthFormLabels,
|
||||
bindBetterAuthAccountEnd,
|
||||
createBetterAuthRuntime,
|
||||
deliverAccountEvent,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} = await import("../apps/web/src/server/better-auth")
|
||||
|
||||
const ENV = {
|
||||
BETTER_AUTH_CONVEX_URL: "http://cloud.test",
|
||||
BETTER_AUTH_CONVEX_SITE_URL: "http://site.test/",
|
||||
BETTER_AUTH_CONVEX_ADMIN_KEY: "admin-key-of-the-test",
|
||||
BETTER_AUTH_RELAY_INTERVAL_MS: "60000",
|
||||
CONUSAI_PUBLIC_URL: "http://app.test",
|
||||
}
|
||||
const config = { provider: "convex-better-auth" as const, identityNamespace: "better-auth:test" }
|
||||
|
||||
describe("the Better Auth deployment contract", () => {
|
||||
it("reads the deployment's URLs, its admin key and the relay interval", () => {
|
||||
expect(betterAuthEnv(ENV)).toEqual({
|
||||
url: "http://cloud.test",
|
||||
siteUrl: "http://site.test",
|
||||
adminKey: "admin-key-of-the-test",
|
||||
relayIntervalMs: 60000,
|
||||
})
|
||||
expect(betterAuthEnv({ ...ENV, BETTER_AUTH_RELAY_INTERVAL_MS: "" }).relayIntervalMs).toBe(2000)
|
||||
})
|
||||
|
||||
it("refuses a missing or malformed variable by name, never by value", () => {
|
||||
for (const [name, value, message] of [
|
||||
["BETTER_AUTH_CONVEX_URL", "", /BETTER_AUTH_CONVEX_URL is not set/],
|
||||
["BETTER_AUTH_CONVEX_SITE_URL", "site.test", /BETTER_AUTH_CONVEX_SITE_URL is not an absolute/],
|
||||
["BETTER_AUTH_CONVEX_ADMIN_KEY", " ", /BETTER_AUTH_CONVEX_ADMIN_KEY is not set/],
|
||||
["BETTER_AUTH_RELAY_INTERVAL_MS", "10", /BETTER_AUTH_RELAY_INTERVAL_MS/],
|
||||
] as const) {
|
||||
expect(() => betterAuthEnv({ ...ENV, [name]: value }), name).toThrow(message)
|
||||
}
|
||||
expect(() => betterAuthEnv({ ...ENV, BETTER_AUTH_CONVEX_ADMIN_KEY: "" })).not.toThrow(/admin-key-of-the-test/)
|
||||
})
|
||||
})
|
||||
|
||||
type Seen = { url: string; init: RequestInit | undefined }
|
||||
|
||||
/** Better Auth's token endpoint, the validation query, the sign-out and the admin mutation, faked. */
|
||||
function fakeDeployment() {
|
||||
const seen: Seen[] = []
|
||||
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
seen.push({ url, init })
|
||||
if (url === "http://site.test/api/auth/convex/token") return Response.json({ token: "convex-token" })
|
||||
if (url === "http://site.test/api/auth/sign-out") return Response.json({ success: true })
|
||||
if (url.startsWith("http://cloud.test/api/query")) {
|
||||
const body = JSON.parse(String(init?.body)) as { path: string }
|
||||
if (body.path === "conusaiAuth:pendingRevocations")
|
||||
return Response.json({ status: "success", value: [], logLines: [] })
|
||||
const value = {
|
||||
userId: "ba-user",
|
||||
sessionId: "ba-session",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
createdAt: Date.now() - 1000,
|
||||
email: "ada@example.test",
|
||||
name: "Ada",
|
||||
emailVerified: false,
|
||||
}
|
||||
return Response.json({ status: "success", value, logLines: [] })
|
||||
}
|
||||
if (url.startsWith("http://cloud.test/api/mutation"))
|
||||
return Response.json({ status: "success", value: null, logLines: [] })
|
||||
throw new Error(`unexpected ${url}`)
|
||||
}) as typeof globalThis.fetch
|
||||
return { fetch, seen }
|
||||
}
|
||||
|
||||
function runtimeWith(onSessionRevoked = vi.fn(async () => undefined)) {
|
||||
const registry = buildRegistry([tenancyModule])
|
||||
const identities = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const deployment = fakeDeployment()
|
||||
const warnings: string[] = []
|
||||
const runtime = createBetterAuthRuntime({
|
||||
config,
|
||||
identities,
|
||||
onSessionRevoked,
|
||||
onAccountEvent: async () => undefined,
|
||||
log: { warn: (message) => warnings.push(message) },
|
||||
env: ENV,
|
||||
fetch: deployment.fetch,
|
||||
})
|
||||
return { runtime, deployment, onSessionRevoked, warnings }
|
||||
}
|
||||
|
||||
const signedIn = (cookie = "better-auth.session_token=tok.sig") =>
|
||||
new Request("http://app.test/auth/sign-out", { method: "POST", headers: { cookie } })
|
||||
|
||||
describe("the Better Auth sign-out", () => {
|
||||
it("ends the device's derived service sessions, then the session at Better Auth, then every cookie", async () => {
|
||||
const { runtime, deployment, onSessionRevoked } = runtimeWith()
|
||||
try {
|
||||
const response = await runtime.signOut(signedIn())
|
||||
expect(onSessionRevoked).toHaveBeenCalledWith(
|
||||
{ provider: "convex-better-auth", opaqueId: "ba-session" },
|
||||
"sign-out",
|
||||
)
|
||||
const signOut = deployment.seen.findIndex((call) => call.url === "http://site.test/api/auth/sign-out")
|
||||
expect(signOut).toBeGreaterThan(0)
|
||||
expect(new Headers(deployment.seen[signOut]?.init?.headers).get("origin")).toBe("http://app.test")
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get("location")).toBe("http://app.test/auth/signed-out")
|
||||
expect(response.headers.getSetCookie()).toContain(
|
||||
"better-auth.session_token=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax",
|
||||
)
|
||||
} finally {
|
||||
await runtime.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it("clears the cookies of a browser whose session Better Auth cannot confirm, and says so", async () => {
|
||||
const { runtime, onSessionRevoked, warnings } = runtimeWith()
|
||||
try {
|
||||
const response = await runtime.signOut(signedIn(""))
|
||||
expect(onSessionRevoked).not.toHaveBeenCalled()
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.getSetCookie().length).toBeGreaterThan(0)
|
||||
expect(warnings).toEqual([])
|
||||
} finally {
|
||||
await runtime.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it("ends a Better Auth account with the deployment's admin key, by its Better Auth user ID", async () => {
|
||||
const { runtime, deployment } = runtimeWith()
|
||||
try {
|
||||
await runtime.endAccount("ba-user")
|
||||
const call = deployment.seen.find((candidate) => candidate.url.startsWith("http://cloud.test/api/mutation"))
|
||||
expect(JSON.parse(String(call?.init?.body))).toMatchObject({
|
||||
path: "conusaiAuth:endAccount",
|
||||
args: [{ userId: "ba-user" }],
|
||||
})
|
||||
expect(new Headers(call?.init?.headers).get("authorization")).toBe("Convex admin-key-of-the-test")
|
||||
} finally {
|
||||
await runtime.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("the account deletion's Better Auth step, bound in every process that may run it", () => {
|
||||
const BOUND = {
|
||||
AUTH_PROVIDER: "convex-better-auth",
|
||||
AUTH_IDENTITY_NAMESPACE: "better-auth:test",
|
||||
BETTER_AUTH_CONVEX_URL: "http://cloud.test",
|
||||
BETTER_AUTH_CONVEX_ADMIN_KEY: "admin-key-of-the-test",
|
||||
}
|
||||
const providerOf = (container: Container) =>
|
||||
container.has(ACCOUNT_IDENTITY_PROVIDER) ? container.resolve(ACCOUNT_IDENTITY_PROVIDER) : null
|
||||
|
||||
it("the web process binds the provider the ledger records, under the namespace; an OIDC app binds none", () => {
|
||||
const web = new Container()
|
||||
bindBetterAuthAccountEnd(web, BOUND)
|
||||
expect([providerOf(web)?.id, providerOf(web)?.issuer]).toEqual(["convex-better-auth", "better-auth:test"])
|
||||
const oidc = new Container()
|
||||
bindBetterAuthAccountEnd(oidc, { ...BOUND, AUTH_PROVIDER: "oidc" })
|
||||
expect(providerOf(oidc)).toBeNull()
|
||||
})
|
||||
|
||||
it("the worker binds the same for its sweep, and refuses to start without the deployment's admin key", async () => {
|
||||
const platform = await startFakePlatform()
|
||||
try {
|
||||
const env = {
|
||||
NODE_ENV: "test",
|
||||
CONUSAI_FAKE_PLATFORM: "1",
|
||||
CONUSAI_API_URL: `${platform.url}/api`,
|
||||
CONUSAI_API_TOKEN: "test-token",
|
||||
CONUSAI_PROJECT_ID: "42",
|
||||
CONUSAI_ORG_ID: "42",
|
||||
CONUSAI_ORG_SLUG: "owner-org",
|
||||
POSTGRES_URL: "postgresql://app_app:secret@db:5432/app",
|
||||
POSTGRES_READER_URL: "postgresql://app_reader:secret@db:5432/app",
|
||||
}
|
||||
const registry = buildRegistry([...appModules])
|
||||
const clients = platformClients(loadAppEnv("worker", env), registry, env)
|
||||
// The worker reads its process environment, as it does in a container.
|
||||
const bound = (extra: Record<string, string>) => {
|
||||
for (const [name, value] of Object.entries(extra)) vi.stubEnv(name, value)
|
||||
try {
|
||||
const container = new Container()
|
||||
bindModulePorts(container, clients, registry)
|
||||
return container
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
}
|
||||
const worker = bound(BOUND)
|
||||
expect([providerOf(worker)?.id, providerOf(worker)?.issuer]).toEqual(["convex-better-auth", "better-auth:test"])
|
||||
expect(() => bound({ ...BOUND, BETTER_AUTH_CONVEX_ADMIN_KEY: "" })).toThrow("BETTER_AUTH_CONVEX_ADMIN_KEY")
|
||||
expect(providerOf(bound({ AUTH_PROVIDER: "" }))).toBeNull()
|
||||
} finally {
|
||||
await platform.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("the Better Auth deployment's credentials stay server-side (no platform link injects them)", () => {
|
||||
type Compose = { services: Record<string, { build?: { args?: unknown }; environment?: Record<string, string> }> }
|
||||
const compose = (path: string) => parse(readFileSync(new URL(`../${path}`, import.meta.url), "utf8")) as Compose
|
||||
const holders = (file: Compose, name: string) =>
|
||||
Object.entries(file.services)
|
||||
.filter(([, service]) => service.environment?.[name] !== undefined)
|
||||
.map(([id]) => id)
|
||||
.sort()
|
||||
|
||||
it("names the admin key only in the server processes that use it, each from the deployment's own variable", () => {
|
||||
const postgres = compose("compose.yaml")
|
||||
const convex = compose("profiles/convex/compose.yaml")
|
||||
// The web server (session checks, the relay), the worker (the sweep ends a deleted account), the
|
||||
// job that deploys the Better Auth functions; on Convex the deploy job also installs it in the
|
||||
// app's backend, whose account actions end the account there.
|
||||
expect(holders(postgres, "BETTER_AUTH_CONVEX_ADMIN_KEY")).toEqual(["migrate", "web", "worker"])
|
||||
expect(holders(convex, "BETTER_AUTH_CONVEX_ADMIN_KEY")).toEqual(["convex-deploy", "web"])
|
||||
// The secret: the deploy job alone.
|
||||
expect(holders(postgres, "BETTER_AUTH_SECRET")).toEqual(["migrate"])
|
||||
expect(holders(convex, "BETTER_AUTH_SECRET")).toEqual(["convex-deploy"])
|
||||
for (const file of [postgres, convex]) {
|
||||
for (const [id, service] of Object.entries(file.services)) {
|
||||
for (const [name, value] of Object.entries(service.environment ?? {})) {
|
||||
if (!name.startsWith("BETTER_AUTH_")) continue
|
||||
// Passed through from the variable of the same name, defaulting to empty: never a literal.
|
||||
expect(value, `${id}: ${name}`).toBe(`\${${name}:-}`)
|
||||
}
|
||||
expect(JSON.stringify(service.build?.args ?? {}), `${id} builds with no Better Auth value`).not.toContain(
|
||||
"BETTER_AUTH",
|
||||
)
|
||||
// `VITE_` is the prefix Vite writes into the browser's bundle (test/asset-config.test.ts keeps it the only one).
|
||||
expect(Object.keys(service.environment ?? {}).filter((name) => name.startsWith("VITE_"))).toEqual([])
|
||||
}
|
||||
}
|
||||
const dockerfile = readFileSync(new URL("../Dockerfile", import.meta.url), "utf8")
|
||||
expect(dockerfile).not.toContain("BETTER_AUTH")
|
||||
const example = readFileSync(new URL("../.env.example", import.meta.url), "utf8")
|
||||
expect(example).not.toMatch(/VITE_[A-Z_]*BETTER_AUTH|VITE_[A-Z_]*CONVEX/)
|
||||
})
|
||||
|
||||
it("links the Better Auth deployment only as the named better-auth Convex link, never beside Identity", () => {
|
||||
// A link role decides the injected names (BL): the default Convex link injects
|
||||
// CONVEX_SELF_HOSTED_*, the better-auth one BETTER_AUTH_CONVEX_* as runtime-only variables.
|
||||
// `kit template:prune --auth convex-better-auth` swaps the Identity link for it; an app that
|
||||
// keeps OIDC declares none (docs/how-to/build-an-app-on-the-template.md).
|
||||
for (const path of ["conusai.json", "profiles/convex/conusai.json"]) {
|
||||
const config = JSON.parse(readFileSync(new URL(`../${path}`, import.meta.url), "utf8")) as {
|
||||
links?: Array<{ type: string; role?: string }>
|
||||
}
|
||||
const links = config.links ?? []
|
||||
const roles = links.filter((link) => link.type === "convex").map((link) => link.role ?? "default")
|
||||
expect(new Set(roles).size, `${path}: one Convex link per role`).toBe(roles.length)
|
||||
const betterAuth = links.filter((link) => link.role === "better-auth")
|
||||
expect(
|
||||
betterAuth.every((link) => link.type === "convex"),
|
||||
path,
|
||||
).toBe(true)
|
||||
if (betterAuth.length > 0) {
|
||||
expect(
|
||||
links.some((link) => link.type === "identity"),
|
||||
`${path}: no Identity link beside Better Auth`,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Better Auth's account events", () => {
|
||||
it("go to the account module's flow as the mapped app user; nothing for a subject never mapped", async () => {
|
||||
const delivered: unknown[] = []
|
||||
const ports = { accountFlow: async (event: unknown) => void delivered.push(event) }
|
||||
const event = {
|
||||
reason: "user-disabled",
|
||||
identityNamespace: "better-auth:test",
|
||||
providerSubject: "ba-user",
|
||||
} as const
|
||||
await deliverAccountEvent(ports, { ...event, kitUserId: null })
|
||||
expect(delivered).toEqual([])
|
||||
await deliverAccountEvent(ports, { ...event, kitUserId: "app-user" })
|
||||
await deliverAccountEvent(ports, { ...event, reason: "account-deleted", kitUserId: "app-user" })
|
||||
expect(delivered).toEqual([
|
||||
{ reason: "user-disabled", userId: "app-user" },
|
||||
{ reason: "account-deleted", userId: "app-user" },
|
||||
])
|
||||
})
|
||||
|
||||
it("throw the flow's failure, so the relay keeps the event and delivers it again", async () => {
|
||||
const ports = {
|
||||
accountFlow: async () => {
|
||||
throw new Error("backend unavailable")
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
deliverAccountEvent(ports, {
|
||||
reason: "account-deleted",
|
||||
identityNamespace: "better-auth:test",
|
||||
providerSubject: "ba-user",
|
||||
kitUserId: "app-user",
|
||||
}),
|
||||
).rejects.toThrow("backend unavailable")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the sign-in form", () => {
|
||||
it("checks the password length Better Auth enforces, and labels every error from a fixed set", () => {
|
||||
expect(MIN_PASSWORD_LENGTH).toBe(OPTIONS_MIN)
|
||||
const labels = betterAuthFormLabels(
|
||||
{ message: (key, values) => `${key}${values ? JSON.stringify(values) : ""}` },
|
||||
"sign-up",
|
||||
)
|
||||
expect(labels.passwordHelp).toBe(`signin.account.passwordHelp{"min":${MIN_PASSWORD_LENGTH}}`)
|
||||
expect(Object.keys(labels.errors).sort()).toEqual([
|
||||
"disabled",
|
||||
"exists",
|
||||
"failed",
|
||||
"invalid",
|
||||
"rateLimited",
|
||||
"unavailable",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the locally installed Better Auth component", () => {
|
||||
it("is the schema the app's Better Auth options generate, with the admin plugin's fields", async () => {
|
||||
const options = {
|
||||
database: convexAdapter({} as never, {} as never),
|
||||
...betterAuthOptions({ providers: [{ applicationID: "convex", domain: "" }] }),
|
||||
}
|
||||
const adapter = (
|
||||
options.database as unknown as (all: unknown) => {
|
||||
createSchema(all: unknown, file: string): Promise<{ code: string }>
|
||||
}
|
||||
)(options)
|
||||
const { code } = await adapter.createSchema(options, "better-auth/functions/betterAuth/schema.ts")
|
||||
const body = code.slice(code.indexOf("import "))
|
||||
const header = `// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated from ./options.ts by the @convex-dev/better-auth schema generator: do not edit it by hand.
|
||||
// After a change of the Better Auth options, regenerate it with
|
||||
// pnpm exec vitest run test/better-auth.test.ts -u
|
||||
// (the "local install" of @convex-dev/better-auth, which is how the admin plugin's fields get here).
|
||||
|
||||
`
|
||||
for (const field of ["role:", "banned:", "banReason:", "banExpires:", "impersonatedBy:"])
|
||||
expect(body).toContain(field)
|
||||
await expect(`${header}${body}`).toMatchFileSnapshot("../better-auth/functions/betterAuth/schema.ts")
|
||||
})
|
||||
|
||||
it("maps one Better Auth user to one app user, under the configured namespace and never by e-mail", async () => {
|
||||
const registry = buildRegistry([tenancyModule])
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const first = await requireAppUser(data, {
|
||||
issuer: config.identityNamespace,
|
||||
subject: "ba-user",
|
||||
email: "ada@example.test",
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
const other = await requireAppUser(data, {
|
||||
issuer: config.identityNamespace,
|
||||
subject: "ba-other",
|
||||
email: "ada@example.test",
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
expect(other.id).not.toBe(first.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The boundary rules are generated from the folder tree (spec §2), so a module added tomorrow is
|
||||
// covered without anybody editing a list. This suite is that promise.
|
||||
|
||||
import { createRequire } from "node:module"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { buildForbidden, listModules } = require("../scripts/dep-rules.cjs") as {
|
||||
buildForbidden(
|
||||
moduleIds: string[],
|
||||
): { name: string; from: { path?: string; pathNot?: string }; to: { path?: string | string[]; circular?: boolean } }[]
|
||||
listModules(root: string): string[]
|
||||
}
|
||||
|
||||
const named = (rules: ReturnType<typeof buildForbidden>, name: string) => rules.find((rule) => rule.name === name)
|
||||
|
||||
describe("the generated boundary rules", () => {
|
||||
it("find this app's modules on disk", () => {
|
||||
const found = listModules(new URL("..", import.meta.url).pathname)
|
||||
expect(Array.isArray(found)).toBe(true)
|
||||
})
|
||||
|
||||
it("keep the worker entry out of every app but the worker", () => {
|
||||
const rule = named(buildForbidden([]), "worker-entry-is-the-workers-own")
|
||||
expect(rule?.from.pathNot).toBe("^apps/worker/")
|
||||
// The resolved path, which is what dependency-cruiser matches on.
|
||||
expect(rule?.to.path).toBe("@conusai-kit/core/dist/worker\\.")
|
||||
})
|
||||
|
||||
it("keep a driver, an SDK and a framework out of a module's core", () => {
|
||||
const rule = named(buildForbidden(["notes"]), "core-imports-no-engine-sdk-or-framework")
|
||||
const patterns = (rule?.to.path ?? []) as string[]
|
||||
const forbidden = (name: string): boolean => patterns.some((pattern) => new RegExp(pattern).test(name))
|
||||
expect(forbidden("kysely")).toBe(true)
|
||||
expect(forbidden("react")).toBe(true)
|
||||
// The web shell's framework, by the names a use case could import it under.
|
||||
expect(forbidden("@tanstack/react-start")).toBe(true)
|
||||
expect(forbidden("@tanstack/react-start/server")).toBe(true)
|
||||
expect(forbidden("@tanstack/react-router")).toBe(true)
|
||||
expect(forbidden("@conusai-sdk/blob")).toBe(true)
|
||||
// And the contracts a use case is written against stay open.
|
||||
expect(forbidden("@conusai-kit/core")).toBe(false)
|
||||
})
|
||||
|
||||
it("add a rule per module pair, so a module reaches another only through its contract", () => {
|
||||
const rules = buildForbidden(["documents", "notes"])
|
||||
expect(named(rules, "module-notes-crosses-only-contracts")?.to.path).toBe("^modules/(documents)/(?!contract/)")
|
||||
expect(named(rules, "module-documents-crosses-only-contracts")?.to.path).toBe("^modules/(notes)/(?!contract/)")
|
||||
})
|
||||
|
||||
it("add no module rule when there is only one module to cross into", () => {
|
||||
expect(buildForbidden(["notes"]).some((rule) => rule.name.startsWith("module-"))).toBe(false)
|
||||
})
|
||||
|
||||
it("refuse a cycle anywhere", () => {
|
||||
expect(named(buildForbidden([]), "no-circular")?.to.circular).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The brand is the only source of colour (decision AT-D11), and every string a page shows is a
|
||||
// message in both locales (spec §10.4).
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { appCatalogues } from "../apps/web/src/ui/messages"
|
||||
import { appBrand, BRAND_TOKEN_NAMES, brandTokenStyle } from "../config/src/brand.js"
|
||||
|
||||
const walk = (dir: string, suffix: RegExp): string[] =>
|
||||
readdirSync(dir).flatMap((name) => {
|
||||
// Installed packages are not this app's pages, and a linked workspace package would pull its
|
||||
// own sources (stories, fixtures) into the scan. What a build or the Start plugin writes is not source.
|
||||
if (["node_modules", "dist", ".tanstack"].includes(name)) return []
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) return walk(path, suffix)
|
||||
return suffix.test(path) ? [path] : []
|
||||
})
|
||||
|
||||
const appDir = new URL("..", import.meta.url).pathname
|
||||
|
||||
describe("the app brand", () => {
|
||||
it("reads its tokens from @conusai-kit/brand, so the app defines none", () => {
|
||||
expect(BRAND_TOKEN_NAMES.length).toBeGreaterThan(0)
|
||||
expect(appBrand.tokens).toEqual({})
|
||||
expect(brandTokenStyle({ ...appBrand, tokens: { primary: "#123456" } })).toEqual({ "--brand-primary": "#123456" })
|
||||
})
|
||||
|
||||
it("ships no colour literal of its own in the stylesheet", () => {
|
||||
const css = readFileSync(join(appDir, "apps/web/src/ui/globals.css"), "utf8")
|
||||
const literals = css.match(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/g) ?? []
|
||||
expect(literals).toEqual([])
|
||||
})
|
||||
|
||||
it("loads no external stylesheet, font or script", () => {
|
||||
// The document's head is `src/ui/document.tsx` and the root route's `head`, not only the pages.
|
||||
const files = walk(join(appDir, "apps/web/src"), /\.(?:tsx|css)$/)
|
||||
expect(files.some((file) => file.endsWith("src/routes/__root.tsx"))).toBe(true)
|
||||
expect(files.some((file) => file.endsWith("src/ui/document.tsx"))).toBe(true)
|
||||
expect(files.some((file) => file.endsWith("src/ui/globals.css"))).toBe(true)
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8")
|
||||
expect(text, `${file} reaches outside this app`).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost)/)
|
||||
}
|
||||
})
|
||||
|
||||
it("has no attribution turned on by default: a customer sells their own product", () => {
|
||||
expect(appBrand.attribution).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the message catalogues", () => {
|
||||
const en = appCatalogues.en ?? {}
|
||||
const lt = appCatalogues.lt ?? {}
|
||||
|
||||
it("hold the same keys in English and in Lithuanian", () => {
|
||||
for (const [module, messages] of Object.entries(en)) {
|
||||
expect(Object.keys(lt[module] ?? {}).sort(), `lt is missing keys of ${module}`).toEqual(
|
||||
Object.keys(messages).sort(),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("select the Lithuanian plural categories, which English does not have", () => {
|
||||
expect(lt.workspace?.projectCount).toContain("few {")
|
||||
expect(en.workspace?.projectCount).not.toContain("few {")
|
||||
})
|
||||
|
||||
it("name no role of their own: a role's one name is the kit's, on every page", () => {
|
||||
// The invitation form, the invitation page, the members table and the role picker all read
|
||||
// `builtInRoleName` and `roleName` of @conusai-kit/tenancy/ui, so a member is the same Editor on each.
|
||||
expect(en.roles).toBeUndefined()
|
||||
expect(lt.roles).toBeUndefined()
|
||||
for (const file of ["../apps/web/src/server/org-pages.ts", "../apps/web/src/server/account-pages.ts"])
|
||||
expect(readFileSync(new URL(file, import.meta.url), "utf8"), file).not.toMatch(/message\(`roles\./)
|
||||
})
|
||||
|
||||
it("hold the messages a page picks by a value rather than by name: sign-in errors, invitation states", () => {
|
||||
const keys = [
|
||||
...["sessionExpired", "noTransaction", "denied", "provider", "failed"].map((code) => `signin.error.${code}`),
|
||||
...["invalid", "used", "expired", "member", "failed"].map((state) => `invite.error.${state}`),
|
||||
]
|
||||
for (const key of keys) {
|
||||
const [module, ...rest] = key.split(".")
|
||||
const message = rest.join(".")
|
||||
expect(en[module as string]?.[message], `en is missing ${key}`).toBeTruthy()
|
||||
expect(lt[module as string]?.[message], `lt is missing ${key}`).toBeTruthy()
|
||||
expect(lt[module as string]?.[message], `${key} is not translated`).not.toBe(en[module as string]?.[message])
|
||||
}
|
||||
})
|
||||
|
||||
it("select the Lithuanian plural categories of the counts the workspace pages show", () => {
|
||||
expect(lt.workspace?.projectCount).toContain("few {")
|
||||
expect(en.workspace?.projectCount).not.toContain("few {")
|
||||
})
|
||||
|
||||
it("hold every message the pages ask for, in both locales", () => {
|
||||
// Every label a page shows is `i18n.message("<key>")`, so the keys the pages use and the keys
|
||||
// the catalogues hold are comparable -- and a renamed message fails here rather than
|
||||
// rendering its own key to a customer. A loader builds a page's labels on the server (F10), so the
|
||||
// keys are in `.ts` files as well as `.tsx`, often through a local `message` over `i18n.message`.
|
||||
const used = new Set<string>()
|
||||
for (const file of walk(join(appDir, "apps/web"), /\.tsx?$/)) {
|
||||
for (const [, key] of readFileSync(file, "utf8").matchAll(/\bmessage\("([^"]+)"/g)) used.add(key as string)
|
||||
}
|
||||
// The shell's labels, read from `shellData()`: a walk that missed loaders would stop finding these.
|
||||
expect(used).toContain("app.signIn")
|
||||
expect(used).toContain("boundary.notFoundTitle")
|
||||
// And the pages' own: `src/server/account-pages.ts` and `src/server/org-pages.ts`.
|
||||
expect(used).toContain("app.organizations")
|
||||
expect(used).toContain("workspace.create")
|
||||
expect(used.size).toBeGreaterThan(10)
|
||||
for (const key of [...used].sort()) {
|
||||
const [module, ...rest] = key.split(".")
|
||||
const message = rest.join(".")
|
||||
expect(en[module as string]?.[message], `en is missing ${key}`).toBeTruthy()
|
||||
expect(lt[module as string]?.[message], `lt is missing ${key}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
|
||||
...(await import("./support/native-readiness.js")).selectedModules([
|
||||
"documents",
|
||||
"knowledge",
|
||||
"agent",
|
||||
"guides",
|
||||
"support",
|
||||
]),
|
||||
}))
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a page says about itself, rendered with a customer's brand: their name, their colours on the
|
||||
// first byte of the document, and no trace of ConusAI's (plan item AT2.6 done_when, the rendered-page
|
||||
// half; there are no emails or share images in the template to render).
|
||||
|
||||
import { brandTokenStyle, defineBrand } from "@app/config"
|
||||
import { createElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { brandHead, brandMetadata, brandTokensOnly, Document } from "../apps/web/src/ui/document"
|
||||
import { appCatalogues } from "../apps/web/src/ui/messages"
|
||||
|
||||
const customer = defineBrand({
|
||||
name: "Acme Review",
|
||||
shortName: "Acme",
|
||||
primary: "#1a1a8c",
|
||||
logo: { light: "/acme-light.svg", dark: "/acme-dark.svg", favicon: "/acme.svg" },
|
||||
})
|
||||
|
||||
// The layout's own consent wording in the page's locale, as RootLayout passes it.
|
||||
const consent = (key: string): string => {
|
||||
const text = appCatalogues.lt?.consent?.[key]
|
||||
if (!text) throw new Error(`missing lt consent.${key}`)
|
||||
return text
|
||||
}
|
||||
const privacy = {
|
||||
title: consent("title"),
|
||||
body: consent("body"),
|
||||
replay: consent("replay"),
|
||||
accept: consent("accept"),
|
||||
reject: consent("reject"),
|
||||
reopen: consent("reopen"),
|
||||
gpc: consent("gpc"),
|
||||
}
|
||||
|
||||
const render = (extra: { theme?: "dark" | "light"; observe?: { release: string; environment: string } } = {}): string =>
|
||||
renderToStaticMarkup(
|
||||
createElement(Document, {
|
||||
brand: { ...customer, style: brandTokenStyle(customer) },
|
||||
locale: "lt",
|
||||
theme: extra.theme ?? "dark",
|
||||
signedIn: false,
|
||||
signIn: "Prisijungti",
|
||||
signOut: null,
|
||||
controls: null,
|
||||
privacy,
|
||||
...(extra.observe ? { observe: extra.observe } : {}),
|
||||
children: createElement("main", null, "turinys"),
|
||||
}),
|
||||
)
|
||||
|
||||
const html = render()
|
||||
|
||||
describe("a page rendered with a customer's brand", () => {
|
||||
it("carries the customer's name and logos and no ConusAI name", () => {
|
||||
expect(html).toContain("Acme Review")
|
||||
expect(html).toContain('src="/acme-dark.svg"')
|
||||
expect(html.toLowerCase()).not.toContain("conusai")
|
||||
})
|
||||
|
||||
it("writes the brand layer as a stylesheet in the head, so the first paint is branded and a platform theme can win", () => {
|
||||
const root = /<html[^>]*>/.exec(html)?.[0] ?? ""
|
||||
expect(root).toContain('lang="lt"')
|
||||
expect(root).not.toContain("--primary")
|
||||
const layer = /<style data-brand-layer="app"[^>]*>([^<]*)<\/style>/.exec(html)?.[1] ?? ""
|
||||
expect(html.indexOf("data-brand-layer")).toBeLessThan(html.indexOf("</head>"))
|
||||
expect(layer).toMatch(/^:root:root\{.*--primary:light-dark\(#1a1a8c, ?#[0-9a-f]{6}\)/)
|
||||
expect(layer).toMatch(/--ring:light-dark\(#[0-9a-f]{6}, ?#[0-9a-f]{6}\)/)
|
||||
})
|
||||
|
||||
it("leaves the colours to the platform's organization theme when there is one, keeping the brand's own tokens", () => {
|
||||
expect(brandTokensOnly({ "--primary": "#1a1a8c", "--brand-font-size-root": "16px" })).toEqual({
|
||||
"--brand-font-size-root": "16px",
|
||||
})
|
||||
})
|
||||
|
||||
it("names the customer, and only the customer, in the tab and in a share card", () => {
|
||||
const metadata = JSON.stringify(brandMetadata(customer))
|
||||
expect(metadata).toContain("Acme Review")
|
||||
expect(metadata.toLowerCase()).not.toContain("conusai")
|
||||
})
|
||||
|
||||
it("points the share card at the app's own image, or at the organization's on its own pages", () => {
|
||||
const app = brandMetadata(customer)
|
||||
expect(app.openGraph?.images).toEqual([{ url: "/api/brand/og", width: 1200, height: 630, alt: "Acme Review" }])
|
||||
expect(app.twitter).toMatchObject({ card: "summary_large_image", images: ["/api/brand/og"] })
|
||||
const organization = brandMetadata(customer, "/acme/brand/og")
|
||||
expect(JSON.stringify(organization)).toContain("/acme/brand/og")
|
||||
expect(JSON.stringify(organization)).not.toContain("/api/brand/og")
|
||||
})
|
||||
|
||||
it("gives a link preview an absolute image address from the deployment's public URL", () => {
|
||||
const { meta } = brandHead(brandMetadata(customer), "https://app.example.test")
|
||||
expect(meta).toContainEqual({ property: "og:image", content: "https://app.example.test/api/brand/og" })
|
||||
expect(meta).toContainEqual({ name: "twitter:image", content: "https://app.example.test/api/brand/og" })
|
||||
})
|
||||
|
||||
it("has exactly one element that carries the theme class: the page, not a component", () => {
|
||||
expect((html.match(/class="[^"]*\blight\b/g) ?? []).length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it("puts the light theme on the root element from the first byte, and the dark one on nothing", () => {
|
||||
expect(/<html[^>]*>/.exec(html)?.[0]).not.toMatch(/class="[^"]*\blight\b/)
|
||||
const light = render({ theme: "light" })
|
||||
expect(/<html[^>]*>/.exec(light)?.[0]).toMatch(/class="light"/)
|
||||
expect((light.match(/class="(?:[^"]* )?light(?: [^"]*)?"/g) ?? []).length).toBe(1)
|
||||
})
|
||||
|
||||
it("tells the browser to start observability only when the server turned it on, without naming ConusAI", () => {
|
||||
expect(html).not.toContain("app-observe")
|
||||
const observed = render({ observe: { release: "2026.10.1", environment: "production" } })
|
||||
expect(observed).toContain('name="app-observe"')
|
||||
expect(observed).toContain('data-release="2026.10.1"')
|
||||
expect(observed.toLowerCase()).not.toContain("conusai")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The app brand (plan item AT2.6). A customer replaces the ConusAI defaults in config/src/brand.ts with
|
||||
// `defineBrand`, and what they give it has to be safe to write into a stylesheet and a page. The
|
||||
// legibility of their colours is @conusai-kit/brand's own test: this file checks what the app does with them.
|
||||
|
||||
import { appBrand, brandContrast, brandTokenStyle, defineBrand } from "@app/config"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const base = { name: "Acme Review", shortName: "Acme" }
|
||||
|
||||
describe("defineBrand", () => {
|
||||
it("returns the brand, frozen, with its colours as a style", () => {
|
||||
const brand = defineBrand({ ...base, primary: "#1a1a8c" })
|
||||
expect(brand.name).toBe("Acme Review")
|
||||
expect(Object.isFrozen(brand)).toBe(true)
|
||||
expect(brand.attribution).toBe(false)
|
||||
// The light page keeps the brand's colour; the dark page gets it lifted until a button stands out (kit-brand).
|
||||
expect(brandTokenStyle(brand)["--primary"]).toMatch(/^light-dark\(#1a1a8c, #[0-9a-f]{6}\)$/)
|
||||
expect(brandTokenStyle(brand)["--sidebar-primary"]).toBe(brandTokenStyle(brand)["--primary"])
|
||||
expect(Object.keys(brandTokenStyle(brand)).some((name) => name.startsWith("--brand-accent"))).toBe(false)
|
||||
})
|
||||
|
||||
it("reports the contrast its colours have on each theme", () => {
|
||||
const report = brandContrast(defineBrand({ ...base, primary: "#1a1a8c" }))
|
||||
expect(report.dark?.inkContrast).toBeGreaterThanOrEqual(4.5)
|
||||
expect(report.light?.inkContrast).toBeGreaterThanOrEqual(4.5)
|
||||
expect(brandContrast(defineBrand(base))).toEqual({})
|
||||
})
|
||||
|
||||
it("refuses a colour that is not hex, naming the field and not the value", () => {
|
||||
expect(() => defineBrand({ ...base, primary: "red" })).toThrow(/primary/)
|
||||
expect(() => defineBrand({ ...base, primary: "secret-colour" })).not.toThrow(/secret-colour/)
|
||||
expect(() => defineBrand({ ...base, accent: "#12" })).toThrow(/accent/)
|
||||
})
|
||||
|
||||
it("refuses a name that is empty, too long or carries a control character", () => {
|
||||
for (const name of ["", " ", "x".repeat(61), "bad\u0007name"]) {
|
||||
expect(() => defineBrand({ name, shortName: "x" })).toThrow(/name/)
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses a logo that is not a path on this app's own origin", () => {
|
||||
for (const logo of [
|
||||
"https://cdn.example.test/logo.svg",
|
||||
"//cdn.example.test/logo.svg",
|
||||
"javascript:alert(1)",
|
||||
"data:image/svg+xml,<svg/>",
|
||||
"logo.svg",
|
||||
]) {
|
||||
expect(() => defineBrand({ ...base, logo: { light: logo, dark: "/a.svg", favicon: "/a.svg" } }), logo).toThrow(
|
||||
/logo/,
|
||||
)
|
||||
}
|
||||
expect(defineBrand({ ...base, logo: { light: "/l.svg", dark: "/d.svg", favicon: "/f.svg" } }).logo.dark).toBe(
|
||||
"/d.svg",
|
||||
)
|
||||
})
|
||||
|
||||
it("refuses a token that could break out of a style attribute", () => {
|
||||
for (const value of ["red; background: url(//evil)", "#fff}", "url(//evil)", "expression(1)", "a\\b", "<b>"]) {
|
||||
expect(() => defineBrand({ ...base, tokens: { "--brand-accent": value } }), value).toThrow(/token/)
|
||||
}
|
||||
expect(() => defineBrand({ ...base, tokens: { "not a name": "#fff" } })).toThrow(/token/)
|
||||
expect(brandTokenStyle(defineBrand({ ...base, tokens: { "radius-panel": "0.5rem" } }))).toEqual({
|
||||
"--brand-radius-panel": "0.5rem",
|
||||
})
|
||||
})
|
||||
|
||||
it("is what the app ships: a neutral name, no ConusAI wording, no attribution", () => {
|
||||
expect(JSON.stringify(appBrand).toLowerCase()).not.toContain("conusai")
|
||||
expect(appBrand.attribution).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tenant brand end to end (plan item AT2.6): who may change it, what is refused, what is stored,
|
||||
// and what the organization layout will read back. Every call goes through the kernel's caller, so
|
||||
// the permission check, the validation and the data wall are the real ones.
|
||||
|
||||
import { type BrandView, tenantStyle } from "@app/modules/branding"
|
||||
import { checkLogo } from "@conusai-kit/brand/logo/check"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { organizations } from "@conusai-kit/tenancy"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { closeApps, type DocumentsApp, makeDocumentsApp, type SeededProject } from "./support/documents-app.js"
|
||||
import { chunk, dimensions, MARK_WEBP, markPng, noisePng } from "./support/images.js"
|
||||
|
||||
afterEach(closeApps)
|
||||
|
||||
const document = { engine: { name: "unused", version: "0" }, page_count: 0, pages: [] }
|
||||
|
||||
async function fresh(): Promise<{ app: DocumentsApp; project: SeededProject }> {
|
||||
const app = await makeDocumentsApp({ document })
|
||||
return { app, project: await app.seedProject("acme", "owner@acme.test") }
|
||||
}
|
||||
|
||||
// Real images: the kit's checks read a PNG's chunks and decode its pixels.
|
||||
const PNG = markPng(1000, 250)
|
||||
const png = (width: number) => markPng(width, 60)
|
||||
const svgOf = (inner: string) => Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4">${inner}</svg>`)
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return error
|
||||
throw error
|
||||
}
|
||||
throw new Error("expected a refusal")
|
||||
}
|
||||
|
||||
const stored = (app: DocumentsApp, project: SeededProject) =>
|
||||
app.data
|
||||
.global(organizations)
|
||||
.get(project.orgId)
|
||||
.then((row) => row?.branding ?? null)
|
||||
|
||||
describe("reading the brand", () => {
|
||||
it("answers nothing set for an organization that has no brand", async () => {
|
||||
const { project } = await fresh()
|
||||
expect(await project.call.callByName("branding.brand.get", { org: project.orgSlug })).toEqual({
|
||||
displayName: null,
|
||||
primary: null,
|
||||
accent: null,
|
||||
logoLight: null,
|
||||
logoDark: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("lets any member read it", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
|
||||
expect(await viewer.call.callByName("branding.brand.get", { org: project.orgSlug })).toMatchObject({
|
||||
primary: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("changing the brand", () => {
|
||||
it("stores the name and the colours on the organization and answers with them", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const view = await project.call.callByName("branding.brand.set", {
|
||||
org: project.orgSlug,
|
||||
displayName: " Acme Review ",
|
||||
primary: "#1a1a8c",
|
||||
accent: "#f4c430",
|
||||
})
|
||||
expect(view).toMatchObject({ displayName: "Acme Review", primary: "#1a1a8c", accent: "#f4c430" })
|
||||
expect(await stored(app, project)).toEqual({ displayName: "Acme Review", primary: "#1a1a8c", accent: "#f4c430" })
|
||||
})
|
||||
|
||||
it("leaves what is not sent as it was, and clears what is sent as null", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#1a1a8c", accent: "#f4c430" })
|
||||
await project.call.callByName("branding.brand.set", { org: project.orgSlug, accent: null })
|
||||
expect(await stored(app, project)).toEqual({ primary: "#1a1a8c" })
|
||||
await project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: null })
|
||||
// an empty brand is no brand: one representation of "none"
|
||||
expect(await stored(app, project)).toBeNull()
|
||||
})
|
||||
|
||||
it("refuses a colour that is not hex, with the field named and the value not repeated", async () => {
|
||||
const { app, project } = await fresh()
|
||||
for (const bad of ["red", "#12", "#gggggg", "rgb(1,2,3)", "#fff;}body{display:none", "url(//evil)"]) {
|
||||
const error = await refusal(project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: bad }))
|
||||
expect(error.status, bad).toBe(422)
|
||||
// the refusal names the field and does not repeat what was typed in it
|
||||
expect(JSON.stringify(error.problem), bad).toContain("primary")
|
||||
expect(JSON.stringify(error.problem), bad).not.toContain(bad)
|
||||
}
|
||||
expect(await stored(app, project)).toBeNull()
|
||||
})
|
||||
|
||||
it("refuses an empty display name, which would be a name nobody sees", async () => {
|
||||
const { project } = await fresh()
|
||||
const error = await refusal(
|
||||
project.call.callByName("branding.brand.set", { org: project.orgSlug, displayName: " " }),
|
||||
)
|
||||
expect(error.status).toBe(422)
|
||||
})
|
||||
|
||||
it("is an administrator's act: a member and a viewer are answered as strangers, and nothing changes", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const admin = await app.addMember(project, "admin@acme.test", "admin")
|
||||
const member = await app.addMember(project, "member@acme.test", "member")
|
||||
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
|
||||
for (const who of [member, viewer]) {
|
||||
// 404, not 403: the kernel answers a member who lacks the permission as it answers a stranger (spec §3.3)
|
||||
expect(
|
||||
(await refusal(who.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#000000" }))).status,
|
||||
).toBe(404)
|
||||
expect(
|
||||
(
|
||||
await refusal(
|
||||
who.call.callByName("branding.logo.set", {
|
||||
org: project.orgSlug,
|
||||
variant: "light",
|
||||
contentType: "image/png",
|
||||
body: PNG.toString("base64"),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(404)
|
||||
}
|
||||
expect(await stored(app, project)).toBeNull()
|
||||
await admin.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#000000" })
|
||||
expect(await stored(app, project)).toEqual({ primary: "#000000" })
|
||||
})
|
||||
|
||||
it("is declared to write one audit event per change", async () => {
|
||||
const app = await makeDocumentsApp({ document })
|
||||
expect(app.registry.procedures.get("branding.brand.set")?.def.audit).toBe("branding.brand_changed")
|
||||
expect(app.registry.procedures.get("branding.logo.set")?.def.audit).toBe("branding.brand_changed")
|
||||
expect(app.registry.audit["branding.brand_changed"]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
const logoKeys = (app: DocumentsApp, project: SeededProject) =>
|
||||
app.store
|
||||
.keysOf({ level: "org", actor: project.owner, orgId: project.orgId })
|
||||
.filter((key) => key.startsWith("branding/"))
|
||||
.sort()
|
||||
|
||||
const setLogo = (project: SeededProject, variant: "light" | "dark", contentType: string, bytes: Uint8Array) =>
|
||||
project.call.callByName("branding.logo.set", {
|
||||
org: project.orgSlug,
|
||||
variant,
|
||||
contentType,
|
||||
body: Buffer.from(bytes).toString("base64"),
|
||||
}) as Promise<BrandView>
|
||||
|
||||
const getLogo = async (project: SeededProject, variant: "light" | "dark", size?: "1x" | "2x") => {
|
||||
const back = (await project.call.callByName("branding.logo.get", {
|
||||
org: project.orgSlug,
|
||||
variant,
|
||||
...(size === undefined ? {} : { size }),
|
||||
})) as { contentType: string; body: string }
|
||||
return { contentType: back.contentType, bytes: Buffer.from(back.body, "base64") }
|
||||
}
|
||||
|
||||
describe("the logos", () => {
|
||||
it("stores a raster logo as its 1x and 2x renditions, and serves the 2x unless the 1x is asked for", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const view = await setLogo(project, "light", "image/png", PNG)
|
||||
expect(view.logoLight).toMatch(/^\/acme\/brand\/logo\/light\?v=[0-9a-f]{16}$/)
|
||||
expect(view.logoDark).toBeNull()
|
||||
const two = await getLogo(project, "light")
|
||||
expect(two.contentType).toBe("image/png")
|
||||
expect(dimensions(two.bytes)).toEqual({ width: 840, height: 210 })
|
||||
expect(dimensions((await getLogo(project, "light", "1x")).bytes)).toEqual({ width: 420, height: 105 })
|
||||
expect(two.bytes.equals(PNG)).toBe(false)
|
||||
// the object lives under the organization's own namespace of the store: two files, never the upload
|
||||
expect(logoKeys(app, project)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("resizes an image larger than a stored logo may be instead of refusing it, and every stored file passes the kit check", async () => {
|
||||
const { project } = await fresh()
|
||||
const big = noisePng(700, 300)
|
||||
expect(big.length).toBeGreaterThan(256 * 1024)
|
||||
await setLogo(project, "dark", "image/png", big)
|
||||
for (const size of ["1x", "2x"] as const) {
|
||||
const { bytes, contentType } = await getLogo(project, "dark", size)
|
||||
expect(bytes.length).toBeLessThanOrEqual(256 * 1024)
|
||||
expect(checkLogo(contentType, bytes)).toMatchObject({ ok: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("takes a WebP and stores it as PNG", async () => {
|
||||
const { project } = await fresh()
|
||||
await setLogo(project, "dark", "image/webp", MARK_WEBP)
|
||||
const { bytes, contentType } = await getLogo(project, "dark")
|
||||
expect(contentType).toBe("image/png")
|
||||
expect(dimensions(bytes)).toEqual({ width: 400, height: 100 })
|
||||
})
|
||||
|
||||
it("replaces a logo and removes the files it replaced", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await setLogo(project, "dark", "image/png", png(100))
|
||||
const second = await setLogo(project, "dark", "image/png", png(200))
|
||||
const keys = logoKeys(app, project)
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys.every((key) => second.logoDark?.includes(key.split("-")[2] ?? "?"))).toBe(true)
|
||||
// the same image again is the same logo: nothing is lost
|
||||
const third = await setLogo(project, "dark", "image/png", png(200))
|
||||
expect(third.logoDark).toBe(second.logoDark)
|
||||
expect(logoKeys(app, project)).toEqual(keys)
|
||||
})
|
||||
|
||||
it("keeps an SVG that is shapes alone exactly as uploaded, one file for both sizes", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const logo = svgOf('<rect width="4" height="4" fill="#123456"/>')
|
||||
const view = await setLogo(project, "light", "image/svg+xml", logo)
|
||||
expect(view.logoLight).not.toBeNull()
|
||||
expect(logoKeys(app, project)).toHaveLength(1)
|
||||
for (const size of [undefined, "1x", "2x"] as const) {
|
||||
const back = await getLogo(project, "light", size)
|
||||
expect(back.contentType).toBe("image/svg+xml")
|
||||
expect(back.bytes.equals(logo)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses an SVG that carries a script, stores nothing and leaves the brand as it was", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#1a1a8c" })
|
||||
const before = await stored(app, project)
|
||||
for (const inner of [
|
||||
"<script>alert(1)</script>",
|
||||
'<rect onload="alert(1)" width="1" height="1"/>',
|
||||
'<image href="https://evil.example.test/x.png"/>',
|
||||
]) {
|
||||
const error = await refusal(setLogo(project, "light", "image/svg+xml", svgOf(inner)))
|
||||
expect(error.status, inner).toBe(422)
|
||||
}
|
||||
expect(await stored(app, project)).toEqual(before)
|
||||
expect(logoKeys(app, project)).toEqual([])
|
||||
})
|
||||
|
||||
it("refuses a file the kit's checks refuse, naming the rule and not the file", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const damaged = Buffer.from(PNG)
|
||||
damaged[40] = (damaged[40] ?? 0) ^ 0xff
|
||||
const lie = Buffer.concat([
|
||||
PNG.subarray(0, 8),
|
||||
PNG.subarray(8, 33),
|
||||
chunk("IDAT", Buffer.from("not zlib data at all")),
|
||||
chunk("IEND", new Uint8Array()),
|
||||
])
|
||||
for (const [name, type, bytes, rule] of [
|
||||
["a PNG that is really markup", "image/png", Buffer.from("<svg/>"), /not a PNG/],
|
||||
["a WebP that is a PNG", "image/webp", PNG, /not a WebP/],
|
||||
["a PNG with a damaged chunk", "image/png", damaged, /damaged/],
|
||||
["a PNG cut short", "image/png", PNG.subarray(0, 200), /damaged|PNG/],
|
||||
["a PNG whose data does not decode", "image/png", lie, /decoded|visible/],
|
||||
["a type that is not an image", "text/html", Buffer.from("<html>"), /PNG, WebP or SVG/],
|
||||
[
|
||||
"more than the upload limit",
|
||||
"image/png",
|
||||
Buffer.concat([PNG, Buffer.alloc(2 * 1024 * 1024 + 1 - PNG.length)]),
|
||||
/larger than/,
|
||||
],
|
||||
] as const) {
|
||||
const error = await refusal(setLogo(project, "light", type, bytes))
|
||||
expect(error.status, name).toBe(422)
|
||||
expect(JSON.stringify(error.problem), name).toMatch(rule)
|
||||
}
|
||||
expect(await stored(app, project)).toBeNull()
|
||||
expect(logoKeys(app, project)).toEqual([])
|
||||
})
|
||||
|
||||
it("refuses a body that is not base64", async () => {
|
||||
const { project } = await fresh()
|
||||
const text = await refusal(
|
||||
project.call.callByName("branding.logo.set", {
|
||||
org: project.orgSlug,
|
||||
variant: "light",
|
||||
contentType: "image/png",
|
||||
body: "not base64!",
|
||||
}),
|
||||
)
|
||||
expect(text.status).toBe(422)
|
||||
})
|
||||
|
||||
it("is removed from the brand and from the store when it is cleared, and then there is no logo to read", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await setLogo(project, "light", "image/png", PNG)
|
||||
await setLogo(project, "dark", "image/png", png(100))
|
||||
expect(logoKeys(app, project)).toHaveLength(4)
|
||||
await project.call.callByName("branding.logo.clear", { org: project.orgSlug, variant: "light" })
|
||||
expect(logoKeys(app, project)).toHaveLength(2)
|
||||
expect(
|
||||
(await refusal(project.call.callByName("branding.logo.get", { org: project.orgSlug, variant: "light" }))).status,
|
||||
).toBe(404)
|
||||
expect(dimensions((await getLogo(project, "dark")).bytes)).toEqual({ width: 100, height: 60 })
|
||||
})
|
||||
|
||||
it("is not readable by another organization's member", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await setLogo(project, "light", "image/png", PNG)
|
||||
const other = await app.seedProject("other", "owner@other.test")
|
||||
expect(
|
||||
(await refusal(other.call.callByName("branding.logo.get", { org: project.orgSlug, variant: "light" }))).status,
|
||||
).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe("what the organization layout writes from a stored brand", () => {
|
||||
it("is the brand's own colours, legible in both themes", () => {
|
||||
const { style, report } = tenantStyle({ primary: "#1a1a8c", accent: "#f4c430" })
|
||||
expect(style["--primary"]).toMatch(/^light-dark\(#1a1a8c, #[0-9a-f]{6}\)$/)
|
||||
expect(style["--sidebar-primary"]).toBe(style["--primary"])
|
||||
expect(style["--ring"]).toMatch(/^light-dark\(#1a1a8c, #[0-9a-f]{6}\)$/)
|
||||
expect(style["--accent"]).toBe("color-mix(in oklab, #f4c430 18%, var(--background))")
|
||||
expect(style["--primary-foreground"]).toMatch(/^#ffffff$|^light-dark\(#ffffff, #[0-9a-f]{6}\)$/)
|
||||
expect(report.dark?.edgeContrast).toBeGreaterThanOrEqual(3)
|
||||
expect(report.dark?.fillContrast).toBeGreaterThanOrEqual(4.5)
|
||||
expect(report.dark?.inkContrast).toBeGreaterThanOrEqual(4.5)
|
||||
expect(report.light?.inkContrast).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
it("is nothing for an organization with no colour of its own, so the app's brand shows", () => {
|
||||
expect(tenantStyle({ primary: null, accent: null }).style).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Deployment credentials and an unverified tuple cannot establish app authority.
|
||||
import { BUSINESS_REQUEST_BINDING } from "@conusai-kit/business"
|
||||
import { Container } from "@conusai-kit/core"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { activitiesServiceOf } from "../modules/activities/adapters/service.js"
|
||||
import { archiveServiceOf } from "../modules/archive/adapters/service.js"
|
||||
import { catalogServiceOf } from "../modules/catalog/adapters/service.js"
|
||||
import { crmServiceOf } from "../modules/crm/adapters/service.js"
|
||||
import { inventoryServiceOf } from "../modules/inventory/adapters/service.js"
|
||||
|
||||
const env = {
|
||||
CONUSAI_API_URL: "https://cloud.example.test/api",
|
||||
CONUSAI_API_TOKEN: "synthetic-deployment-token",
|
||||
CONUSAI_PROJECT_ID: "7",
|
||||
CONUSAI_ORG_SLUG: "fixture",
|
||||
}
|
||||
describe("business service app partition admission", () => {
|
||||
for (const [name, resolve] of Object.entries({
|
||||
activities: activitiesServiceOf,
|
||||
archive: archiveServiceOf,
|
||||
catalog: catalogServiceOf,
|
||||
crm: crmServiceOf,
|
||||
inventory: inventoryServiceOf,
|
||||
})) {
|
||||
it(`${name} refuses an absent or unverified request binding before network dispatch`, () => {
|
||||
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value)
|
||||
const fetch = vi.spyOn(globalThis, "fetch")
|
||||
try {
|
||||
const unbound = new Container()
|
||||
const forged = new Container()
|
||||
forged.value(BUSINESS_REQUEST_BINDING, {
|
||||
bindingId: "01900000-0000-7000-8000-000000000001",
|
||||
orgSlug: "fixture",
|
||||
client: () => {
|
||||
throw new Error("unverified client must never be constructed")
|
||||
},
|
||||
} as never)
|
||||
for (const container of [unbound, forged])
|
||||
expect(() => resolve(container, "read")).toThrow(
|
||||
expect.objectContaining({ code: "service_partition_missing", status: 409 }),
|
||||
)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
fetch.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,383 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Simulated native transport and actual template admission boundaries; no live readiness is asserted.
|
||||
|
||||
import * as nativeCore from "@conusai-kit/core" // kit:guides // kit:support
|
||||
import { Container, type ServiceAdmissionContext } from "@conusai-kit/core"
|
||||
import * as kitData from "@conusai-kit/data" // kit:guides // kit:support
|
||||
import * as kitGuides from "@conusai-kit/guides" // kit:guides // kit:support
|
||||
import * as kitSupport from "@conusai-kit/support" // kit:guides // kit:support
|
||||
import * as kitTenancy from "@conusai-kit/tenancy" // kit:guides // kit:support
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { nativeHelpSelectors as webSelectors } from "../apps/web/src/server/ports.js" // kit:guides // kit:support
|
||||
import { nativeHelpSelectors as workerSelectors } from "../apps/worker/src/ports.js" // kit:guides // kit:support
|
||||
import { businessServiceComposition } from "../config/src/business-services.js"
|
||||
import { crmModule } from "../modules/crm/module.js"
|
||||
|
||||
const env = {
|
||||
deployment: {
|
||||
CONUSAI_API_URL: "https://platform.example.test/api",
|
||||
CONUSAI_API_TOKEN: "synthetic-token",
|
||||
CONUSAI_PROJECT_ID: "9",
|
||||
},
|
||||
}
|
||||
const context = {
|
||||
container: new Container(),
|
||||
module: "crm",
|
||||
procedure: "crm.party.list",
|
||||
kind: "read",
|
||||
scope: {
|
||||
level: "org",
|
||||
orgId: "01900000-0000-7000-8000-000000000001",
|
||||
actor: { userId: "01900000-0000-7000-8000-000000000002" },
|
||||
},
|
||||
} satisfies ServiceAdmissionContext
|
||||
describe("optional business request composition", () => {
|
||||
it("requires no additional deployment metadata when business modules are absent", () => {
|
||||
expect(businessServiceComposition(env, [], "postgres")).toEqual({})
|
||||
})
|
||||
it("does not send native platform requirements to the business gateway", async () => {
|
||||
const native = {
|
||||
...crmModule,
|
||||
serviceRequirements: crmModule.serviceRequirements.map((requirement) => ({
|
||||
...requirement,
|
||||
transport: "platform" as const,
|
||||
})),
|
||||
}
|
||||
expect(businessServiceComposition(env, [native], "postgres").serviceReadiness).toBeDefined()
|
||||
const hooks = businessServiceComposition(env, [crmModule], "postgres")
|
||||
expect(native.serviceRequirements).toHaveLength(1)
|
||||
for (const requirement of native.serviceRequirements)
|
||||
await expect(hooks.serviceReadiness?.observe(requirement, context)).rejects.toMatchObject({
|
||||
code: "service_observation_missing",
|
||||
})
|
||||
})
|
||||
it("routes native observations through the exact environment and independently expected Cloud identity", async () => {
|
||||
const descriptor = `sha256:${"a".repeat(64)}`
|
||||
const source = "b".repeat(40)
|
||||
const complete = {
|
||||
deployment: {
|
||||
...env.deployment,
|
||||
CONUSAI_ORG_ID: "1",
|
||||
CONUSAI_ENVIRONMENT_ID: "3",
|
||||
CONUSAI_ORG_SLUG: "org",
|
||||
CONUSAI_WORKSPACE_SLUG: "workspace",
|
||||
CONUSAI_PROJECT_SLUG: "project",
|
||||
CONUSAI_PLATFORM_RELEASE_DESCRIPTOR: descriptor,
|
||||
CONUSAI_PLATFORM_SOURCE_COMMIT: source,
|
||||
},
|
||||
}
|
||||
const baseRequirement = crmModule.serviceRequirements[0]
|
||||
if (!baseRequirement) throw new Error("the CRM fixture must declare a service requirement")
|
||||
const requirement = {
|
||||
...baseRequirement,
|
||||
transport: "platform" as const,
|
||||
service: "ocr",
|
||||
apiRange: "=1.0.0-preview.20261103",
|
||||
partitionRequired: false,
|
||||
operations: { "crm.party.list": ["ocr:use"] },
|
||||
}
|
||||
const module = { ...crmModule, serviceRequirements: [requirement] }
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
Response.json({
|
||||
component: {
|
||||
id: "ocr",
|
||||
observed_at: new Date().toISOString(),
|
||||
facts: {
|
||||
installed: "true",
|
||||
enabled: "true",
|
||||
entitled: "true",
|
||||
configured: "true",
|
||||
ready: "true",
|
||||
},
|
||||
},
|
||||
authorized: "true",
|
||||
environment_bound: "true",
|
||||
compatible: "true",
|
||||
dependencies_ready: "true",
|
||||
policy_epoch: 2,
|
||||
descriptor_digest: descriptor,
|
||||
source_commit: source,
|
||||
organization_id: 1,
|
||||
project_id: 9,
|
||||
environment_id: 3,
|
||||
authorization_checked_at: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
try {
|
||||
const hooks = businessServiceComposition(complete, [module], "postgres")
|
||||
const projectContext = {
|
||||
...context,
|
||||
scope: {
|
||||
level: "project" as const,
|
||||
orgId: "01900000-0000-7000-8000-000000000001",
|
||||
workspaceId: "local-workspace",
|
||||
projectId: "local-project",
|
||||
actor: { userId: "01900000-0000-7000-8000-000000000002" },
|
||||
},
|
||||
}
|
||||
await hooks.prepareServices?.(new Container(), projectContext, [requirement])
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
const observation = await hooks.serviceReadiness?.observe(requirement, projectContext)
|
||||
expect(observation?.nativeAuthority?.expectedDescriptorDigest).toBe(descriptor)
|
||||
expect(new URL(String(fetch.mock.calls[0]?.[0])).pathname).toBe(
|
||||
"/api/v1/orgs/org/workspaces/workspace/projects/project/environments/3/platform-services/ocr/readiness",
|
||||
)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
fetch.mockRestore()
|
||||
}
|
||||
})
|
||||
it("constructs no provider or network work and leaves unrelated procedures usable", async () => {
|
||||
const fetch = vi.spyOn(globalThis, "fetch")
|
||||
try {
|
||||
const hooks = businessServiceComposition(env, [crmModule], "postgres")
|
||||
await hooks.prepareServices?.(
|
||||
new Container(),
|
||||
{ ...context, module: "tenancy", procedure: "tenancy.org.list" },
|
||||
[],
|
||||
)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
hooks.prepareServices?.(new Container(), context, crmModule.serviceRequirements ?? []),
|
||||
).rejects.toMatchObject({ code: "service_configuration_missing" })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
fetch.mockRestore()
|
||||
}
|
||||
})
|
||||
it("refuses Convex business dispatch with the supported-profile reason", async () => {
|
||||
const hooks = businessServiceComposition(env, [crmModule], "convex")
|
||||
await expect(
|
||||
hooks.prepareServices?.(new Container(), context, crmModule.serviceRequirements ?? []),
|
||||
).rejects.toMatchObject({ code: "service_profile_unsupported" })
|
||||
})
|
||||
})
|
||||
|
||||
// kit:support:begin
|
||||
// kit:guides:begin
|
||||
// Trusted selectors read the same bound ports that perform actual effects.
|
||||
describe("native help composition", () => {
|
||||
for (const [host, selectors] of [
|
||||
["web", webSelectors],
|
||||
["worker", workerSelectors],
|
||||
] as const) {
|
||||
for (const service of ["support", "guides"] as const) {
|
||||
it(`${host} routes the actual ${service} selector at dispatch and refreshes port changes`, async () => {
|
||||
let container = new Container()
|
||||
const descriptor = `sha256:${"a".repeat(64)}`
|
||||
const source = "b".repeat(40)
|
||||
const deployment = {
|
||||
...env.deployment,
|
||||
CONUSAI_ORG_ID: "1",
|
||||
CONUSAI_ENVIRONMENT_ID: "3",
|
||||
CONUSAI_ORG_SLUG: "org",
|
||||
CONUSAI_WORKSPACE_SLUG: "workspace",
|
||||
CONUSAI_PROJECT_SLUG: "project",
|
||||
CONUSAI_PLATFORM_RELEASE_DESCRIPTOR: descriptor,
|
||||
CONUSAI_PLATFORM_SOURCE_COMMIT: source,
|
||||
}
|
||||
const base = crmModule.serviceRequirements[0]
|
||||
if (!base) throw new Error("CRM fixture requires a service requirement")
|
||||
const requirement = {
|
||||
...base,
|
||||
service,
|
||||
transport: "platform" as const,
|
||||
partitionRequired: false,
|
||||
apiRange: "=1.0.0-preview.20261103",
|
||||
operations: { "crm.party.list": [service === "support" ? "support:widget-sessions" : "guides:runtime"] },
|
||||
}
|
||||
const hooks = businessServiceComposition(
|
||||
{ deployment },
|
||||
[{ ...crmModule, serviceRequirements: [requirement] }],
|
||||
"postgres",
|
||||
selectors,
|
||||
)
|
||||
const readiness = hooks.serviceReadiness
|
||||
if (!readiness) throw new Error("Native readiness must be composed")
|
||||
const dispatch = () => readiness.observe(requirement, { ...context, container })
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
|
||||
Response.json({
|
||||
component: {
|
||||
id: service,
|
||||
observed_at: new Date().toISOString(),
|
||||
facts: { installed: "true", enabled: "true", entitled: "true", configured: "true", ready: "true" },
|
||||
},
|
||||
authorized: "true",
|
||||
environment_bound: "true",
|
||||
compatible: "true",
|
||||
dependencies_ready: "true",
|
||||
policy_epoch: 2,
|
||||
descriptor_digest: descriptor,
|
||||
source_commit: source,
|
||||
organization_id: 1,
|
||||
project_id: 9,
|
||||
environment_id: 3,
|
||||
app_scope: "org",
|
||||
authorization_checked_at: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
const bind = (selector: string) => {
|
||||
container = container.child()
|
||||
if (service === "support")
|
||||
container.value(kitSupport.SUPPORT_PLATFORM, {
|
||||
mode: "platform",
|
||||
channel: selector,
|
||||
client: {},
|
||||
} as kitSupport.SupportPlatform)
|
||||
else
|
||||
container.value(kitGuides.GUIDES_PLATFORM, {
|
||||
mode: "platform",
|
||||
identityNamespace: selector,
|
||||
client: {},
|
||||
} as kitGuides.GuidesPlatform)
|
||||
}
|
||||
await expect(dispatch()).rejects.toMatchObject({ code: "service_configuration_missing" })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
for (const value of ["selected_first", "selected_second"]) {
|
||||
bind(value)
|
||||
await dispatch()
|
||||
const request = JSON.parse(String(fetch.mock.calls.at(-1)?.[1]?.body))
|
||||
expect(String(fetch.mock.calls.at(-1)?.[0])).toContain(`/platform-services/${service}/readiness`)
|
||||
expect(request).toMatchObject({
|
||||
app_scope: "org",
|
||||
[service === "support" ? "channel" : "identity_namespace"]: value,
|
||||
})
|
||||
}
|
||||
bind("invalid/selector")
|
||||
await expect(dispatch()).rejects.toMatchObject({ code: "service_configuration_missing" })
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
container = container.child()
|
||||
if (service === "support") container.value(kitSupport.SUPPORT_PLATFORM, { mode: "offline" })
|
||||
else container.value(kitGuides.GUIDES_PLATFORM, { mode: "offline" })
|
||||
await expect(dispatch()).rejects.toMatchObject({ code: "service_configuration_missing" })
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("admits an actual Guides request through trusted composition and fences removed members and revoked native permission", async () => {
|
||||
const registry = nativeCore.buildRegistry([kitTenancy.tenancyModule, kitGuides.guidesModule])
|
||||
const data = kitData.createMemoryDataSource({ entities: kitData.entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(kitData.DATA_SOURCE, data)
|
||||
root.value(kitData.SYSTEM_DATA, data)
|
||||
root.value(kitGuides.GUIDES_REGISTRY, registry)
|
||||
const descriptor = `sha256:${"a".repeat(64)}`
|
||||
const source = "b".repeat(40)
|
||||
const deployment = {
|
||||
...env.deployment,
|
||||
CONUSAI_ORG_ID: "1",
|
||||
CONUSAI_ENVIRONMENT_ID: "3",
|
||||
CONUSAI_ORG_SLUG: "org",
|
||||
CONUSAI_WORKSPACE_SLUG: "workspace",
|
||||
CONUSAI_PROJECT_SLUG: "project",
|
||||
CONUSAI_PLATFORM_RELEASE_DESCRIPTOR: descriptor,
|
||||
CONUSAI_PLATFORM_SOURCE_COMMIT: source,
|
||||
}
|
||||
let authorized = "true"
|
||||
const runtimeInputs: unknown[] = []
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
if (String(url).includes("/platform-services/guides/readiness"))
|
||||
return Response.json({
|
||||
component: {
|
||||
id: "guides",
|
||||
observed_at: new Date().toISOString(),
|
||||
facts: { installed: "true", enabled: "true", entitled: "true", configured: "true", ready: "true" },
|
||||
},
|
||||
authorized,
|
||||
environment_bound: "true",
|
||||
compatible: "true",
|
||||
dependencies_ready: "true",
|
||||
policy_epoch: 2,
|
||||
descriptor_digest: descriptor,
|
||||
source_commit: source,
|
||||
organization_id: 1,
|
||||
project_id: 9,
|
||||
environment_id: 3,
|
||||
app_scope: "org",
|
||||
authorization_checked_at: new Date().toISOString(),
|
||||
})
|
||||
runtimeInputs.push(url instanceof Request ? await url.clone().json() : JSON.parse(String(init?.body)))
|
||||
return Response.json({ outcomes: [] })
|
||||
})
|
||||
root.value(
|
||||
kitGuides.GUIDES_PLATFORM,
|
||||
kitGuides.guidesPlatform({
|
||||
apiUrl: deployment.CONUSAI_API_URL,
|
||||
token: deployment.CONUSAI_API_TOKEN,
|
||||
fake: false,
|
||||
identityNamespace: "selected_namespace",
|
||||
}),
|
||||
)
|
||||
const hooks = businessServiceComposition(
|
||||
{ deployment },
|
||||
[kitTenancy.tenancyModule, kitGuides.guidesModule],
|
||||
"postgres",
|
||||
webSelectors,
|
||||
)
|
||||
const caller = (actor: nativeCore.Actor) => {
|
||||
const container = root.child()
|
||||
const tenancy = kitTenancy.createTenancyHooks({ data, registry })
|
||||
container.value(kitTenancy.TENANCY, tenancy)
|
||||
return nativeCore.createCaller({ registry, container, tenancy, ...hooks }, { actor })
|
||||
}
|
||||
const owner = await data.global(kitTenancy.users).insert({
|
||||
issuer: "https://identity.example.test",
|
||||
subject: "owner",
|
||||
email: "owner@example.test",
|
||||
displayName: "Owner",
|
||||
locale: "en",
|
||||
})
|
||||
const member = await data.global(kitTenancy.users).insert({
|
||||
issuer: "https://identity.example.test",
|
||||
subject: "member",
|
||||
email: "member@example.test",
|
||||
displayName: "Member",
|
||||
locale: "en",
|
||||
})
|
||||
const actor = { userId: owner.id }
|
||||
const alpha = (await caller(actor).callByName("tenancy.org.create", { slug: "alpha", name: "Alpha" })) as {
|
||||
orgId: string
|
||||
}
|
||||
await caller(actor).callByName("tenancy.org.create", { slug: "bravo", name: "Bravo" })
|
||||
await data.unitOfWork.run({ level: "org", orgId: alpha.orgId, actor }, (tx) =>
|
||||
tx.of(kitTenancy.orgMemberships).insert({ userId: member.id, role: "viewer" }),
|
||||
)
|
||||
const input = {
|
||||
org: "alpha",
|
||||
idempotencyKey: "guides:test",
|
||||
events: [
|
||||
{
|
||||
schema_version: 1,
|
||||
event_id: "018f0000-0000-7000-8000-000000000001",
|
||||
occurred_at: new Date().toISOString(),
|
||||
type: "track",
|
||||
name: "test",
|
||||
},
|
||||
],
|
||||
}
|
||||
const dispatch = (org = "alpha") =>
|
||||
caller({ userId: member.id }).callByName("guides.runtime.record", { ...input, org })
|
||||
await expect(dispatch("bravo")).rejects.toMatchObject({ status: 404 })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
await dispatch()
|
||||
expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toMatchObject({
|
||||
app_scope: "org",
|
||||
identity_namespace: "selected_namespace",
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeInputs[0]).toMatchObject({
|
||||
identity: { identity_namespace: "selected_namespace", subject: member.id, app_tenant: alpha.orgId },
|
||||
})
|
||||
authorized = "false"
|
||||
await expect(dispatch()).rejects.toMatchObject({ code: "service_permission_missing" })
|
||||
expect(fetch).toHaveBeenCalledTimes(3)
|
||||
await caller(actor).callByName("tenancy.member.remove", { org: "alpha", userId: member.id })
|
||||
authorized = "true"
|
||||
await expect(dispatch()).rejects.toMatchObject({ status: 404 })
|
||||
expect(fetch).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:end
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A `transport: "business"` procedure on the fake platform of @conusai-kit/testing, composed as the
|
||||
// app composes it (`businessServiceComposition`): binding lookup and partition admission over HTTP,
|
||||
// then the CRM service in the binding's partition. A fake platform; no live readiness is asserted.
|
||||
|
||||
import { type Actor, buildRegistry, Container, createCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { fakeNativeServices, startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { bindOnFakePlatform, declaredBusinessScopes } from "../apps/worker/src/fake-business-bindings.js"
|
||||
import { businessServiceComposition } from "../config/src/business-services.js"
|
||||
import { crmModule } from "../modules/crm/module.js"
|
||||
|
||||
const simulated = fakeNativeServices({ token: "dev-token" })
|
||||
let platform: Awaited<ReturnType<typeof startFakePlatform>>
|
||||
beforeAll(async () => {
|
||||
platform = await startFakePlatform(0, { deployment: simulated.authority })
|
||||
})
|
||||
afterAll(async () => {
|
||||
await platform.close()
|
||||
})
|
||||
|
||||
async function app() {
|
||||
const registry = buildRegistry([tenancyModule, crmModule])
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
const env = {
|
||||
platform: "fake" as const,
|
||||
production: false,
|
||||
deployment: { ...simulated.deployment, CONUSAI_API_URL: `${platform.url}/api`, CONUSAI_API_TOKEN: "dev-token" },
|
||||
}
|
||||
const hooks = businessServiceComposition(env, [crmModule], "postgres")
|
||||
const caller = (actor: Actor) => {
|
||||
const container = root.child()
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, ...hooks }, { actor })
|
||||
}
|
||||
const owner = await data.global(users).insert({
|
||||
issuer: "https://identity.example.test",
|
||||
subject: "owner",
|
||||
email: "owner@example.test",
|
||||
displayName: "Owner",
|
||||
locale: "en",
|
||||
})
|
||||
const call = caller({ userId: owner.id })
|
||||
const org = (await call.callByName("tenancy.org.create", { slug: "buyer", name: "Buyer" })) as { orgId: string }
|
||||
await call.callByName("tenancy.workspace.create", {
|
||||
org: "buyer",
|
||||
slug: "purchasing",
|
||||
name: "Purchasing",
|
||||
visibility: "org",
|
||||
})
|
||||
await call.callByName("tenancy.project.create", {
|
||||
org: "buyer",
|
||||
workspace: "purchasing",
|
||||
slug: "suppliers",
|
||||
name: "Suppliers",
|
||||
moduleKey: "crm",
|
||||
})
|
||||
return {
|
||||
env,
|
||||
registry,
|
||||
call,
|
||||
orgId: org.orgId,
|
||||
project: { org: "buyer", workspace: "purchasing", project: "suppliers" },
|
||||
}
|
||||
}
|
||||
|
||||
describe("a business procedure on the fake platform", () => {
|
||||
it("binds what the selected modules declare, and nothing for an app without business modules", () => {
|
||||
expect(declaredBusinessScopes(buildRegistry([tenancyModule, crmModule]))).toEqual({
|
||||
crm: expect.arrayContaining(["crm.parties:read", "crm.parties:write"]),
|
||||
})
|
||||
expect(declaredBusinessScopes(buildRegistry([tenancyModule]))).toEqual({})
|
||||
})
|
||||
|
||||
it("is refused until the app tenant is bound, then admitted and served from its partition", async () => {
|
||||
const { env, registry, call, orgId, project } = await app()
|
||||
await expect(call.callByName("crm.party.list", project)).rejects.toMatchObject({
|
||||
code: "service_partition_missing",
|
||||
})
|
||||
|
||||
expect(await bindOnFakePlatform(env, registry, [orgId])).toBe(1)
|
||||
const created = (await call.callByName("crm.party.create", {
|
||||
...project,
|
||||
kind: "organization",
|
||||
displayName: "Synthetic supplier",
|
||||
country: "LT",
|
||||
requestId: "01900000-0000-7000-8000-000000000010",
|
||||
})) as { partyId: string; displayName: string }
|
||||
expect(created.displayName).toBe("Synthetic supplier")
|
||||
const listed = (await call.callByName("crm.party.list", project)) as { rows: Array<{ partyId: string }> }
|
||||
expect(listed.rows.map((row) => row.partyId)).toEqual([created.partyId])
|
||||
|
||||
const binding = platform.business?.calls.find((entry) => entry.binding !== undefined)?.binding
|
||||
expect(binding).toBeDefined()
|
||||
expect(platform.business?.partition(binding as string).crm.parties.size).toBe(1)
|
||||
})
|
||||
|
||||
it("is refused by name once the organization disables the service", async () => {
|
||||
const { env, registry, call, orgId, project } = await app()
|
||||
await bindOnFakePlatform(env, registry, [orgId])
|
||||
platform.business?.disable("crm")
|
||||
try {
|
||||
await expect(call.callByName("crm.party.list", project)).rejects.toMatchObject({ code: "service_not_enabled" })
|
||||
} finally {
|
||||
platform.business?.enable("crm")
|
||||
}
|
||||
})
|
||||
|
||||
it("never provisions outside a development run on the fake platform", async () => {
|
||||
const { env, registry, orgId } = await app()
|
||||
await expect(bindOnFakePlatform({ ...env, platform: "platform" }, registry, [orgId])).rejects.toThrow(
|
||||
/administrator/,
|
||||
)
|
||||
await expect(bindOnFakePlatform({ ...env, production: true }, registry, [orgId])).rejects.toThrow(/administrator/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The sign-out control ends every enabled module's browser context (joint contract §12, J1). Sign-out
|
||||
// is a full navigation, so no provider unmounts: without the lifecycle run, the guides progress and
|
||||
// unsent events of the person who signed out stay in the tab's `sessionStorage`.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { composedClientModules, endClientModules } from "../apps/web/src/ui/client-module-list"
|
||||
|
||||
/** A `Storage` over a Map, as much of it as the modules' handlers use. */
|
||||
function memoryStorage(seed: Record<string, string>): Storage {
|
||||
const values = new Map(Object.entries(seed))
|
||||
const storage = {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
key: (index: number) => [...values.keys()][index] ?? null,
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => values.clear(),
|
||||
}
|
||||
// `Object.keys(storage)` lists the stored keys, as it does for the browser's own.
|
||||
return new Proxy(storage, {
|
||||
ownKeys: () => [...values.keys()],
|
||||
getOwnPropertyDescriptor: (_target, key) =>
|
||||
typeof key === "string" && values.has(key)
|
||||
? { enumerable: true, configurable: true, value: values.get(key) }
|
||||
: undefined,
|
||||
}) as unknown as Storage
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("signing out ends the client modules' context", () => {
|
||||
it("clears what each enabled module keeps in the tab and leaves the app's own keys", async () => {
|
||||
const own = "app.preference"
|
||||
const keys = [own]
|
||||
keys.push("conusai.guides.acme.u-7.queue", "conusai.guides.beta.u-7.active") // kit:guides
|
||||
keys.push("conusai.support.session.v1:spk_example") // kit:support
|
||||
const storage = memoryStorage(Object.fromEntries(keys.map((key) => [key, "{}"])))
|
||||
vi.stubGlobal("window", { sessionStorage: storage })
|
||||
|
||||
const report = await endClientModules("sign-out")
|
||||
|
||||
expect(report.ok).toBe(true)
|
||||
expect(report.outcomes.map((outcome) => outcome.module).sort()).toEqual([...composedClientModules.modules].sort())
|
||||
expect(Object.keys(storage)).toEqual([own])
|
||||
})
|
||||
|
||||
it("issues a new context instance, so a late answer of the old one is discarded", async () => {
|
||||
vi.stubGlobal("window", { sessionStorage: memoryStorage({}) })
|
||||
const before = composedClientModules.current()
|
||||
await endClientModules("sign-out")
|
||||
expect(before.signal.aborted).toBe(true)
|
||||
expect(composedClientModules.isCurrent(before.instance)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
|
||||
...(await import("./support/native-readiness.js")).selectedModules(["guides", "support"]),
|
||||
}))
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Actual module UI and HTTP data client; synthetic server responses only.
|
||||
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { en } from "../../modules/knowledge/i18n/en.js"
|
||||
import { searchFormLabels } from "../../modules/knowledge/ui/labels.js"
|
||||
import { SearchForm } from "../../modules/knowledge/ui/search-form.js"
|
||||
import "@conusai-kit/ui/styles.css"
|
||||
import "@conusai-kit/ui/primitives.css"
|
||||
import "@conusai-kit/ui/patterns.css"
|
||||
|
||||
const scenario = new URLSearchParams(location.search).get("scenario") ?? "cited"
|
||||
const client = createDataClient({ headers: () => ({ "x-fixture-scenario": scenario }) })
|
||||
const t = (key: string, values: Record<string, string | number> = {}): string => {
|
||||
let value: unknown = en
|
||||
for (const part of key.split(".")) value = (value as Record<string, unknown>)[part]
|
||||
if (typeof value !== "string") throw new Error("missing fixture message")
|
||||
for (const [key, replacement] of Object.entries(values)) value = value.replace(`{${key}}`, String(replacement))
|
||||
return value
|
||||
}
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("missing fixture root")
|
||||
createRoot(root).render(
|
||||
<UiLabelsProvider labels={uiLabels(createI18n("en", builtInCatalogues))}>
|
||||
<DataProvider client={client}>
|
||||
<SearchForm
|
||||
slugs={{ org: "synthetic", workspace: "workspace", project: "project" }}
|
||||
collectionName="sources"
|
||||
labels={searchFormLabels(t)}
|
||||
locale="en"
|
||||
/>
|
||||
</DataProvider>
|
||||
</UiLabelsProvider>,
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Chromium + actual module/client, local synthetic HTTP. No provider/auth proof.
|
||||
import assert from "node:assert/strict"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
||||
import http from "node:http"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { chromium } from "@playwright/test"
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const evidence = process.env.LIFECYCLE_BROWSER_EVIDENCE
|
||||
if (!evidence) throw new Error("LIFECYCLE_BROWSER_EVIDENCE must name a new external directory")
|
||||
await mkdir(evidence, { mode: 0o700 })
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), "conusai-knowledge-browser-"))
|
||||
const build = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"build",
|
||||
"test/component-browser/knowledge.fixture.tsx",
|
||||
"--target",
|
||||
"browser",
|
||||
"--format",
|
||||
"iife",
|
||||
"--outdir",
|
||||
temp,
|
||||
"--entry-naming",
|
||||
"fixture.[ext]",
|
||||
],
|
||||
{ cwd: root, encoding: "utf8" },
|
||||
)
|
||||
if (build.status !== 0) throw new Error(`fixture build failed: ${build.stdout}${build.stderr}`)
|
||||
const assets = new Set(await readdir(temp))
|
||||
const network = []
|
||||
const errors = []
|
||||
const consoleErrors = []
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === "/api/v1/knowledge.search") {
|
||||
const scenario = req.headers["x-fixture-scenario"]
|
||||
const status = scenario === "configuration" ? 503 : 200
|
||||
const body =
|
||||
status === 503
|
||||
? { status: 503, code: "configuration_missing", title: "Configuration required" }
|
||||
: {
|
||||
results:
|
||||
scenario === "revoked"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
fileId: "synthetic-file",
|
||||
filename: "synthetic-source.txt",
|
||||
collectionName: "sources",
|
||||
snippet: "A synthetic passage with a current source citation.",
|
||||
score: 0.9,
|
||||
chunk: 1,
|
||||
page: 3,
|
||||
blockId: "p3-b2",
|
||||
chunkId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
],
|
||||
}
|
||||
network.push({ method: req.method, path: req.url, scenario, status })
|
||||
res.writeHead(status, { "content-type": "application/json" })
|
||||
res.end(JSON.stringify(body))
|
||||
return
|
||||
}
|
||||
if (req.url === "/favicon.ico") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.url?.startsWith("/") && assets.has(req.url.slice(1))) {
|
||||
res.writeHead(200, {
|
||||
"content-type": req.url.endsWith(".css")
|
||||
? "text/css"
|
||||
: req.url.endsWith(".woff2")
|
||||
? "font/woff2"
|
||||
: "application/javascript",
|
||||
})
|
||||
res.end(await readFile(path.join(temp, req.url.slice(1))))
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { "content-type": "text/html" })
|
||||
res.end(
|
||||
'<!doctype html><html lang="en"><head><title>Knowledge lifecycle fixture</title><link rel="stylesheet" href="/fixture.css"></head><body><main id="root"></main><script src="/fixture.js"></script></body></html>',
|
||||
)
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
consoleErrors.push(message.text())
|
||||
if (!/503/.test(message.text())) errors.push(message.text())
|
||||
}
|
||||
})
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
for (const scenario of ["cited", "revoked", "configuration"]) {
|
||||
await page.goto(`${base}/?scenario=${scenario}`)
|
||||
await page.getByRole("searchbox", { name: "Question" }).fill("synthetic source")
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click()
|
||||
if (scenario === "cited") {
|
||||
await page.getByText("A synthetic passage with a current source citation.", { exact: true }).waitFor()
|
||||
assert.match(await page.locator("ol").innerText(), /synthetic-source.txt.*result 1.*page 3/s)
|
||||
await page.getByText("p3-b2", { exact: true }).waitFor()
|
||||
} else if (scenario === "revoked") {
|
||||
await page.getByText("No answers in this collection.", { exact: true }).waitFor()
|
||||
assert.equal(await page.locator("blockquote").count(), 0)
|
||||
} else {
|
||||
await page.getByRole("alert").filter({ hasText: "Set CONUSAI_ENVIRONMENT" }).waitFor()
|
||||
assert.equal(await page.locator("blockquote").count(), 0)
|
||||
}
|
||||
await page.screenshot({ path: path.join(evidence, `${scenario}.png`), fullPage: true })
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
const sources = {}
|
||||
for (const file of [
|
||||
"modules/knowledge/ui/search-form.tsx",
|
||||
"modules/knowledge/ui/labels.ts",
|
||||
"modules/knowledge/contract/procedures.ts",
|
||||
"test/component-browser/knowledge.fixture.tsx",
|
||||
])
|
||||
sources[file] = createHash("sha256")
|
||||
.update(await readFile(path.join(root, file)))
|
||||
.digest("hex")
|
||||
await writeFile(
|
||||
path.join(evidence, "result.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
tier: "component-browser-synthetic-http",
|
||||
sourceCommit:
|
||||
process.env.LIFECYCLE_SOURCE_COMMIT ??
|
||||
spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout.trim(),
|
||||
sources,
|
||||
network,
|
||||
consoleErrors,
|
||||
unexpectedErrors: errors,
|
||||
result: "passed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
console.log("Knowledge citations, revocation and configuration refusals passed; fixture browser only")
|
||||
} finally {
|
||||
await browser.close()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: a ${NAME} in this file is what the
|
||||
// compose document holds verbatim -- compose interpolates it from the deployment's env file -- so
|
||||
// the assertions compare against that text and not against a value.
|
||||
|
||||
// compose.yaml: the ordering the platform does not give (decision AT-D14), and the keys the
|
||||
// platform's own compose model accepts.
|
||||
//
|
||||
// The model is crates/conusai-deployer/src/compose_model.rs: a service key it has no field for
|
||||
// fails the deployment's parse, and so does a host binding in `ports`. This suite is that list,
|
||||
// read from the document, so a key added here in a hurry fails in review and not on stage.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { parse } from "yaml"
|
||||
|
||||
interface Service {
|
||||
build?: { context: string; dockerfile: string; target?: string }
|
||||
image?: string
|
||||
command?: string[]
|
||||
environment?: Record<string, string>
|
||||
expose?: (string | number)[]
|
||||
ports?: unknown[]
|
||||
depends_on?: Record<string, { condition: string }>
|
||||
restart?: string
|
||||
healthcheck?: { test: string[]; interval?: string }
|
||||
deploy?: { resources?: { limits?: { cpus?: string; memory?: string } } }
|
||||
init?: boolean
|
||||
working_dir?: string
|
||||
stop_grace_period?: string
|
||||
read_only?: boolean
|
||||
tmpfs?: string[]
|
||||
}
|
||||
|
||||
const document = parse(readFileSync(new URL("../compose.yaml", import.meta.url), "utf8")) as {
|
||||
services: Record<string, Service>
|
||||
}
|
||||
const services = document.services
|
||||
|
||||
const optional = (name: string): string => `\${${name}:-}`
|
||||
|
||||
/**
|
||||
* The passthroughs every profile owes each process (BUG-APP-02): a variable the code reads but
|
||||
* compose never names never reaches the container, so the deployment's value silently loses to
|
||||
* the default. `${VAR}` where boot refuses to run without it, `${VAR:-}` where a default stands.
|
||||
*/
|
||||
function expectPassthroughs(named: Record<string, Service>): void {
|
||||
for (const name of [
|
||||
"OIDC_WEB_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE",
|
||||
"OIDC_INTROSPECTION_CLIENT_ID",
|
||||
"OIDC_INTROSPECTION_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE_STATUS_URL",
|
||||
"OIDC_BACKCHANNEL_URL",
|
||||
"OIDC_SCOPES",
|
||||
// The identity link's admission credential and id (ADR-062): an invitation admits its address.
|
||||
"OIDC_LINK_ID",
|
||||
"OIDC_ADMISSION_TOKEN",
|
||||
"APP_ORGANIZATION_SELF_SERVICE",
|
||||
"AUTH_PROVIDER_GENERATION",
|
||||
"AUTH_COOKIE_NAME",
|
||||
"AUTH_SESSION_IDLE_SECONDS",
|
||||
"AUTH_SESSION_ABSOLUTE_SECONDS",
|
||||
]) {
|
||||
expect(named.web?.environment?.[name], `web ${name}`).toBe(optional(name))
|
||||
}
|
||||
for (const name of ["web", "worker"] as const) {
|
||||
if (named[name]) expect(named[name]?.environment?.CONUSAI_ORG_SLUG).toBe(optional("CONUSAI_ORG_SLUG"))
|
||||
}
|
||||
// The knowledge client's routes name the workspace and the project (K0): compose passes only what it lists.
|
||||
for (const name of ["web", "worker"] as const) {
|
||||
for (const variable of ["CONUSAI_WORKSPACE_SLUG", "CONUSAI_PROJECT_SLUG"]) {
|
||||
if (named[name]) expect(named[name]?.environment?.[variable], `${name} ${variable}`).toBe(optional(variable))
|
||||
}
|
||||
}
|
||||
if (named.worker) {
|
||||
expect(named.worker.environment?.CONUSAI_PUBLIC_URL).toBe("${CONUSAI_PUBLIC_URL}")
|
||||
expect(named.worker.environment?.DOCUMENTS_EXCLUDED_PAGE_CLASSES).toBe(optional("DOCUMENTS_EXCLUDED_PAGE_CLASSES"))
|
||||
}
|
||||
if (named.migrate) expect(named.migrate.environment?.CONUSAI_RELEASE).toBe("${CONUSAI_RELEASE}")
|
||||
for (const name of [
|
||||
"CONUSAI_ENVIRONMENT",
|
||||
"CONUSAI_DSN",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
]) {
|
||||
if (named.migrate) expect(named.migrate.environment?.[name], `migrate ${name}`).toBe(optional(name))
|
||||
}
|
||||
}
|
||||
|
||||
/** Every key TenantService models (compose_model.rs). Anything else fails the platform's parse. */
|
||||
const MODELLED = new Set([
|
||||
"image",
|
||||
"build",
|
||||
"command",
|
||||
"environment",
|
||||
"ports",
|
||||
"expose",
|
||||
"volumes",
|
||||
"depends_on",
|
||||
"restart",
|
||||
"healthcheck",
|
||||
"deploy",
|
||||
"init",
|
||||
"stop_grace_period",
|
||||
"stop_signal",
|
||||
"working_dir",
|
||||
"user",
|
||||
"read_only",
|
||||
"tmpfs",
|
||||
])
|
||||
|
||||
describe("compose.yaml", () => {
|
||||
it("uses the platform's exact read-only private temporary filesystem profile", () => {
|
||||
expect(services.web?.read_only).toBe(true)
|
||||
expect(services.web?.tmpfs).toEqual(["/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777"])
|
||||
})
|
||||
it("has the three services of conusai.json and nothing else", () => {
|
||||
expect(Object.keys(services).sort()).toEqual(["migrate", "web", "worker"])
|
||||
})
|
||||
|
||||
it("uses only the keys the platform's compose model has a field for", () => {
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
for (const key of Object.keys(service)) {
|
||||
expect(MODELLED.has(key), `${name}.${key} is not in the platform's compose model`).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("builds every service from one Dockerfile, so one image serves all three", () => {
|
||||
const builds = Object.values(services).map((service) => JSON.stringify(service.build))
|
||||
expect(new Set(builds).size).toBe(1)
|
||||
expect(Object.values(services).every((service) => service.image === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it("starts each process with its own argv, never a shell string", () => {
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
expect(Array.isArray(service.command), `${name} has no argv command`).toBe(true)
|
||||
}
|
||||
expect(services.migrate?.command?.at(-1)).toContain("migrate.js")
|
||||
})
|
||||
|
||||
it("runs the migrate job to completion before web and worker start", () => {
|
||||
// `kind: job` in conusai.json is inert at deploy time: one `docker compose up -d --wait`
|
||||
// starts everything at once, and `pre_deploy_command` has no executor at all. This is the
|
||||
// ordering (decision AT-D14).
|
||||
expect(services.migrate?.restart).toBe("no")
|
||||
for (const name of ["web", "worker"] as const) {
|
||||
expect(services[name]?.depends_on?.migrate).toEqual({ condition: "service_completed_successfully" })
|
||||
}
|
||||
})
|
||||
|
||||
it("publishes no host port", () => {
|
||||
for (const service of Object.values(services)) expect(service.ports).toBeUndefined()
|
||||
expect(services.web?.expose).toEqual(["3000"])
|
||||
})
|
||||
|
||||
it("gives every service a limit, and the two long-running ones a healthcheck", () => {
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
expect(service.deploy?.resources?.limits?.memory, `${name} has no memory limit`).toBeTruthy()
|
||||
expect(service.deploy?.resources?.limits?.cpus, `${name} has no cpu limit`).toBeTruthy()
|
||||
expect(service.init, `${name} does not reap its children`).toBe(true)
|
||||
}
|
||||
expect(services.web?.healthcheck?.test).toBeTruthy()
|
||||
expect(services.worker?.healthcheck?.test).toBeTruthy()
|
||||
// A job is not healthy or unhealthy; it succeeds or it fails.
|
||||
expect(services.migrate?.healthcheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it("hands the owner's URL to the migrate job alone", () => {
|
||||
expect(services.migrate?.environment?.POSTGRES_MIGRATE_URL).toBe("${POSTGRES_MIGRATE_URL}")
|
||||
expect(services.web?.environment?.POSTGRES_MIGRATE_URL).toBeUndefined()
|
||||
expect(services.worker?.environment?.POSTGRES_MIGRATE_URL).toBeUndefined()
|
||||
for (const name of ["web", "worker", "migrate"] as const) {
|
||||
expect(services[name]?.environment?.POSTGRES_URL).toBe("${POSTGRES_URL}")
|
||||
expect(services[name]?.environment?.POSTGRES_READER_URL).toBe("${POSTGRES_READER_URL}")
|
||||
}
|
||||
})
|
||||
|
||||
it("passes every variable each process reads through to its container", () => {
|
||||
expectPassthroughs(services)
|
||||
})
|
||||
})
|
||||
|
||||
// kit:profile-dialect:begin
|
||||
describe("profiles/mariadb/compose.yaml", () => {
|
||||
const profile = parse(readFileSync(new URL("../profiles/mariadb/compose.yaml", import.meta.url), "utf8")) as {
|
||||
services: Record<string, Service>
|
||||
}
|
||||
|
||||
it("hands the one MariaDB login to every process and the Valkey queue to web and worker", () => {
|
||||
for (const name of ["web", "worker", "migrate"] as const) {
|
||||
expect(profile.services[name]?.environment?.MARIADB_URL).toBe("${MARIADB_URL}")
|
||||
}
|
||||
for (const name of ["web", "worker"] as const) {
|
||||
expect(profile.services[name]?.environment?.REDIS_URL).toBe("${REDIS_URL}")
|
||||
}
|
||||
// MariaDB has no row-level security: there is no owner/runtime/reader split to hand out.
|
||||
expect(profile.services.migrate?.environment?.POSTGRES_MIGRATE_URL).toBeUndefined()
|
||||
})
|
||||
|
||||
it("passes every variable each process reads through to its container", () => {
|
||||
expectPassthroughs(profile.services)
|
||||
})
|
||||
})
|
||||
|
||||
for (const name of ["mongodb", "convex"] as const) {
|
||||
describe(`profiles/${name}/compose.yaml`, () => {
|
||||
const profile = parse(readFileSync(new URL(`../profiles/${name}/compose.yaml`, import.meta.url), "utf8")) as {
|
||||
services: Record<string, Service>
|
||||
}
|
||||
|
||||
it("passes the platform billing slug and web auth settings through to the containers", () => {
|
||||
for (const service of ["web", "worker"] as const) {
|
||||
if (profile.services[service]) {
|
||||
expect(profile.services[service]?.environment?.CONUSAI_ORG_SLUG).toBe(
|
||||
name === "convex" ? `\${CONUSAI_ORG_SLUG}` : optional("CONUSAI_ORG_SLUG"),
|
||||
)
|
||||
for (const variable of ["CONUSAI_WORKSPACE_SLUG", "CONUSAI_PROJECT_SLUG"]) {
|
||||
expect(profile.services[service]?.environment?.[variable], `${service} ${variable}`).toBe(
|
||||
optional(variable),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const variable of [
|
||||
"OIDC_WEB_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE",
|
||||
"OIDC_INTROSPECTION_CLIENT_ID",
|
||||
"OIDC_INTROSPECTION_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE_STATUS_URL",
|
||||
"OIDC_BACKCHANNEL_URL",
|
||||
"OIDC_SCOPES",
|
||||
"OIDC_LINK_ID",
|
||||
"OIDC_ADMISSION_TOKEN",
|
||||
"APP_ORGANIZATION_SELF_SERVICE",
|
||||
"AUTH_PROVIDER_GENERATION",
|
||||
"AUTH_COOKIE_NAME",
|
||||
"AUTH_SESSION_IDLE_SECONDS",
|
||||
"AUTH_SESSION_ABSOLUTE_SECONDS",
|
||||
]) {
|
||||
expect(profile.services.web?.environment?.[variable], `web ${variable}`).toBe(optional(variable))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
// kit:profile-dialect:end
|
||||
|
||||
describe("the local stack", () => {
|
||||
const dev = readFileSync(new URL("../compose.dev.yaml", import.meta.url), "utf8")
|
||||
const roles = readFileSync(new URL("../dev/postgres-roles.sql", import.meta.url), "utf8")
|
||||
const example = readFileSync(new URL("../.env.example", import.meta.url), "utf8")
|
||||
|
||||
it("installs the roles script through dev/postgres-entrypoint.sh, whatever mode the checkout gave it", () => {
|
||||
// A clone under `umask 077` writes the script 0600, which the server's user cannot read under rootless
|
||||
// Docker: no stack mounts it where the server reads it (template lesson core-15).
|
||||
const stacks = [
|
||||
"compose.dev.yaml", // `pnpm dev`
|
||||
"e2e/support/stack.ts", // `pnpm e2e`
|
||||
"e2e/better-auth/stack.ts", // kit:convex-better-auth
|
||||
"test/relay-scope.test.ts", // the worker's own relay
|
||||
]
|
||||
for (const stack of stacks) {
|
||||
const text = readFileSync(new URL(`../${stack}`, import.meta.url), "utf8")
|
||||
expect(text, stack).not.toContain("docker-entrypoint-initdb.d")
|
||||
expect(text, stack).toContain("/conusai-dev/postgres-entrypoint.sh")
|
||||
}
|
||||
})
|
||||
|
||||
it("creates the three roles the platform creates, with the same powers", () => {
|
||||
for (const role of ["appdev_owner", "appdev_app", "appdev_reader"]) {
|
||||
expect(roles, `${role} is not created`).toMatch(new RegExp(`CREATE ROLE ${role}\\s+LOGIN`))
|
||||
}
|
||||
// The three are LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOINHERIT NOREPLICATION,
|
||||
// as data-services DS21 creates them.
|
||||
expect(roles.match(/NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOINHERIT NOREPLICATION/g)).toHaveLength(3)
|
||||
// The owner owns schema public; the runtime role gets rows, never ownership.
|
||||
expect(roles).toContain('ALTER SCHEMA public OWNER TO "appdev_owner"')
|
||||
expect(roles).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "appdev_app"')
|
||||
// The reader gets no table privilege here: the app's own migrations grant it what it reads.
|
||||
expect(roles).not.toMatch(/GRANT SELECT ON (ALL )?TABLES? [^;]*"appdev_reader"/)
|
||||
})
|
||||
|
||||
it("hands each local process the same URL the platform would", () => {
|
||||
expect(example).toContain("POSTGRES_URL=postgresql://appdev_app:")
|
||||
expect(example).toContain("POSTGRES_READER_URL=postgresql://appdev_reader:")
|
||||
expect(example).toContain("POSTGRES_MIGRATE_URL=postgresql://appdev_owner:")
|
||||
})
|
||||
|
||||
it("keeps the development stack out of what is deployed", () => {
|
||||
// conusai.json names compose.yaml alone, so nothing in compose.dev.yaml is ever deployed.
|
||||
expect(dev).toContain("image: postgres:18")
|
||||
expect(readFileSync(new URL("../conusai.json", import.meta.url), "utf8")).not.toContain("compose.dev.yaml")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
import { createTestObserve } from "@conusai-sdk/observe-testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { browserObserveOptions, observeMeta, startBrowserObserve } from "../apps/web/src/observe/browser.js"
|
||||
import {
|
||||
CONSENT_COOKIE,
|
||||
CONSENT_NOTICE_VERSION,
|
||||
CONSENT_SECONDS,
|
||||
consentCookie,
|
||||
privacyRestricted,
|
||||
readConsentChoice,
|
||||
recordConsentChoice,
|
||||
} from "../apps/web/src/observe/consent.js"
|
||||
|
||||
const NOW = Date.parse("2026-10-01T12:00:00.000Z")
|
||||
const choice = (analytics: boolean, replay: boolean) => ({
|
||||
analytics,
|
||||
replay,
|
||||
at: new Date(NOW).toISOString(),
|
||||
notice: CONSENT_NOTICE_VERSION,
|
||||
})
|
||||
|
||||
describe("the app's consent record", () => {
|
||||
it("stores both independent choices for six months and rejects malformed or stale records", () => {
|
||||
const saved = choice(true, false)
|
||||
const cookie = consentCookie(saved)
|
||||
expect(cookie).toContain(`${CONSENT_COOKIE}=`)
|
||||
expect(cookie).toContain(`Max-Age=${CONSENT_SECONDS}`)
|
||||
expect(cookie).toContain("SameSite=Lax; Secure")
|
||||
expect(readConsentChoice(cookie, NOW)).toEqual(saved)
|
||||
expect(readConsentChoice(cookie, NOW + CONSENT_SECONDS * 1000)).toBeUndefined()
|
||||
expect(readConsentChoice(`${CONSENT_COOKIE}=not-json`, NOW)).toBeUndefined()
|
||||
expect(readConsentChoice(consentCookie({ ...saved, notice: "old" }), NOW)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not infer replay from analytics consent", () => {
|
||||
const settings = { release: "r", environment: "production" }
|
||||
expect(browserObserveOptions(settings, choice(true, false)).modules).toEqual({ traces: true, analytics: true })
|
||||
expect(browserObserveOptions(settings, choice(true, true)).modules).toEqual({
|
||||
traces: true,
|
||||
analytics: true,
|
||||
replay: true,
|
||||
})
|
||||
expect(browserObserveOptions(settings, choice(true, true), true).modules).not.toHaveProperty("replay")
|
||||
})
|
||||
})
|
||||
|
||||
describe("restoring and changing a choice", () => {
|
||||
const documentWith = (cookie: string) => ({
|
||||
cookie,
|
||||
querySelector: (selector: string) =>
|
||||
selector === 'meta[name="app-observe"]'
|
||||
? { getAttribute: (name: string) => observeMeta({ release: "r", environment: "e" })[name] ?? null }
|
||||
: null,
|
||||
})
|
||||
|
||||
it("restores analytics without granting replay, and revokes everything on withdrawal", () => {
|
||||
const instance = createTestObserve({ consent: "opt-in", modules: { errors: false } })
|
||||
startBrowserObserve(documentWith(consentCookie(choice(true, false))) as never, () => instance)
|
||||
expect(instance.consent.state()).toBe("granted")
|
||||
|
||||
recordConsentChoice({ analytics: false, replay: false })
|
||||
expect(instance.consent.state()).toBe("denied")
|
||||
})
|
||||
|
||||
it("treats GPC as a refusal even when an older cookie granted both purposes", () => {
|
||||
const instance = createTestObserve({ consent: "opt-in", modules: { errors: false } })
|
||||
startBrowserObserve(documentWith(consentCookie(choice(true, true))) as never, () => instance, {
|
||||
globalPrivacyControl: true,
|
||||
})
|
||||
expect(instance.consent.state()).toBe("denied")
|
||||
expect(privacyRestricted({ doNotTrack: "1" })).toBe(true)
|
||||
expect(privacyRestricted({ globalPrivacyControl: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A deployment the contract refuses says why: `/api/ready` names what to set, every other route is a 503 and no route
|
||||
// a 500, and a message that quotes a value stays in the log. And signing out can reach the identity provider.
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { issuerOrigins, refusalOf, refusalResponse } from "../apps/web/src/server/contract"
|
||||
|
||||
const refusal = refusalOf(new Error("OIDC_ISSUER is required in production"))
|
||||
const request = (path: string): Request => new Request(`https://app.example.test${path}`)
|
||||
|
||||
describe("a process the deployment contract refused", () => {
|
||||
it("names what to set on /api/ready", async () => {
|
||||
const response = refusalResponse(request("/api/ready"), refusal)
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toEqual({
|
||||
ready: false,
|
||||
checks: { contract: false },
|
||||
reason: "OIDC_ISSUER is required in production",
|
||||
})
|
||||
})
|
||||
|
||||
it("answers every other route 503, and says nothing more there", async () => {
|
||||
const response = refusalResponse(request("/acme"), refusal)
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get("retry-after")).toBe("30")
|
||||
expect(await response.json()).toEqual({ status: 503, code: "not_configured" })
|
||||
})
|
||||
|
||||
it("keeps a value out of what the public can read", async () => {
|
||||
const quoted = refusalOf(new Error('CONUSAI_API_URL is "https://secret.example.test"'))
|
||||
expect(quoted.message).toContain("secret.example.test")
|
||||
expect(JSON.stringify(await refusalResponse(request("/api/ready"), quoted).json())).not.toContain("secret")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the form-action of the content security policy", () => {
|
||||
it("adds the issuer's origin, without its path", () => {
|
||||
expect(issuerOrigins("https://id.example.test/oauth/v2")).toEqual(["https://id.example.test"])
|
||||
})
|
||||
|
||||
it("adds nothing with no issuer", () => {
|
||||
expect(issuerOrigins(undefined)).toEqual([])
|
||||
expect(issuerOrigins(" ")).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// conusai.json against the real parser's own schema (AT2.1 done_when).
|
||||
//
|
||||
// `schemas/conusai.schema.json` is generated from crates/conusai-presets, and
|
||||
// scripts/vendor/test-template.sh runs that generator's `--check` before this suite, so what is
|
||||
// validated here is the closed schema the platform parses with: an unknown key fails, and so
|
||||
// does a missing one.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { Ajv2020 } from "ajv/dist/2020.js"
|
||||
import addFormats from "ajv-formats"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { moduleSelected } from "../modules/inclusion.js"
|
||||
|
||||
const repoRoot = process.env.CONUSAI_REPO_ROOT
|
||||
const config = JSON.parse(readFileSync(new URL("../conusai.json", import.meta.url), "utf8")) as {
|
||||
schema_version: number
|
||||
compose: { file: string }
|
||||
services: Record<
|
||||
string,
|
||||
{
|
||||
kind?: string
|
||||
pre_deploy_command?: string
|
||||
public?: { port: number; primary?: boolean }
|
||||
health_check?: { path: string }
|
||||
size?: string
|
||||
postgres?: { migrate?: boolean }
|
||||
}
|
||||
>
|
||||
links: { type: string }[]
|
||||
previews: { enabled: boolean; size?: string }
|
||||
token?: { permissions: string[] }
|
||||
guides?: { bundle: string }
|
||||
}
|
||||
|
||||
describe("conusai.json", () => {
|
||||
it.runIf(repoRoot)("validates against the generated conusai.json schema", () => {
|
||||
const schema = JSON.parse(readFileSync(join(repoRoot as string, "schemas/conusai.schema.json"), "utf8")) as object
|
||||
const ajv = new Ajv2020({ strict: false, allErrors: true })
|
||||
addFormats(ajv)
|
||||
const validate = ajv.compile(schema)
|
||||
const valid = validate(config)
|
||||
expect(validate.errors ?? [], JSON.stringify(validate.errors, null, 2)).toEqual([])
|
||||
expect(valid).toBe(true)
|
||||
})
|
||||
|
||||
it("is schema version 2 and names the compose file", () => {
|
||||
expect(config.schema_version).toBe(2)
|
||||
expect(config.compose.file).toBe("compose.yaml")
|
||||
})
|
||||
|
||||
it("declares web, worker and the migrate service, all of one image and none of them a job", () => {
|
||||
expect(Object.keys(config.services).sort()).toEqual(["migrate", "web", "worker"])
|
||||
// kind "job" and pre_deploy_command are gone (KI-197): the migrate service is an ordinary
|
||||
// service whose ordering comes from compose's depends_on (AT-D14).
|
||||
for (const [name, service] of Object.entries(config.services)) {
|
||||
expect(service.kind, `${name}.kind`).toBeUndefined()
|
||||
expect(service.pre_deploy_command, `${name}.pre_deploy_command`).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it.runIf(!moduleSelected("guides"))("does not publish Guides before explicit deployment configuration", () => {
|
||||
expect(config.guides).toBeUndefined()
|
||||
for (const service of Object.values(config.services)) expect(service).not.toHaveProperty("guides")
|
||||
})
|
||||
|
||||
it.runIf(moduleSelected("guides"))("declares the selected starter’s native code-guides bundle", () => {
|
||||
expect(config.guides?.bundle).toBe("/app/.conusai/guides-bundle.json")
|
||||
})
|
||||
|
||||
it("exposes web alone, on its own health path", () => {
|
||||
expect(config.services.web?.public).toEqual({ port: 3000, primary: true })
|
||||
// The path the platform probes is the path compose probes: one definition of "this web container is
|
||||
// fit to serve", /api/ready (store, reader, migration level), and not a second, weaker one.
|
||||
expect(config.services.web?.health_check?.path).toBe("/api/ready")
|
||||
const compose = readFileSync(new URL("../compose.yaml", import.meta.url), "utf8")
|
||||
expect(compose).toContain("http://127.0.0.1:3000/api/ready")
|
||||
expect(config.services.worker?.public).toBeUndefined()
|
||||
expect(config.services.migrate?.public).toBeUndefined()
|
||||
})
|
||||
|
||||
it("declares exactly one migrator, which is the migrate job (data-services DS21)", () => {
|
||||
const migrators = Object.entries(config.services).filter(([, service]) => service.postgres?.migrate === true)
|
||||
expect(migrators.map(([name]) => name)).toEqual(["migrate"])
|
||||
})
|
||||
|
||||
it("links a Postgres service, and one sign-in link", () => {
|
||||
// The identity link is what injects OIDC_ISSUER/OIDC_WEB_CLIENT_ID/OIDC_AUDIENCE at
|
||||
// deploy (MIB I3); without the declaration the app boots signed-out-only (KI-199).
|
||||
const links = config.links as Array<{ type: string; role?: string }>
|
||||
expect(links[0]?.type).toBe("postgres")
|
||||
const signInLinks = ["identity"]
|
||||
signInLinks.push("better-auth") // kit:convex-better-auth -- the named Convex link replaces Identity (BL)
|
||||
expect(links.filter((link) => signInLinks.includes(link.role ?? link.type))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("keeps the actual base file, metering, account and branding scope minimums across profiles", () => {
|
||||
const base = ["blob:read", "blob:write", "usage:write", "billing:ingest", "privacy:subjects", "branding:read"]
|
||||
expect(config.token?.permissions).toEqual(expect.arrayContaining(base))
|
||||
const profiles = new URL("../profiles/", import.meta.url)
|
||||
for (const profile of existsSync(profiles) ? readdirSync(profiles) : []) {
|
||||
const file = new URL(`${profile}/conusai.json`, profiles)
|
||||
if (!existsSync(file)) continue
|
||||
const copy = JSON.parse(readFileSync(file, "utf8"))
|
||||
expect(copy.token.permissions).toEqual(expect.arrayContaining(base))
|
||||
if (!moduleSelected("guides")) expect(copy.guides).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
import {
|
||||
applyIdentityMappings,
|
||||
IDENTITY_MIGRATION_FORMAT,
|
||||
type IdentityMigrationBundle,
|
||||
identityIssuerCoverage,
|
||||
identityMigrationDigest,
|
||||
} from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { createConvexAuthDataSource } from "../apps/web/src/server/convex-auth-data"
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
queries: [] as { args: unknown }[],
|
||||
mutations: [] as { args: unknown }[],
|
||||
}))
|
||||
|
||||
vi.mock("convex/browser", () => ({
|
||||
ConvexHttpClient: class {
|
||||
setAdminAuth(): void {}
|
||||
query(_reference: unknown, args: unknown): Promise<unknown> {
|
||||
calls.queries.push({ args })
|
||||
return Promise.resolve({ source: 1, target: 1 })
|
||||
}
|
||||
mutation(_reference: unknown, args: unknown): Promise<unknown> {
|
||||
calls.mutations.push({ args })
|
||||
return Promise.resolve({ migrationId: "7", applied: 1 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// The template ships without the platform repository, so the bundle is built here with the kit's own
|
||||
// digest; the kit's tests pin that digest to the platform's shared fixture.
|
||||
const unsigned = {
|
||||
migration_id: 7,
|
||||
source: { issuer: "https://old.example.test", audience: "old-audience" },
|
||||
target: { issuer: "https://new.example.test/api/auth", audience: "urn:conusai:identity:2:3:4" },
|
||||
mappings: [{ source_subject: "source-1", target_subject: "target-1" }],
|
||||
}
|
||||
const sharedBundle: IdentityMigrationBundle = {
|
||||
format: IDENTITY_MIGRATION_FORMAT,
|
||||
...unsigned,
|
||||
digest: await identityMigrationDigest(unsigned),
|
||||
}
|
||||
|
||||
describe("Convex auth identity migrations", () => {
|
||||
it("uses the private mapping mutation and coverage query", async () => {
|
||||
calls.queries.length = 0
|
||||
calls.mutations.length = 0
|
||||
const data = createConvexAuthDataSource({ url: "https://convex.example.test", adminKey: "test-admin-key" })
|
||||
const bundle = sharedBundle
|
||||
const options = {
|
||||
bundle,
|
||||
staged: {
|
||||
migrationId: String(bundle.migration_id),
|
||||
digest: bundle.digest,
|
||||
targetIssuer: bundle.target.issuer,
|
||||
targetAudience: bundle.target.audience,
|
||||
},
|
||||
active: bundle.source,
|
||||
}
|
||||
|
||||
await applyIdentityMappings(data, options)
|
||||
expect(calls.mutations).toEqual([{ args: { options } }])
|
||||
|
||||
expect(await identityIssuerCoverage(data, { source: bundle.source.issuer, target: bundle.target.issuer })).toEqual({
|
||||
source: 1,
|
||||
target: 1,
|
||||
})
|
||||
expect(calls.queries).toEqual([
|
||||
{ args: { issuers: { source: bundle.source.issuer, target: bundle.target.issuer } } },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
||||
import type { AddressInfo } from "node:net"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { syncPlatformEnvironment } from "../profiles/convex/sync-env.mjs"
|
||||
|
||||
const servers: Server[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections()
|
||||
server.close(() => resolve())
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
/** A promise the test resolves when something it waits on has happened (the tests' lib is ES2023). */
|
||||
function signal() {
|
||||
let resolve = () => {}
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function listen(handler: (req: IncomingMessage, res: ServerResponse) => void) {
|
||||
const server = createServer(handler)
|
||||
servers.push(server)
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
}
|
||||
|
||||
async function fixture(options: { queryStatus?: number; updateStatus?: number } = {}) {
|
||||
const values = new Map([
|
||||
["EXISTING", "fixture-original"],
|
||||
["UNLISTED", "fixture-untouched"],
|
||||
])
|
||||
const requests: string[] = []
|
||||
let authenticated = true
|
||||
const url = await listen((req, res) => {
|
||||
requests.push(req.url ?? "")
|
||||
authenticated &&= req.headers.authorization === "Convex fixture-placeholder"
|
||||
const chunks: Buffer[] = []
|
||||
req.on("data", (chunk) => chunks.push(chunk))
|
||||
req.on("end", () => {
|
||||
if (req.url === "/api/query") {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString())
|
||||
if (body.path !== "_system/cli/queryEnvironmentVariables" || req.method !== "POST") {
|
||||
res.writeHead(400).end()
|
||||
return
|
||||
}
|
||||
res.writeHead(options.queryStatus ?? 200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: "success",
|
||||
value: [...values].map(([name, value]) => ({ name, value })),
|
||||
}),
|
||||
)
|
||||
} else if (req.url === "/api/update_environment_variables") {
|
||||
if (options.updateStatus) {
|
||||
res.writeHead(options.updateStatus).end("fixture-sensitive-error-body")
|
||||
return
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString())
|
||||
for (const entry of body.changes) values.set(entry.name, entry.value)
|
||||
res.writeHead(200).end()
|
||||
} else {
|
||||
res.writeHead(404).end()
|
||||
}
|
||||
})
|
||||
})
|
||||
// The sync's own default deadline (30 s) is only a hang bound here: these cases end on the
|
||||
// fixture's answers, and the deadline itself is tested with a faked clock below.
|
||||
const sync = (variables: Record<string, string>) =>
|
||||
syncPlatformEnvironment({ url, adminKey: "fixture-placeholder", variables })
|
||||
return { values, requests, sync, authenticated: () => authenticated }
|
||||
}
|
||||
|
||||
describe("Convex deployment environment sync through HTTP-only relays", () => {
|
||||
it("creates missing values, skips equal values and preserves unlisted values", async () => {
|
||||
const backend = await fixture()
|
||||
await backend.sync({ NEW: "fixture-new", EXISTING: "fixture-original" })
|
||||
expect(backend.values.get("NEW")).toBe("fixture-new")
|
||||
expect(backend.values.get("EXISTING")).toBe("fixture-original")
|
||||
expect(backend.values.get("UNLISTED")).toBe("fixture-untouched")
|
||||
expect(backend.requests).toEqual(["/api/query", "/api/update_environment_variables"])
|
||||
expect(backend.authenticated()).toBe(true)
|
||||
await backend.sync({ NEW: "fixture-new", EXISTING: "fixture-original" })
|
||||
expect(backend.requests).toEqual(["/api/query", "/api/update_environment_variables", "/api/query"])
|
||||
})
|
||||
|
||||
it("refuses the entire batch on a conflict, without overwriting or adding values", async () => {
|
||||
const backend = await fixture()
|
||||
await expect(backend.sync({ NEW: "fixture-new", EXISTING: "fixture-conflict" })).rejects.toThrow(
|
||||
"Convex platform service environment sync failed",
|
||||
)
|
||||
expect(backend.values.has("NEW")).toBe(false)
|
||||
expect(backend.values.get("EXISTING")).toBe("fixture-original")
|
||||
expect(backend.requests).toEqual(["/api/query"])
|
||||
})
|
||||
|
||||
it.each([401, 403, 500])("fails a refused query (%i) without update or automatic retries", async (queryStatus) => {
|
||||
const backend = await fixture({ queryStatus })
|
||||
await expect(backend.sync({ NEW: "fixture-new" })).rejects.toThrow(
|
||||
"Convex platform service environment sync failed",
|
||||
)
|
||||
expect(backend.requests).toEqual(["/api/query"])
|
||||
expect(backend.values.has("NEW")).toBe(false)
|
||||
})
|
||||
|
||||
it("fails an update without exposing the response body or retrying it", async () => {
|
||||
const backend = await fixture({ updateStatus: 503 })
|
||||
await expect(backend.sync({ NEW: "fixture-new" })).rejects.toThrow(
|
||||
/^Convex platform service environment sync failed$/,
|
||||
)
|
||||
expect(backend.requests).toEqual(["/api/query", "/api/update_environment_variables"])
|
||||
expect(backend.values.has("NEW")).toBe(false)
|
||||
})
|
||||
|
||||
it("bounds the whole query and update sequence with one deadline", async () => {
|
||||
// The deadline is the sync's own timer, so the test owns the clock: only setTimeout and
|
||||
// clearTimeout are faked, and each response is held until the test has moved time. Nothing
|
||||
// here depends on how fast the host runs; a former real-time version (80 ms responses, a 120 ms
|
||||
// deadline) failed whenever the query alone took longer than the deadline.
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })
|
||||
try {
|
||||
const requests: string[] = []
|
||||
const arrived = { query: signal(), update: signal() }
|
||||
let releaseQuery = () => {}
|
||||
const url = await listen((req, res) => {
|
||||
requests.push(req.url ?? "")
|
||||
req.resume()
|
||||
req.on("end", () => {
|
||||
if (req.url === "/api/query") {
|
||||
releaseQuery = () => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ status: "success", value: [] }))
|
||||
}
|
||||
arrived.query.resolve()
|
||||
} else {
|
||||
// Held: only the deadline can end this request.
|
||||
arrived.update.resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
const sync = syncPlatformEnvironment({
|
||||
url,
|
||||
adminKey: "fixture-placeholder",
|
||||
variables: { NEW: "fixture-new" },
|
||||
timeoutMs: 1000,
|
||||
})
|
||||
let settled = false
|
||||
sync.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true),
|
||||
)
|
||||
|
||||
await arrived.query.promise
|
||||
vi.advanceTimersByTime(600)
|
||||
releaseQuery()
|
||||
await arrived.update.promise
|
||||
// 600 ms of the 1000 ms are spent. One more turn of the event loop must not end the sync.
|
||||
vi.advanceTimersByTime(399)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
// The 1000th millisecond ends it; a deadline restarted for the update would still have
|
||||
// 600 ms left, and the test would stop at vitest's hang bound instead.
|
||||
vi.advanceTimersByTime(1)
|
||||
await expect(sync).rejects.toThrow("Convex platform service environment sync failed")
|
||||
expect(requests).toEqual(["/api/query", "/api/update_environment_variables"])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("aborts a stalled response body instead of waiting indefinitely", async () => {
|
||||
// As above, the test owns the deadline's clock: time passes only once the stalled body has
|
||||
// been written, however long the host takes to get the request there.
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })
|
||||
try {
|
||||
let requests = 0
|
||||
const written = signal()
|
||||
const url = await listen((_req, res) => {
|
||||
requests++
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.write('{"status":"success","value":', () => written.resolve())
|
||||
})
|
||||
const sync = syncPlatformEnvironment({
|
||||
url,
|
||||
adminKey: "fixture-placeholder",
|
||||
variables: { NEW: "fixture-new" },
|
||||
timeoutMs: 50,
|
||||
})
|
||||
await written.promise
|
||||
vi.advanceTimersByTime(50)
|
||||
await expect(sync).rejects.toThrow("Convex platform service environment sync failed")
|
||||
expect(requests).toBe(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses redirects before any credential can reach another host", async () => {
|
||||
let foreignRequests = 0
|
||||
const foreign = await listen((_req, res) => {
|
||||
foreignRequests++
|
||||
res.writeHead(200).end()
|
||||
})
|
||||
const url = await listen((_req, res) => res.writeHead(307, { Location: foreign }).end())
|
||||
await expect(
|
||||
syncPlatformEnvironment({ url, adminKey: "fixture-placeholder", variables: { NEW: "fixture-new" } }),
|
||||
).rejects.toThrow("Convex platform service environment sync failed")
|
||||
expect(foreignRequests).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex profile's live stream in the web process (apps/web/src/server/convex-live.ts) and the write
|
||||
// txid of its procedure transport (convex-caller.ts, umbrella TS6.3), against fake Convex clients: the
|
||||
// log source reads `live:head`/`live:changes` and subscribes to `live:head` once per organization while a
|
||||
// stream of it is open; `live:open`'s refusals are the live problems the Postgres profile answers (401
|
||||
// signed out, 404 not reached) and its denials pass through; the heartbeat asks `live:recheck`; a write
|
||||
// is called with `conusaiLiveTxid` and its envelope's txid becomes the request's `ConusAI-Txid`.
|
||||
// The Convex functions themselves (convex/live.ts) run in a deployment; this file does not start one.
|
||||
|
||||
import { captureTxids } from "@conusai-kit/data-kysely"
|
||||
import { convexLiveLog, createLiveServer, LiveProblem, type OpenRequest } from "@conusai-kit/live-server"
|
||||
import { getFunctionName, type UserIdentityAttributes } from "convex/server"
|
||||
import { ConvexError } from "convex/values"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
/** Every call a fake ConvexHttpClient of convex-caller.ts received: [kind, function, args]. */
|
||||
const httpCalls: Array<[string, string, Record<string, unknown>]> = []
|
||||
/** What that client answers; a test sets `answer` for the calls it makes. */
|
||||
const http: { answer: (kind: string, name: string, args: Record<string, unknown>) => unknown } = { answer: () => null }
|
||||
|
||||
vi.mock("convex/browser", () => {
|
||||
class FakeHttpClient {
|
||||
setAdminAuth(): void {}
|
||||
async query(reference: never, args: Record<string, unknown>) {
|
||||
httpCalls.push(["query", getFunctionName(reference), args])
|
||||
return http.answer("query", getFunctionName(reference), args)
|
||||
}
|
||||
async mutation(reference: never, args: Record<string, unknown>) {
|
||||
httpCalls.push(["mutation", getFunctionName(reference), args])
|
||||
return http.answer("mutation", getFunctionName(reference), args)
|
||||
}
|
||||
async action(reference: never, args: Record<string, unknown>) {
|
||||
httpCalls.push(["action", getFunctionName(reference), args])
|
||||
return http.answer("action", getFunctionName(reference), args)
|
||||
}
|
||||
}
|
||||
return { ConvexHttpClient: FakeHttpClient, ConvexClient: class {} }
|
||||
})
|
||||
|
||||
const { convexLiveAuthorize, convexLiveSource } = await import("../apps/web/src/server/convex-live")
|
||||
const { unwrapLiveTxid } = await import("../apps/web/src/server/convex-caller")
|
||||
const { createConvexCaller } = await import("../apps/web/src/server/convex-caller") // kit:notes
|
||||
|
||||
const ORG = "org-1"
|
||||
const HEAD = { txid: 4, prunedThrough: 0 }
|
||||
|
||||
/** A fake subscribing client: what is subscribed now, and a push of a new head. */
|
||||
function fakeWatcher() {
|
||||
const subscriptions = new Map<number, { orgId: string; callback: (value: unknown) => unknown }>()
|
||||
let next = 0
|
||||
const client = {
|
||||
opened: 0,
|
||||
closed: 0,
|
||||
subscriptions,
|
||||
onUpdate(reference: never, args: never, callback: (value: unknown) => unknown) {
|
||||
expect(getFunctionName(reference)).toBe("live:head")
|
||||
const id = next++
|
||||
subscriptions.set(id, { orgId: (args as { orgId: string }).orgId, callback })
|
||||
callback(HEAD)
|
||||
return () => subscriptions.delete(id)
|
||||
},
|
||||
async close() {
|
||||
client.closed += 1
|
||||
},
|
||||
push(orgId: string, head: unknown) {
|
||||
for (const entry of subscriptions.values()) if (entry.orgId === orgId) entry.callback(head)
|
||||
},
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
function fakeSource() {
|
||||
const watcher = fakeWatcher()
|
||||
const reads: Array<[string, Record<string, unknown>]> = []
|
||||
const source = convexLiveSource({
|
||||
admin: {
|
||||
async query(reference: never, args: never) {
|
||||
const name = getFunctionName(reference)
|
||||
reads.push([name, args as Record<string, unknown>])
|
||||
if (name === "live:head") return HEAD
|
||||
if (name === "live:changes") return { rows: [], head: HEAD, more: false }
|
||||
throw new Error(`unexpected ${name}`)
|
||||
},
|
||||
},
|
||||
watcher: () => {
|
||||
watcher.opened += 1
|
||||
return watcher
|
||||
},
|
||||
})
|
||||
return { source, watcher, reads }
|
||||
}
|
||||
|
||||
const identityOf = (userId: string): UserIdentityAttributes => ({
|
||||
issuer: "https://conusai.internal/bff",
|
||||
subject: userId,
|
||||
tokenIdentifier: `https://conusai.internal/bff|${userId}`,
|
||||
})
|
||||
|
||||
const OPEN: OpenRequest = {
|
||||
protocol: "clp/1",
|
||||
after: null,
|
||||
entities: ["notes.note", "notes.secret"],
|
||||
topics: [{ ws: "main", project: "notes" }],
|
||||
}
|
||||
|
||||
const request = () => new Request(`http://app.test/api/v1/orgs/acme/live`, { method: "POST" })
|
||||
|
||||
const problem = (status: number, code: string) =>
|
||||
new ConvexError({ type: `/problems/${code}`, title: code, status, code })
|
||||
|
||||
/** The authorize of convex-live.ts over a fake caller and a fake `live:open`/`live:recheck`. */
|
||||
function authorizeWith(answer: (name: string, args: Record<string, unknown>) => unknown, userId = () => "u-ada") {
|
||||
const calls: Array<[string, UserIdentityAttributes, Record<string, unknown>]> = []
|
||||
const warn = vi.fn()
|
||||
const authorize = convexLiveAuthorize({
|
||||
caller: async () => ({ userId: userId(), session: "s-1", identity: identityOf(userId()) }),
|
||||
clientFor: (identity) => ({
|
||||
async query(reference: never, args: never) {
|
||||
const name = getFunctionName(reference)
|
||||
calls.push([name, identity, args as Record<string, unknown>])
|
||||
return answer(name, args as Record<string, unknown>)
|
||||
},
|
||||
}),
|
||||
orgSlugOf: () => "acme",
|
||||
warn,
|
||||
})
|
||||
return { authorize, calls, warn }
|
||||
}
|
||||
|
||||
const granted = {
|
||||
org: ORG,
|
||||
topics: [{ ws: "main", project: "notes", ids: { org: ORG, ws: "w-1", project: "p-1" } }],
|
||||
entities: ["notes.note"],
|
||||
denied: [{ entity: "notes.secret", reason: "unknown" }],
|
||||
}
|
||||
|
||||
describe("the Convex live log source", () => {
|
||||
it("reads the head and the changes with the admin client, and positions them as convexLiveLog does", async () => {
|
||||
const { source, reads } = fakeSource()
|
||||
const log = convexLiveLog({ source })
|
||||
expect(await log.head(ORG)).toEqual({ position: { txid: 5n, seq: 0n }, w: 5n })
|
||||
const read = await log.read(ORG, { txid: 3n, seq: 2n }, 50)
|
||||
expect(read).toMatchObject({ final: [], more: false, w: 5n, position: { txid: 5n, seq: 0n } })
|
||||
expect(reads).toEqual([
|
||||
["live:head", { orgId: ORG }],
|
||||
["live:changes", { orgId: ORG, after: { txid: 3, seq: 2 }, limit: 50 }],
|
||||
])
|
||||
})
|
||||
|
||||
it("subscribes to an organization's head once while its streams are open, and ends it with the last one", async () => {
|
||||
const { source, watcher } = fakeSource()
|
||||
const log = convexLiveLog({ source })
|
||||
const woken: Array<string | null> = []
|
||||
log.onWake((org) => woken.push(org))
|
||||
const first = log.watch?.(ORG)
|
||||
const second = log.watch?.(ORG)
|
||||
const other = log.watch?.("org-2")
|
||||
// One websocket client, one subscription per organization.
|
||||
expect(watcher.opened).toBe(1)
|
||||
expect([...watcher.subscriptions.values()].map(({ orgId }) => orgId)).toEqual([ORG, "org-2"])
|
||||
woken.length = 0
|
||||
watcher.push(ORG, { txid: 5, prunedThrough: 0 })
|
||||
expect(woken).toEqual([ORG])
|
||||
first?.()
|
||||
first?.()
|
||||
expect(watcher.subscriptions.size).toBe(2)
|
||||
second?.()
|
||||
expect([...watcher.subscriptions.values()].map(({ orgId }) => orgId)).toEqual(["org-2"])
|
||||
other?.()
|
||||
expect(watcher.subscriptions.size).toBe(0)
|
||||
await log.close()
|
||||
expect(watcher.closed).toBe(1)
|
||||
})
|
||||
|
||||
it("is what a live server's streams subscribe through: the subscription follows the open streams", async () => {
|
||||
const { source, watcher } = fakeSource()
|
||||
const { authorize } = authorizeWith(() => granted)
|
||||
const server = createLiveServer({
|
||||
engine: "convex",
|
||||
log: convexLiveLog({ source }),
|
||||
authorize,
|
||||
heartbeatMs: 60_000,
|
||||
})
|
||||
const open = async () => {
|
||||
const abort = new AbortController()
|
||||
const response = await server.handle(
|
||||
new Request("http://app.test/api/v1/orgs/acme/live", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ protocol: "clp/1", after: null, entities: ["notes.note"], topics: [] }),
|
||||
signal: abort.signal,
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const reader = response.body?.getReader()
|
||||
const first = new TextDecoder().decode((await reader?.read())?.value)
|
||||
expect(first).toContain("event: hello")
|
||||
return { abort, reader }
|
||||
}
|
||||
const a = await open()
|
||||
const b = await open()
|
||||
expect(server.open).toBe(2)
|
||||
expect(watcher.subscriptions.size).toBe(1)
|
||||
a.abort.abort()
|
||||
expect(watcher.subscriptions.size).toBe(1)
|
||||
b.abort.abort()
|
||||
expect(watcher.subscriptions.size).toBe(0)
|
||||
expect(server.open).toBe(0)
|
||||
await server.close()
|
||||
expect(watcher.closed).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the Convex profile's stream authorization", () => {
|
||||
it("asks live:open as the caller and passes its grants and denials through", async () => {
|
||||
const { authorize, calls } = authorizeWith(() => granted)
|
||||
const grant = await authorize(request(), {
|
||||
...OPEN,
|
||||
topics: [{ ws: "main", project: "notes", extra: "dropped" } as never],
|
||||
})
|
||||
expect(grant.org).toBe(ORG)
|
||||
expect(grant.session).toBe("s-1")
|
||||
expect(grant.granted).toEqual({ entities: ["notes.note"], topics: granted.topics })
|
||||
expect(grant.denied).toEqual([{ entity: "notes.secret", reason: "unknown" }])
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
"live:open",
|
||||
identityOf("u-ada"),
|
||||
{ org: "acme", topics: [{ ws: "main", project: "notes" }], entities: ["notes.note", "notes.secret"] },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("answers live:open's refusals as the Postgres profile does: 401 signed out, 404 not reached", async () => {
|
||||
for (const [status, type] of [
|
||||
[401, "urn:conusai:live:unauthenticated"],
|
||||
[404, "urn:conusai:live:not-found"],
|
||||
] as const) {
|
||||
const { authorize } = authorizeWith(() => {
|
||||
throw problem(status, status === 401 ? "unauthenticated" : "not_found")
|
||||
})
|
||||
const refused = await authorize(request(), OPEN).catch((error: unknown) => error)
|
||||
expect(refused).toBeInstanceOf(LiveProblem)
|
||||
expect(refused).toMatchObject({ status, type })
|
||||
}
|
||||
// Another problem of the kernel's stays the kernel's (serveLive answers it as a procedure's).
|
||||
const { authorize } = authorizeWith(() => {
|
||||
throw problem(429, "rate_limited")
|
||||
})
|
||||
await expect(authorize(request(), OPEN)).rejects.toMatchObject({ status: 429, code: "rate_limited" })
|
||||
})
|
||||
|
||||
it("re-checks every heartbeat with live:recheck, and revokes a changed caller, a lost membership or a lost read", async () => {
|
||||
let recheck: () => unknown = () => null
|
||||
let user = "u-ada"
|
||||
const { authorize, calls, warn } = authorizeWith(
|
||||
(name) => (name === "live:open" ? granted : recheck()),
|
||||
() => user,
|
||||
)
|
||||
const grant = await authorize(request(), OPEN)
|
||||
expect(await grant.recheck()).toBeNull()
|
||||
expect(calls.at(-1)).toEqual([
|
||||
"live:recheck",
|
||||
identityOf("u-ada"),
|
||||
// The entities the stream was granted, so the deployment re-checks each one's read permission.
|
||||
{ org: "acme", topics: [{ ws: "main", project: "notes" }], granted: ["notes.note"] },
|
||||
])
|
||||
recheck = () => "grants_changed"
|
||||
expect(await grant.recheck()).toBe("grants_changed")
|
||||
recheck = () => "membership"
|
||||
expect(await grant.recheck()).toBe("membership")
|
||||
recheck = () => {
|
||||
throw problem(404, "not_found")
|
||||
}
|
||||
expect(await grant.recheck()).toBe("membership")
|
||||
recheck = () => {
|
||||
throw problem(401, "unauthenticated")
|
||||
}
|
||||
expect(await grant.recheck()).toBe("session")
|
||||
recheck = () => {
|
||||
throw new Error("the deployment is unreachable")
|
||||
}
|
||||
expect(await grant.recheck()).toBeNull()
|
||||
expect(warn).toHaveBeenCalledWith("live: the re-check failed: Error")
|
||||
user = "u-grace"
|
||||
expect(await grant.recheck()).toBe("session")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the Convex profile's write txid", () => {
|
||||
it("unwraps a tenant mutation's envelope and records its txid with the router's capture", async () => {
|
||||
const written = await captureTxids(async () => unwrapLiveTxid({ value: { id: "n-1" }, txid: "12" }))
|
||||
expect(written).toEqual({ value: { id: "n-1" }, txid: "12" })
|
||||
const nothing = await captureTxids(async () => unwrapLiveTxid({ value: null, txid: null }))
|
||||
expect(nothing).toEqual({ value: null, txid: null })
|
||||
expect(() => unwrapLiveTxid({ id: "n-1" })).toThrow()
|
||||
})
|
||||
|
||||
// kit:notes:begin
|
||||
it("asks a write for its txid and answers it as ConusAI-Txid's capture; a read is called unchanged", async () => {
|
||||
httpCalls.length = 0
|
||||
http.answer = (kind) => (kind === "mutation" ? { value: { noteId: "n-1" }, txid: "7" } : { items: [] })
|
||||
const caller = createConvexCaller({
|
||||
url: "http://convex.test",
|
||||
adminKey: "admin-key",
|
||||
principal: { actor: { userId: "u-ada" }, method: "bearer" } as never,
|
||||
objectStore: {} as never,
|
||||
identity: identityOf("u-ada"),
|
||||
})
|
||||
const input = { org: "acme", workspace: "main", project: "notes", title: "A" }
|
||||
const write = await captureTxids(() => caller.callByName("notes.note.create", input))
|
||||
expect(write).toEqual({ value: { noteId: "n-1" }, txid: "7" })
|
||||
const read = await captureTxids(() => caller.callByName("notes.note.list", { org: "acme" }))
|
||||
expect(read).toEqual({ value: { items: [] }, txid: null })
|
||||
expect(httpCalls).toEqual([
|
||||
["mutation", "generated/notes:notes_note_create", { input, conusaiLiveTxid: true }],
|
||||
["query", "generated/notes:notes_note_list", { input: { org: "acme" } }],
|
||||
])
|
||||
})
|
||||
// kit:notes:end
|
||||
})
|
||||
@@ -0,0 +1,637 @@
|
||||
// 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"]),
|
||||
}))
|
||||
@@ -0,0 +1,356 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A revoked app session ends the service sessions the modules minted for it (joint contract §14): the
|
||||
// hook the web registers with kit-auth (`onSessionRevoked`) asks every installed module's account
|
||||
// share to revoke them server-side by the session's reference, bounded, and never fails a sign-out.
|
||||
// On the Postgres profile it is durable: the account module's revocation outbox records it, and the
|
||||
// worker's revocation job delivers what a platform that was down did not confirm, exactly once.
|
||||
|
||||
import { createSessionStore, deriveKeys, kitSessionRef, sessionRefKey, sessions } from "@conusai-kit/auth"
|
||||
import { buildRegistry, Container, contribute, defineModule } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA, toInstant } from "@conusai-kit/data"
|
||||
import { SUPPORT_PLATFORM, supportModule } from "@conusai-kit/support" // kit:support
|
||||
import { recordingSupportPlatform } from "@conusai-kit/support/testing" // kit:support
|
||||
import { accountData, accountDataOf } from "@conusai-kit/tenancy"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { revokeDerivedSessions } from "../apps/web/src/server/derived-sessions"
|
||||
import { ACCOUNT_REGISTRY, accountSessionRevocations, recordSessionRevocation } from "../modules/account/index.js"
|
||||
import { custodyModule } from "../modules/custody.js"
|
||||
import { appModules as baseModules } from "../modules/index.js"
|
||||
|
||||
const appModules = [...baseModules.filter((entry) => entry.id !== "support"), custodyModule(supportModule)]
|
||||
const registry = buildRegistry([...appModules])
|
||||
const ref = { provider: "oidc", opaqueId: "0199abcd-1234-7000-8000-00000000d001" }
|
||||
|
||||
function ports(bind: (root: Container) => void, timeoutMs?: number, using = registry) {
|
||||
const root = new Container()
|
||||
bind(root)
|
||||
const warnings: string[] = []
|
||||
return {
|
||||
ports: {
|
||||
registry: using,
|
||||
root,
|
||||
log: { warn: (message: string) => warnings.push(message) },
|
||||
...(timeoutMs ? { timeoutMs } : {}),
|
||||
},
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
describe("revokeDerivedSessions", () => {
|
||||
it("answers at once when no installed module mints derived sessions", async () => {
|
||||
const without = buildRegistry(appModules.filter((module) => module.id !== "support"))
|
||||
const none = ports(() => undefined, undefined, without)
|
||||
await expect(revokeDerivedSessions(none.ports, ref)).resolves.toBeUndefined()
|
||||
expect(none.warnings).toEqual([])
|
||||
expect(sessionRefKey(ref)).toContain(ref.opaqueId)
|
||||
})
|
||||
|
||||
// kit:support:begin
|
||||
it("revokes the support widget sessions minted for exactly that app session", async () => {
|
||||
const support = recordingSupportPlatform()
|
||||
const { ports: bound, warnings } = ports((root) => root.value(SUPPORT_PLATFORM, support))
|
||||
await revokeDerivedSessions(bound, ref)
|
||||
expect(support.calls).toEqual([
|
||||
{ method: "revokeWidgetSessions", subject: { subject: "", identitySession: sessionRefKey(ref) } },
|
||||
])
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it("never fails the sign-out: an unreachable or slow module is logged by name and the hook answers", async () => {
|
||||
const support = recordingSupportPlatform()
|
||||
const client = support.client as { revokeWidgetSessions: unknown }
|
||||
client.revokeWidgetSessions = async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
const down = ports((root) => root.value(SUPPORT_PLATFORM, support))
|
||||
await expect(revokeDerivedSessions(down.ports, ref)).resolves.toBeUndefined()
|
||||
expect(down.warnings).toEqual(["auth: support did not revoke the sessions of a revoked app session"])
|
||||
|
||||
client.revokeWidgetSessions = () => new Promise(() => undefined)
|
||||
const slow = ports((root) => root.value(SUPPORT_PLATFORM, support), 50)
|
||||
await expect(revokeDerivedSessions(slow.ports, ref)).resolves.toBeUndefined()
|
||||
expect(slow.warnings).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("asks the modules directly when the revocation could not be recorded", async () => {
|
||||
const support = recordingSupportPlatform()
|
||||
const { ports: bound, warnings } = ports((root) => root.value(SUPPORT_PLATFORM, support))
|
||||
await revokeDerivedSessions(
|
||||
{
|
||||
...bound,
|
||||
durable: async () => {
|
||||
throw new Error("the database refused the write")
|
||||
},
|
||||
},
|
||||
ref,
|
||||
)
|
||||
expect(support.calls).toEqual([
|
||||
{ method: "revokeWidgetSessions", subject: { subject: "", identitySession: sessionRefKey(ref) } },
|
||||
])
|
||||
expect(warnings).toEqual([
|
||||
"auth: the revocation of an app session could not be recorded; asking the modules directly",
|
||||
])
|
||||
})
|
||||
|
||||
it("does nothing on the fake platform, where no widget session was minted", async () => {
|
||||
const offline = ports((root) => root.value(SUPPORT_PLATFORM, { mode: "offline" }))
|
||||
await expect(revokeDerivedSessions(offline.ports, ref)).resolves.toBeUndefined()
|
||||
expect(offline.warnings).toEqual([])
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("asks only the modules that mint derived sessions", () => {
|
||||
const revoking = accountDataOf(registry)
|
||||
.filter((entry) => entry.revokeSessions)
|
||||
.map((entry) => entry.key)
|
||||
const expected: string[] = []
|
||||
expected.push("support") // kit:support
|
||||
expect(revoking).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The durable path, end to end on the memory data source: kit-auth's own session store signs a
|
||||
* device out and calls the web's hook, which records the revocation in the account module's outbox;
|
||||
* the worker's revocation job (the registry's own handler) delivers it. `minting` stands for any
|
||||
* module that mints derived sessions, and its platform can be taken down and brought back.
|
||||
*/
|
||||
describe("the revocation outbox", () => {
|
||||
const platform = { down: false, delivered: [] as string[], refused: 0 }
|
||||
const minting = defineModule({
|
||||
id: "minting",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
extends: [
|
||||
contribute(accountData, [
|
||||
{
|
||||
key: "minting",
|
||||
async revokeSessions({ session }) {
|
||||
if (platform.down) {
|
||||
platform.refused += 1
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
if (session) platform.delivered.push(session)
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
const registry = buildRegistry([...appModules, minting])
|
||||
const job = registry.jobs["account.sessions.revoke"]
|
||||
const START = Date.parse("2026-10-25T10:00:00.000Z")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
vi.setSystemTime(START)
|
||||
platform.down = false
|
||||
platform.delivered.length = 0
|
||||
platform.refused = 0
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function app() {
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(ACCOUNT_REGISTRY, registry)
|
||||
root.value(SUPPORT_PLATFORM, { mode: "offline" }) // kit:support
|
||||
const warnings: string[] = []
|
||||
const log = { warn: (message: string) => warnings.push(message) }
|
||||
const store = createSessionStore({
|
||||
config: {
|
||||
idleSeconds: 3600,
|
||||
absoluteSeconds: 86400,
|
||||
cookieName: "s",
|
||||
secret: "x".repeat(32),
|
||||
secureCookies: false,
|
||||
},
|
||||
data,
|
||||
keys: await deriveKeys("x".repeat(32)),
|
||||
// What apps/web/src/server/app.ts registers on the Postgres profile.
|
||||
onRevoked: (revoked) =>
|
||||
revokeDerivedSessions(
|
||||
{ registry, root, log, durable: (session) => recordSessionRevocation(root, session, { log }) },
|
||||
revoked,
|
||||
),
|
||||
})
|
||||
const signIn = async (subject: string) =>
|
||||
(
|
||||
await store.open({
|
||||
userId: "0199abcd-1234-7000-8000-00000000e001",
|
||||
issuer: "https://id.example.test",
|
||||
subject,
|
||||
tokens: { accessToken: "a", expiresAt: toInstant(Date.now() + 3600_000) },
|
||||
})
|
||||
).row
|
||||
const runJob = () => {
|
||||
if (!job) throw new Error("the account module declares no revocation job")
|
||||
return job.def.handler(null, { container: root.child(), correlationId: "test-revoke", attempt: 1 })
|
||||
}
|
||||
const rows = async () => (await data.global(accountSessionRevocations).list({ limit: 50 })).items
|
||||
const later = (ms: number) => vi.setSystemTime(Date.now() + ms)
|
||||
return { data, store, signIn, runJob, rows, later, warnings }
|
||||
}
|
||||
|
||||
it("is scheduled by the worker every minute", () => {
|
||||
expect(job?.module).toBe("account")
|
||||
})
|
||||
|
||||
it("delivers at once when the platform is up, and the job has nothing left to do", async () => {
|
||||
const { store, signIn, runJob, rows, later } = await app()
|
||||
const device = await signIn("sub-up")
|
||||
await store.revoke(device, "sign-out")
|
||||
const key = sessionRefKey(kitSessionRef(device))
|
||||
expect(platform.delivered).toEqual([key])
|
||||
expect((await rows())[0]).toMatchObject({
|
||||
state: "delivered",
|
||||
pending: [],
|
||||
delivered: expect.arrayContaining(["minting"]),
|
||||
})
|
||||
later(10 * 60_000)
|
||||
await runJob()
|
||||
expect(platform.delivered).toEqual([key])
|
||||
})
|
||||
|
||||
it("a platform down at sign-out gets the revocation once it is back, and nothing twice", async () => {
|
||||
const { data, store, signIn, runJob, rows, later, warnings } = await app()
|
||||
const device = await signIn("sub-down")
|
||||
platform.down = true
|
||||
// The sign-out is not blocked: it answers, and the app session is revoked.
|
||||
await expect(store.revoke(device, "sign-out")).resolves.toBeUndefined()
|
||||
expect((await data.global(sessions).get(device.id))?.revokedAt).not.toBeNull()
|
||||
expect(platform.delivered).toEqual([])
|
||||
const [pending] = await rows()
|
||||
expect(pending).toMatchObject({ state: "pending", pending: ["minting"], attempts: 1, claimedUntil: null })
|
||||
expect(warnings).toEqual(["auth: minting did not revoke the sessions of a revoked app session; retrying"])
|
||||
|
||||
// Not due yet (backoff): the job asks nobody.
|
||||
await runJob()
|
||||
expect(platform.refused).toBe(1)
|
||||
// Due, platform still down: one more refusal, scheduled again with a longer backoff; the run fails.
|
||||
later(31_000)
|
||||
await expect(runJob()).rejects.toMatchObject({ code: "account_revocations_waiting" })
|
||||
expect(platform.refused).toBe(2)
|
||||
expect((await rows())[0]).toMatchObject({ state: "pending", attempts: 2 })
|
||||
|
||||
// Back: the next due run delivers it, once.
|
||||
platform.down = false
|
||||
later(61_000)
|
||||
await runJob()
|
||||
const key = sessionRefKey(kitSessionRef(device))
|
||||
expect(platform.delivered).toEqual([key])
|
||||
expect((await rows())[0]).toMatchObject({
|
||||
state: "delivered",
|
||||
pending: [],
|
||||
delivered: expect.arrayContaining(["minting"]),
|
||||
})
|
||||
later(10 * 60_000)
|
||||
await runJob()
|
||||
expect(platform.delivered).toEqual([key])
|
||||
})
|
||||
|
||||
it("two runs at once deliver one revocation once", async () => {
|
||||
const { store, signIn, runJob, later } = await app()
|
||||
const device = await signIn("sub-race")
|
||||
platform.down = true
|
||||
await store.revoke(device, "sign-out")
|
||||
platform.down = false
|
||||
later(31_000)
|
||||
await Promise.all([runJob(), runJob(), runJob()])
|
||||
expect(platform.delivered).toEqual([sessionRefKey(kitSessionRef(device))])
|
||||
})
|
||||
|
||||
it("gives up once no derived session can still be alive, and removes finished rows a day later", async () => {
|
||||
const { store, signIn, runJob, rows, later } = await app()
|
||||
const down = await signIn("sub-lapse")
|
||||
platform.down = true
|
||||
await store.revoke(down, "sign-out")
|
||||
later(66 * 60_000)
|
||||
await runJob()
|
||||
const refused = platform.refused
|
||||
expect((await rows())[0]).toMatchObject({ state: "lapsed" })
|
||||
later(10 * 60_000)
|
||||
await runJob()
|
||||
expect(platform.refused).toBe(refused)
|
||||
later(25 * 60 * 60_000)
|
||||
await runJob()
|
||||
expect(await rows()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// kit:profile-convex:begin
|
||||
/**
|
||||
* The Convex profile's sign-out (apps/web/src/server/convex-revocation.ts): the web records the revoked
|
||||
* app session in the backend's outbox through the internal action `account:revokeSession`, with the
|
||||
* admin key and no identity, bounded. A refusal or a backend that does not answer is thrown, so the
|
||||
* hook asks the modules directly instead.
|
||||
*/
|
||||
describe("the Convex profile's durable revocation", () => {
|
||||
async function backend(answer: (body: { path: string; args: unknown[] }) => unknown | undefined) {
|
||||
const { createServer } = await import("node:http")
|
||||
const seen: Array<{ path: string; args: unknown[]; authorization: string | undefined }> = []
|
||||
const server = createServer((request, response) => {
|
||||
let raw = ""
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk
|
||||
})
|
||||
request.on("end", () => {
|
||||
const body = JSON.parse(raw) as { path: string; args: unknown[] }
|
||||
seen.push({ ...body, authorization: request.headers.authorization })
|
||||
const value = answer(body)
|
||||
if (value === undefined) return // never answers
|
||||
response.writeHead(200, { "content-type": "application/json" })
|
||||
response.end(JSON.stringify(value))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address() as { port: number }
|
||||
return {
|
||||
convex: { url: `http://127.0.0.1:${address.port}`, adminKey: "app|admin-key" },
|
||||
seen,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections()
|
||||
server.close(() => resolve())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
it("records the revocation in the backend's outbox with the admin key", async () => {
|
||||
const { revokeConvexSession } = await import("../apps/web/src/server/convex-revocation")
|
||||
const fake = await backend(() => ({ status: "success", value: null, logLines: [] }))
|
||||
try {
|
||||
await revokeConvexSession(fake.convex, sessionRefKey(ref))
|
||||
expect(fake.seen).toMatchObject([
|
||||
{
|
||||
path: "account:revokeSession",
|
||||
args: [{ session: sessionRefKey(ref) }],
|
||||
authorization: "Convex app|admin-key",
|
||||
},
|
||||
])
|
||||
expect(fake.seen).toHaveLength(1)
|
||||
} finally {
|
||||
await fake.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("throws when nothing was recorded, so the hook asks the modules directly, and never waits long", async () => {
|
||||
const { revokeConvexSession } = await import("../apps/web/src/server/convex-revocation")
|
||||
const refused = await backend(() => ({ status: "error", errorMessage: "Server Error", logLines: [] }))
|
||||
const silent = await backend(() => undefined)
|
||||
try {
|
||||
await expect(revokeConvexSession(refused.convex, "s-1")).rejects.toBeDefined()
|
||||
const started = Date.now()
|
||||
await expect(revokeConvexSession(silent.convex, "s-2", { timeoutMs: 100 })).rejects.toBeDefined()
|
||||
expect(Date.now() - started).toBeLessThan(2000)
|
||||
} finally {
|
||||
await refused.close()
|
||||
await silent.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
// kit:profile-convex:end
|
||||
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Run the real dev entry point with synthetic build/database/platform dependencies.
|
||||
import { type ChildProcess, spawn } from "node:child_process"
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { afterEach, expect, it } from "vitest"
|
||||
|
||||
const source = readFileSync(fileURLToPath(new URL("../scripts/dev.mjs", import.meta.url)), "utf8")
|
||||
let directory: string
|
||||
let parent: ChildProcess | undefined
|
||||
const owned = new Set<number>()
|
||||
const alive = (pid: number) => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const delay = () => new Promise((resolve) => setTimeout(resolve, 20))
|
||||
async function until(check: () => boolean, milliseconds = 5_000) {
|
||||
const deadline = Date.now() + milliseconds
|
||||
while (!check()) {
|
||||
if (Date.now() > deadline) throw new Error("synthetic dev process did not reach the expected state")
|
||||
await delay()
|
||||
}
|
||||
}
|
||||
afterEach(async () => {
|
||||
parent?.kill("SIGKILL")
|
||||
for (const pid of owned) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL")
|
||||
} catch {
|
||||
// Only synthetic task-owned children; a completed child may already be gone.
|
||||
}
|
||||
}
|
||||
owned.clear()
|
||||
if (directory) rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function boot(
|
||||
stage: "migrate" | "seed",
|
||||
outcome: "wait" | "fail" | "signal" | "missing" | "stubborn" | "orphan" = "wait",
|
||||
probeError?: "EPERM" | "EIO",
|
||||
) {
|
||||
directory = mkdtempSync(join(tmpdir(), "app-dev-shutdown-"))
|
||||
for (const folder of ["scripts", "bin", "apps/worker/dist"]) mkdirSync(join(directory, folder), { recursive: true })
|
||||
// Only substitute the external platform dependency; all process management is unchanged.
|
||||
writeFileSync(
|
||||
join(directory, "scripts/dev.mjs"),
|
||||
source.replace('"@conusai-kit/testing/fake-platform"', '"./platform.mjs"'),
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, "scripts/platform.mjs"),
|
||||
`
|
||||
import { writeFileSync } from "node:fs"
|
||||
export const fakeNativeServices = () => ({ deployment: {} })
|
||||
export const validReply = () => ({})
|
||||
export const startFakePlatform = async () => ({
|
||||
url: "synthetic", ai: { respond() {} },
|
||||
async close() { writeFileSync(${JSON.stringify(join(directory, "closed"))}, "closed") }
|
||||
})
|
||||
`,
|
||||
)
|
||||
const pnpm = join(directory, "bin/pnpm")
|
||||
writeFileSync(pnpm, `#!${process.execPath}\nprocess.exit(0)\n`)
|
||||
chmodSync(pnpm, 0o755)
|
||||
if (outcome !== "missing") symlinkSync(process.execPath, join(directory, "bin/node"))
|
||||
const pidFile = join(directory, "child.pid")
|
||||
const grandchildFile = join(directory, "grandchild.pid")
|
||||
const onTerm =
|
||||
outcome === "stubborn"
|
||||
? "() => {}"
|
||||
: `() => setTimeout(() => {
|
||||
require("node:fs").writeFileSync(${JSON.stringify(join(directory, "child-ended"))}, "ended")
|
||||
process.exit(0)
|
||||
}, 30)`
|
||||
const grandchild = `
|
||||
${outcome === "stubborn" || outcome === "orphan" ? 'process.on("SIGTERM", () => {});' : ""}
|
||||
require("node:fs").writeFileSync(${JSON.stringify(grandchildFile)}, String(process.pid))
|
||||
setInterval(() => {}, 1000)
|
||||
`
|
||||
const waiting = `
|
||||
const { writeFileSync } = require("node:fs")
|
||||
const { spawn } = require("node:child_process")
|
||||
process.on("SIGTERM", ${onTerm})
|
||||
writeFileSync(${JSON.stringify(pidFile)}, String(process.pid))
|
||||
spawn(process.execPath, ["-e", ${JSON.stringify(grandchild)}], { stdio: "inherit" })
|
||||
setInterval(() => {}, 1000)
|
||||
`
|
||||
const body =
|
||||
outcome === "fail" ? "process.exit(7)" : outcome === "signal" ? 'process.kill(process.pid, "SIGTERM")' : waiting
|
||||
writeFileSync(join(directory, "apps/worker/dist/migrate.js"), stage === "migrate" ? body : "process.exit(0)")
|
||||
writeFileSync(
|
||||
join(directory, "apps/worker/dist/seed-main.js"),
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(join(directory, "seed-started"))}, "started");\n${body}`,
|
||||
)
|
||||
const probeLog = join(directory, "probes")
|
||||
const preload = join(directory, "probe.mjs")
|
||||
if (probeError) {
|
||||
// Inject only an existence-probe error for a group the actual entry point has signalled.
|
||||
// Real spawning, TERM delivery, child termination and the production wait bounds remain intact.
|
||||
writeFileSync(
|
||||
preload,
|
||||
`
|
||||
import { appendFileSync } from "node:fs"
|
||||
const kill = process.kill.bind(process)
|
||||
let group
|
||||
let denied = 0
|
||||
process.kill = (pid, signal) => {
|
||||
if (pid === group && signal === 0 && denied < 3) {
|
||||
denied += 1
|
||||
appendFileSync(${JSON.stringify(probeLog)}, ${JSON.stringify(`${probeError}\n`)})
|
||||
throw Object.assign(new Error("synthetic existence probe failure"), { code: ${JSON.stringify(probeError)} })
|
||||
}
|
||||
const result = kill(pid, signal)
|
||||
if (pid < 0 && signal === "SIGTERM") group = pid
|
||||
return result
|
||||
}
|
||||
`,
|
||||
)
|
||||
}
|
||||
const args = [...(probeError ? ["--import", preload] : []), join(directory, "scripts/dev.mjs")]
|
||||
const child = spawn(process.execPath, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: join(directory, "bin"),
|
||||
POSTGRES_URL: "synthetic",
|
||||
POSTGRES_READER_URL: "synthetic",
|
||||
POSTGRES_MIGRATE_URL: "synthetic",
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
})
|
||||
let stderr = ""
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
parent = child
|
||||
const exited = new Promise<number | null>((resolve, reject) => {
|
||||
child.once("exit", resolve)
|
||||
child.once("error", reject)
|
||||
})
|
||||
return { child, exited, pidFile, grandchildFile, probeLog, stderr: () => stderr }
|
||||
}
|
||||
|
||||
it.each([
|
||||
["migrate", "SIGTERM"],
|
||||
["seed", "SIGTERM"],
|
||||
["migrate", "SIGINT"],
|
||||
["seed", "SIGINT"],
|
||||
] as const)("%s cancellation by %s stops its child and descendants before further startup", async (stage, signal) => {
|
||||
const { child, exited, pidFile, grandchildFile } = boot(stage)
|
||||
await until(() => existsSync(pidFile) && existsSync(grandchildFile))
|
||||
const pids = [pidFile, grandchildFile].map((file) => Number(readFileSync(file, "utf8")))
|
||||
for (const pid of pids) owned.add(pid)
|
||||
child.kill(signal)
|
||||
expect(await exited).toBe(0)
|
||||
expect(existsSync(join(directory, "child-ended"))).toBe(true)
|
||||
await until(() => pids.every((pid) => !alive(pid)))
|
||||
expect(existsSync(join(directory, "closed"))).toBe(true)
|
||||
if (stage === "migrate") expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(["fail", "signal", "missing"] as const)(
|
||||
"preserves failure handling when the startup child has outcome %s",
|
||||
async (outcome) => {
|
||||
const { exited } = boot("migrate", outcome)
|
||||
expect(await exited).toBe(outcome === "fail" ? 7 : 1)
|
||||
expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
// Use the actual production grace (35s), not a shortened test-only shutdown policy.
|
||||
it.each(["stubborn", "orphan"] as const)(
|
||||
"escalates cancellation when the startup process is %s",
|
||||
async (outcome) => {
|
||||
const { child, exited, pidFile, grandchildFile } = boot("migrate", outcome)
|
||||
await until(() => existsSync(pidFile) && existsSync(grandchildFile))
|
||||
const pids = [pidFile, grandchildFile].map((file) => Number(readFileSync(file, "utf8")))
|
||||
for (const pid of pids) owned.add(pid)
|
||||
const sentinel = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" })
|
||||
if (sentinel.pid === undefined) throw new Error("synthetic unrelated process did not start")
|
||||
owned.add(sentinel.pid)
|
||||
const began = performance.now()
|
||||
child.kill("SIGTERM")
|
||||
await until(() => child.exitCode !== null || child.signalCode !== null, 42_000)
|
||||
expect(await exited).toBe(0)
|
||||
expect(pids.every((pid) => !alive(pid))).toBe(true)
|
||||
expect(performance.now() - began).toBeGreaterThanOrEqual(35_000)
|
||||
expect(alive(sentinel.pid)).toBe(true)
|
||||
expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
expect(existsSync(join(directory, "closed"))).toBe(true)
|
||||
},
|
||||
50_000,
|
||||
)
|
||||
|
||||
it.each(["EPERM", "EIO"] as const)("handles existence-probe %s without treating it as absence", async (code) => {
|
||||
const { child, exited, pidFile, grandchildFile, probeLog, stderr } = boot("migrate", "wait", code)
|
||||
await until(() => existsSync(pidFile) && existsSync(grandchildFile))
|
||||
for (const file of [pidFile, grandchildFile]) owned.add(Number(readFileSync(file, "utf8")))
|
||||
child.kill("SIGTERM")
|
||||
expect(await exited, stderr()).toBe(code === "EPERM" ? 0 : 1)
|
||||
expect(existsSync(probeLog)).toBe(true)
|
||||
if (code === "EPERM") {
|
||||
// EPERM must keep the group pending, not let cleanup declare it absent immediately.
|
||||
expect(readFileSync(probeLog, "utf8").trim().split("\n")).toEqual(["EPERM", "EPERM", "EPERM"])
|
||||
expect(existsSync(join(directory, "closed"))).toBe(true)
|
||||
}
|
||||
expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The account module's data-subject rights on the mariadb and mongodb profiles: the same store
|
||||
// (modules/account/adapters/store.ts) over the profile's real data source, with the kernel's and the
|
||||
// modules' committed dialect migrations applied first. A person's export holds their rows and nobody
|
||||
// else's, and their deletion erases them and leaves another person's account as it was.
|
||||
//
|
||||
// It needs the engines: ACCOUNT_TEST_MARIADB_URL (MariaDB LTS) and ACCOUNT_TEST_MONGODB_URL (MongoDB 8 as
|
||||
// a single-node replica set, AT-D15), each naming a database of its own that the test may fill. Without
|
||||
// them each block is skipped by name; `scripts/vendor/test-template.sh app --profile <name>` starts both.
|
||||
|
||||
import { type DialectProfile, expectedDialectMigrations } from "@app/config"
|
||||
import { sessions } from "@conusai-kit/auth"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
||||
import { orgMemberships, users } from "@conusai-kit/tenancy"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import type { DialectData } from "../../apps/worker/src/dialect/engine.js"
|
||||
import { engineFor } from "../../apps/worker/src/dialect/runtime.js"
|
||||
import { makeApp, type Person } from "../../modules/account/__tests__/harness.js"
|
||||
import type { AccountExport, DeletionView } from "../../modules/account/index.js"
|
||||
import { accountDeletions } from "../../modules/account/index.js"
|
||||
import { freshDialectModules } from "./fixture.js"
|
||||
|
||||
const ENGINES: readonly [DialectProfile, string, string | undefined][] = [
|
||||
["mariadb", "MARIADB_URL", process.env.ACCOUNT_TEST_MARIADB_URL],
|
||||
["mongodb", "MONGODB_URL", process.env.ACCOUNT_TEST_MONGODB_URL],
|
||||
]
|
||||
|
||||
/** What the migrate job of the profile applies (apps/worker/src/dialect/), on the engine under test. */
|
||||
async function migrate(
|
||||
profile: DialectProfile,
|
||||
env: Record<string, string>,
|
||||
registry: ReturnType<typeof buildRegistry>,
|
||||
): Promise<string[]> {
|
||||
const outcome = await engineFor(profile).migrate(env, registry)
|
||||
return [...outcome.applied, ...outcome.skipped]
|
||||
}
|
||||
|
||||
async function orgOf(person: Person, slug: string): Promise<string> {
|
||||
const created = (await person.call().callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
|
||||
return created.orgId
|
||||
}
|
||||
|
||||
for (const [profile, variable, url] of ENGINES) {
|
||||
const modules = freshDialectModules(profile)
|
||||
const registry = buildRegistry(modules)
|
||||
describe.skipIf(!url)(`account data-subject rights on ${profile} (${variable})`, () => {
|
||||
let data: DialectData
|
||||
const env = { [variable]: url ?? "" }
|
||||
|
||||
beforeAll(async () => {
|
||||
// The migrate job's whole list applies on the engine, and it is the list readiness expects.
|
||||
expect((await migrate(profile, env, registry)).sort()).toEqual(
|
||||
(await expectedDialectMigrations(registry, profile)).sort(),
|
||||
)
|
||||
const entities = [...entitiesOf(registry, profile), ...KIT_JOBS_ENTITIES]
|
||||
expect(entities.map(({ table }) => table)).toEqual(
|
||||
expect.arrayContaining(["account_deletion", "account_session_revocation"]),
|
||||
)
|
||||
data = await engineFor(profile).connect(env, entities)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await data?.close()
|
||||
})
|
||||
|
||||
it("exports the person's own rows and deletes the account, leaving another person's as it was", async () => {
|
||||
const app = makeApp({ data, modules })
|
||||
const tag = Math.random().toString(36).slice(2, 8)
|
||||
const ada = await app.signUp(`ada-${tag}@example.test`)
|
||||
const grace = await app.signUp(`grace-${tag}@example.test`)
|
||||
const adaOrg = await orgOf(ada, `solo-${tag}`)
|
||||
await orgOf(grace, `other-${tag}`)
|
||||
|
||||
const exported = (await ada.call().callByName("account.data.export", {})) as AccountExport
|
||||
expect(exported).toMatchObject({ subject: ada.actor.userId, complete: true, failed: [] })
|
||||
expect(exported.sections.account).toMatchObject({
|
||||
status: "ok",
|
||||
data: { profile: { userId: ada.actor.userId, email: ada.email }, organizations: [{ slug: `solo-${tag}` }] },
|
||||
})
|
||||
// Negative control: nothing of the other person is in it.
|
||||
expect(JSON.stringify(exported)).not.toContain(grace.email)
|
||||
expect(JSON.stringify(exported)).not.toContain(`other-${tag}`)
|
||||
|
||||
const view = (await ada.call().callByName("account.deletion.request", { confirm: ada.email })) as DeletionView
|
||||
expect(view).toMatchObject({ state: "completed", pending: [], abandoned: [] })
|
||||
|
||||
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
expect(
|
||||
await app.data.forScope({ level: "org", orgId: adaOrg, actor: ada.actor }).of(orgMemberships).count(),
|
||||
).toBe(0)
|
||||
const adaSessions = (
|
||||
await app.data.global(sessions).list({ where: [{ field: "userId", op: "eq", value: ada.actor.userId }] })
|
||||
).items
|
||||
expect(adaSessions).toEqual([])
|
||||
const ledger = (
|
||||
await app.data
|
||||
.global(accountDeletions)
|
||||
.list({ where: [{ field: "userId", op: "eq", value: ada.actor.userId }] })
|
||||
).items
|
||||
expect(ledger).toMatchObject([{ state: "completed" }])
|
||||
expect(JSON.stringify(ledger)).not.toContain(ada.email)
|
||||
|
||||
// The other person keeps everything: their user row, their membership and their export.
|
||||
expect((await app.data.global(users).get(grace.actor.userId))?.email).toBe(grace.email)
|
||||
const graceExport = (await grace.call().callByName("account.data.export", {})) as AccountExport
|
||||
expect(graceExport.sections.account).toMatchObject({
|
||||
status: "ok",
|
||||
data: { organizations: [{ slug: `other-${tag}` }] },
|
||||
})
|
||||
}, 60_000)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The mariadb and mongodb compositions (profiles/<name>/README.md): the migrate job applies exactly
|
||||
// the list the web shell's readiness probe expects, the job adapter's own table among them, and the
|
||||
// worker serves every module job on BullMQ. `kit template:prune --profile postgres` removes this folder.
|
||||
|
||||
import { expectedDialectMigrations } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { mariadbJobsMigration } from "../../apps/worker/src/dialect/mariadb/index.js"
|
||||
import { mongodbJobsCommands } from "../../apps/worker/src/dialect/mongodb/index.js"
|
||||
import { bullmqJobs, dialectMigrations, queueSlug } from "../../apps/worker/src/dialect/runtime.js"
|
||||
import { dialectSchedules } from "../../apps/worker/src/dialect/schedules.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
|
||||
describe("the dialect profiles' migrations", () => {
|
||||
for (const profile of ["mariadb", "mongodb"] as const) {
|
||||
it(`${profile}: the migrate job applies what readiness expects, kernel, jobs table and modules`, async () => {
|
||||
const expected = await expectedDialectMigrations(registry, profile)
|
||||
const applied = (await dialectMigrations(registry, profile)).map((migration) => migration.name)
|
||||
expect([...applied].sort()).toEqual([...expected].sort())
|
||||
expect(expected).toEqual(
|
||||
expect.arrayContaining([
|
||||
"0001_kernel",
|
||||
"0007_kit-jobs",
|
||||
"notes/0001_notes", // kit:notes
|
||||
"account/0001_account",
|
||||
]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
it("builds the outbox relay's ledger table in each dialect", () => {
|
||||
expect(mariadbJobsMigration().statements.join("\n")).toContain("create table `kit_processed_events`")
|
||||
expect(mongodbJobsCommands()).toContainEqual(expect.objectContaining({ createCollection: "kit_processed_events" }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the dialect profiles' worker", () => {
|
||||
it("serves every module job of the registry, with its own payload schema", () => {
|
||||
const jobs = bullmqJobs(registry)
|
||||
expect(Object.keys(jobs).sort()).toEqual(Object.keys(registry.jobs).sort())
|
||||
for (const [name, def] of Object.entries(jobs)) expect(def.payload).toBe(registry.jobs[name]?.def.payload)
|
||||
})
|
||||
|
||||
it("schedules on each dialect worker only the module jobs of modules installed on its profile", () => {
|
||||
for (const profile of ["mariadb", "mongodb"] as const) {
|
||||
const jobs = dialectSchedules(registry, profile)
|
||||
.map((schedule) => schedule.job)
|
||||
.sort()
|
||||
// The account, file and inbox sweeps run on both; the agent module ships Postgres only.
|
||||
expect(jobs).toEqual([
|
||||
"account.deletion.sweep",
|
||||
"account.sessions.revoke",
|
||||
"files.recover",
|
||||
"notifications.retention.sweep",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it("keys the queue by the platform's project, in the alphabet BullMQ prefixes take", () => {
|
||||
const env = (id: string) => ({ deployment: { CONUSAI_PROJECT_ID: id } }) as Parameters<typeof queueSlug>[0]
|
||||
expect(queueSlug(env("42"))).toBe("p-42")
|
||||
expect(queueSlug(env("Proj_ID.9"))).toMatch(/^[a-z0-9][a-z0-9-]*$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Fresh, synthetic backend-conformance fixtures: no existing app, record or
|
||||
// adoption history is rewritten. Only modules supporting the tested profile
|
||||
// are installed in this new fixture's registry and its matching data source.
|
||||
import type { DialectProfile } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
|
||||
export function freshDialectModules(profile: DialectProfile) {
|
||||
return appModules.filter((module) => module.profiles === undefined || module.profiles.includes(profile))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { type AppEnv, loadAppEnv, profileRegistry, type ServerObservability } from "@app/config"
|
||||
import { appModules, moduleInclusion } from "@app/modules"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, entitiesOf } from "@conusai-kit/data"
|
||||
import { users } from "@conusai-kit/tenancy"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { buildDialectWorkerRuntime, engineFor, migrateDialect } from "../../apps/worker/src/dialect/runtime.js"
|
||||
import { makeApp } from "../../modules/account/__tests__/harness.js"
|
||||
import type { AccountExport, DeletionView } from "../../modules/account/index.js"
|
||||
import { freshDialectModules } from "./fixture.js"
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe.each(["mariadb", "mongodb"] as const)("%s module/profile composition", (profile) => {
|
||||
it("names unsupported adopted modules and preserves their custody registry", () => {
|
||||
const before = JSON.stringify(moduleInclusion)
|
||||
expect(() => profileRegistry(appModules, profile)).toThrow(/agent, assistant/)
|
||||
expect(JSON.stringify(moduleInclusion)).toBe(before)
|
||||
expect(buildRegistry([...appModules]).modules.map(({ id }) => id)).toContain("assistant")
|
||||
})
|
||||
|
||||
it("uses the same compatible fresh registry for data and account rights without changing starter history", async () => {
|
||||
const before = JSON.stringify(moduleInclusion)
|
||||
const modules = freshDialectModules(profile)
|
||||
const registry = profileRegistry(modules, profile)
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, profile) })
|
||||
const app = makeApp({ data, modules })
|
||||
expect(app.registry.modules.map(({ id }) => id)).toEqual(registry.modules.map(({ id }) => id))
|
||||
const ada = await app.signUp("profile-ada@example.test")
|
||||
const grace = await app.signUp("profile-grace@example.test")
|
||||
await ada.call().callByName("tenancy.org.create", { slug: "profile-ada", name: "Ada fixture" })
|
||||
await grace.call().callByName("tenancy.org.create", { slug: "profile-grace", name: "Grace fixture" })
|
||||
const exported = (await ada.call().callByName("account.data.export", {})) as AccountExport
|
||||
expect(exported).toMatchObject({ subject: ada.actor.userId, complete: true, failed: [] })
|
||||
expect(JSON.stringify(exported)).not.toContain(grace.email)
|
||||
const deleted = (await ada.call().callByName("account.deletion.request", { confirm: ada.email })) as DeletionView
|
||||
expect(deleted).toMatchObject({ state: "completed", pending: [], abandoned: [] })
|
||||
expect(await data.global(users).get(ada.actor.userId)).toBeNull()
|
||||
expect((await data.global(users).get(grace.actor.userId))?.email).toBe(grace.email)
|
||||
expect(JSON.stringify(moduleInclusion)).toBe(before)
|
||||
})
|
||||
|
||||
it("refuses the actual worker and migrate paths before connecting or applying migrations", async () => {
|
||||
const databaseUrl = `${profile === "mariadb" ? "mysql" : "mongodb"}://127.0.0.1:1/fixture`
|
||||
const raw: Record<string, string> = {
|
||||
NODE_ENV: "test",
|
||||
CONUSAI_API_URL: "https://fixture.example.test/api",
|
||||
CONUSAI_API_TOKEN: "synthetic-token",
|
||||
CONUSAI_PROJECT_ID: "profile-refusal",
|
||||
REDIS_URL: "redis://127.0.0.1:1",
|
||||
[profile === "mariadb" ? "MARIADB_URL" : "MONGODB_URL"]:
|
||||
`${profile === "mariadb" ? "mysql" : "mongodb"}://127.0.0.1:1/fixture`,
|
||||
}
|
||||
if (profile === "mariadb") raw.DATABASE_URL = databaseUrl
|
||||
const env: AppEnv = loadAppEnv("worker", raw, profile)
|
||||
const engine = engineFor(profile)
|
||||
const connect = vi
|
||||
.spyOn(engine, "connect")
|
||||
.mockRejectedValue(new Error("database access forbidden in refusal control"))
|
||||
const migrate = vi.spyOn(engine, "migrate").mockRejectedValue(new Error("migration forbidden in refusal control"))
|
||||
await expect(buildDialectWorkerRuntime(env, {} as ServerObservability)).rejects.toThrow(/agent, assistant/)
|
||||
expect(() => migrateDialect(env)).toThrow(/agent, assistant/)
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
expect(migrate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The web shell's stores of the mariadb and mongodb profiles (apps/web/src/server/dialect/) on the
|
||||
// real engines: the readiness probes answer, the migration level the migrate job wrote is what
|
||||
// readiness expects, the MongoDB store refuses a standalone server at boot (AT-D15), and the BullMQ
|
||||
// queue enqueues on Valkey under the app's own prefix.
|
||||
//
|
||||
// It needs ACCOUNT_TEST_MARIADB_URL, ACCOUNT_TEST_MONGODB_URL (a replica set) and ACCOUNT_TEST_REDIS_URL,
|
||||
// the databases test/dialect/account.test.ts migrated; without them each block is skipped by name.
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { type AppEnv, expectedDialectMigrations, liveCaptureMigrations, loadAppEnv } from "@app/config"
|
||||
import { notes } from "@app/modules/notes"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
||||
import { Redis } from "ioredis"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { dialectStores, queueSlug } from "../../apps/web/src/server/dialect/stores"
|
||||
import { engineFor } from "../../apps/worker/src/dialect/runtime.js"
|
||||
import { freshDialectModules } from "./fixture.js"
|
||||
|
||||
const redisUrl = process.env.ACCOUNT_TEST_REDIS_URL
|
||||
const platform = {
|
||||
NODE_ENV: "test",
|
||||
CONUSAI_API_URL: "https://api.example.test/api",
|
||||
CONUSAI_API_TOKEN: "token",
|
||||
CONUSAI_PROJECT_ID: "w06-stores",
|
||||
}
|
||||
|
||||
const ENGINES = [
|
||||
[
|
||||
"mariadb",
|
||||
{ MARIADB_URL: process.env.ACCOUNT_TEST_MARIADB_URL, DATABASE_URL: process.env.ACCOUNT_TEST_MARIADB_URL },
|
||||
],
|
||||
["mongodb", { MONGODB_URL: process.env.ACCOUNT_TEST_MONGODB_URL }],
|
||||
] as const
|
||||
|
||||
for (const [profile, store] of ENGINES) {
|
||||
const registry = buildRegistry(freshDialectModules(profile))
|
||||
const url = Object.values(store)[0]
|
||||
describe.skipIf(!url || !redisUrl)(`the web shell's ${profile} store`, () => {
|
||||
it("answers readiness at the migrate job's level and enqueues on its own BullMQ prefix", async () => {
|
||||
const raw = { ...platform, ...store, REDIS_URL: redisUrl }
|
||||
await engineFor(profile).migrate(raw, registry)
|
||||
const env: AppEnv = loadAppEnv("web", raw, profile)
|
||||
const stores = dialectStores(
|
||||
env,
|
||||
[...entitiesOf(registry, profile), ...KIT_JOBS_ENTITIES],
|
||||
liveCaptureMigrations(registry, profile),
|
||||
)
|
||||
if (!stores) throw new Error("no dialect store")
|
||||
try {
|
||||
await stores.probes.boot()
|
||||
await stores.probes.store()
|
||||
const applied = await stores.probes.applied()
|
||||
for (const name of await expectedDialectMigrations(registry, profile))
|
||||
expect(applied.has(name), name).toBe(true)
|
||||
// A request's enqueue reaches Valkey under this app's prefix.
|
||||
const id = await stores
|
||||
.queue()
|
||||
.enqueue(
|
||||
{ level: "org", orgId: "00000000-0000-4000-8000-000000000001", actor: { userId: "u" } },
|
||||
"files.recover",
|
||||
{ probe: true },
|
||||
)
|
||||
expect(id).toBeTruthy()
|
||||
const redis = new Redis(redisUrl as string)
|
||||
try {
|
||||
const keys = await redis.keys(`kit-jobs-${queueSlug(env)}*`)
|
||||
expect(keys.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await redis.quit()
|
||||
}
|
||||
// The live log: the migrate job prepared notes_note (MariaDB row triggers, MongoDB pre-images),
|
||||
// so a note written through the repository is in the log, ids only, in its own organization.
|
||||
{
|
||||
const log = stores.liveLog?.()
|
||||
if (!log) throw new Error(`the ${profile} store has no live log`)
|
||||
await log.start()
|
||||
const scope = {
|
||||
level: "project",
|
||||
orgId: randomUUID(),
|
||||
workspaceId: randomUUID(),
|
||||
projectId: randomUUID(),
|
||||
actor: { userId: randomUUID() },
|
||||
} as const
|
||||
const since = profile === "mongodb" ? (await log.head(scope.orgId)).position : { txid: 0n, seq: 0n }
|
||||
const note = await stores.data
|
||||
.forScope(scope)
|
||||
.of(notes)
|
||||
.insert({ title: "live", body: "b", createdBy: scope.actor.userId })
|
||||
let rows: Awaited<ReturnType<typeof log.read>>["final"] = []
|
||||
for (let attempt = 0; attempt < 50 && rows.length === 0; attempt++) {
|
||||
const read = await log.read(scope.orgId, since, 10)
|
||||
rows = [...read.final, ...read.early]
|
||||
if (rows.length === 0) await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
expect(rows.map((row) => [row.entity, row.id, row.op, row.scope.ws])).toEqual([
|
||||
["notes.note", note.id, "u", scope.workspaceId],
|
||||
])
|
||||
expect((await log.read(randomUUID(), since, 10)).final).toEqual([])
|
||||
await log.close()
|
||||
}
|
||||
} finally {
|
||||
await stores.close()
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.ACCOUNT_TEST_MONGODB_STANDALONE_URL)(
|
||||
"the web shell's mongodb store on a standalone server",
|
||||
() => {
|
||||
it("refuses to boot with the typed replica_set error (AT-D15)", async () => {
|
||||
const env = loadAppEnv(
|
||||
"web",
|
||||
{ ...platform, MONGODB_URL: process.env.ACCOUNT_TEST_MONGODB_STANDALONE_URL, REDIS_URL: "redis://127.0.0.1:1" },
|
||||
"mongodb",
|
||||
)
|
||||
const registry = buildRegistry(freshDialectModules("mongodb"))
|
||||
const stores = dialectStores(env, entitiesOf(registry, "mongodb"))
|
||||
if (!stores) throw new Error("no dialect store")
|
||||
try {
|
||||
await expect(stores.probes.boot()).rejects.toMatchObject({ name: "MongoTopologyError", setting: "replica_set" })
|
||||
} finally {
|
||||
await stores.close()
|
||||
}
|
||||
}, 60_000)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The image the three processes run from (spec §13). These are the properties a deployment
|
||||
// depends on, and each one has cost a deployment somewhere: a manifest the install stage does not
|
||||
// copy, a root process, a base image nobody pinned to the Node the app is written for.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { parse } from "yaml"
|
||||
|
||||
const dockerfile = readFileSync(new URL("../Dockerfile", import.meta.url), "utf8")
|
||||
const workspace = parse(readFileSync(new URL("../pnpm-workspace.yaml", import.meta.url), "utf8")) as {
|
||||
minimumReleaseAgeExclude: string[]
|
||||
trustPolicy: string
|
||||
trustPolicyExclude: string[]
|
||||
}
|
||||
|
||||
describe("the Dockerfile", () => {
|
||||
it("is multi-stage, with the runtime stage compose.yaml names", () => {
|
||||
expect(dockerfile).toMatch(/^FROM node:24-slim AS deps$/m)
|
||||
expect(dockerfile).toMatch(/^FROM deps AS build$/m)
|
||||
expect(dockerfile).toMatch(/^FROM node:24-slim AS runtime$/m)
|
||||
})
|
||||
|
||||
it("copies the manifest of every workspace package before installing", () => {
|
||||
// The install stage resolves the whole workspace, so a package whose manifest is missing
|
||||
// fails the install with "no package named @app/<id> is present in the workspace".
|
||||
const packages = readdirSync(new URL("..", import.meta.url), { withFileTypes: true })
|
||||
expect(packages.some((entry) => entry.name === "pnpm-workspace.yaml")).toBe(true)
|
||||
for (const dir of ["config", "modules", "apps/web", "apps/worker"]) {
|
||||
expect(dockerfile, `${dir}/package.json is not copied`).toContain(`COPY ${dir}/package.json ./${dir}/`)
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps code root-owned but lets the runtime user write generated code", () => {
|
||||
expect(dockerfile).toMatch(/^USER node$/m)
|
||||
expect(dockerfile).toContain("--chown=root:root")
|
||||
expect(dockerfile).toContain(
|
||||
"chown -R node:node /app/convex/_generated /app/better-auth/functions/_generated /app/better-auth/functions/betterAuth/_generated",
|
||||
)
|
||||
})
|
||||
|
||||
it("takes the registry credential as a build secret, never as a layer or a build argument", () => {
|
||||
// Two build paths deliver the same credential under two ids: a preset single-image build
|
||||
// mounts it as `npmrc`, a compose build as `conusai_npmrc` (the platform prefixes its compose
|
||||
// secrets so a tenant's own can never collide). The install must accept either — a
|
||||
// deployment that built through the preset path got no credential at all and pnpm fell back
|
||||
// to the public registry, where the vendor scopes 404 (stage, deployment 4, 2026-10-20).
|
||||
expect(dockerfile).toContain("--mount=type=secret,id=conusai_npmrc,target=/run/secrets/conusai_npmrc")
|
||||
expect(dockerfile).toContain("--mount=type=secret,id=npmrc,target=/run/secrets/npmrc")
|
||||
expect(dockerfile).not.toMatch(/ARG .*(TOKEN|PASSWORD|SECRET)/)
|
||||
expect(dockerfile).not.toMatch(/_authToken/)
|
||||
})
|
||||
|
||||
it("excludes the vendor scopes from the release-age cooldown", () => {
|
||||
// minimumReleaseAge blocks versions published in the last seven days — but every dev
|
||||
// bundle publishes the vendor packages minutes before a deployment installs them, from
|
||||
// the ConusAI vendor registry, so the cooldown must never apply to either scope
|
||||
// (stage, deployment 5, 2026-10-20: ERR_PNPM_NO_MATURE_MATCHING_VERSION).
|
||||
expect(workspace.minimumReleaseAgeExclude).toContain("@conusai-sdk/*")
|
||||
expect(workspace.minimumReleaseAgeExclude).toContain("@conusai-kit/*")
|
||||
})
|
||||
|
||||
it("keeps the trust policy on, excepting one exact version", () => {
|
||||
// A range or a whole package here would switch the policy off for releases nobody has looked at: an entry names
|
||||
// the version, its chain and its reason (pnpm-workspace.yaml), and its integrity is checked against the registry.
|
||||
expect(workspace.trustPolicy).toBe("no-downgrade")
|
||||
expect(workspace.trustPolicyExclude).toEqual(["semver@6.3.1"])
|
||||
})
|
||||
|
||||
it("starts no process by default: compose.yaml names the one each service runs", () => {
|
||||
const cmd = dockerfile.match(/^CMD (.*)$/m)?.[1] ?? ""
|
||||
expect(cmd).toContain("compose.yaml")
|
||||
expect(cmd).not.toMatch(/server\.mjs|vite|main\.js|migrate\.js/)
|
||||
})
|
||||
|
||||
it("carries the Start build, and compose.yaml starts the web process on it with no package manager", () => {
|
||||
// The web service's argv is the server itself: a package manager as PID 1 does not forward SIGTERM.
|
||||
const compose = parse(readFileSync(new URL("../compose.yaml", import.meta.url), "utf8")) as {
|
||||
services: Record<string, { command?: string[]; working_dir?: string }>
|
||||
}
|
||||
expect(compose.services.web?.working_dir).toBe("/app/apps/web")
|
||||
expect(compose.services.web?.command).toEqual(["node", "server.mjs"])
|
||||
expect(dockerfile).not.toContain(".next")
|
||||
expect(dockerfile).not.toMatch(/\bnext (?:build|start)\b/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The extraction of the documents module through the ModelGateway (plan item AT2.3): what the model is
|
||||
// sent, what is done with its answer, and what happens when it does not answer. The model is the fake
|
||||
// platform's gateway route, scripted per test; the gateway client, the extractor, the verification, the
|
||||
// cache, the proposals and the review all run for real.
|
||||
|
||||
import { type LineView, LOW_TRUST_NOTE } from "@app/modules/documents"
|
||||
import { uploadViaProcedures } from "@conusai-kit/testing"
|
||||
import type { FakeChatResponder } from "@conusai-kit/testing/fake-platform"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import {
|
||||
bytesOf,
|
||||
closeApps,
|
||||
type DocumentsApp,
|
||||
makeDocumentsApp,
|
||||
type SeededProject,
|
||||
uploadAndProcess,
|
||||
} from "./support/documents-app.js"
|
||||
import { deterministicModel } from "./support/fake-model.js"
|
||||
|
||||
afterEach(closeApps)
|
||||
|
||||
/** Every page image the platform drew, as the fake platform recorded it. */
|
||||
async function pagesDrawn(app: DocumentsApp): Promise<Array<{ job: number; page: number; dpi: number }>> {
|
||||
const state = (await (await fetch(`${app.platform.url}/__e2e/state`)).json()) as {
|
||||
ocrPages: Array<{ job: number; page: number; dpi: number }>
|
||||
}
|
||||
return state.ocrPages
|
||||
}
|
||||
|
||||
const PAGE = { width: 1000, height: 1400, unit: "px", dpi: 150, rotation: 0 }
|
||||
|
||||
/**
|
||||
* One page of an OCR document whose lines are `rows`, 40 px apart, one block per line (`p{n}-b{m}`), as
|
||||
* the platform returns it once the page is classified: a scan when it was recognised, text otherwise.
|
||||
* `repeated` lists the rows the reader flagged as repeating on most pages.
|
||||
*/
|
||||
function pageOf(
|
||||
index: number,
|
||||
rows: string[],
|
||||
source: "text_layer" | "ocr" = "text_layer",
|
||||
confidence = 0.97,
|
||||
options: { pageClass?: string; repeated?: readonly string[] } = {},
|
||||
) {
|
||||
return {
|
||||
...PAGE,
|
||||
index,
|
||||
class: options.pageClass ?? (source === "ocr" ? "scan" : "text"),
|
||||
source,
|
||||
text: rows.join("\n"),
|
||||
lines: rows.map((text, order) => ({
|
||||
text,
|
||||
bbox: [100, 100 + order * 40, 600, 130 + order * 40],
|
||||
polygon: null,
|
||||
confidence,
|
||||
order,
|
||||
words: [],
|
||||
repeated: options.repeated?.includes(text) ?? false,
|
||||
})),
|
||||
blocks: rows.map((_, order) => ({
|
||||
id: `p${index + 1}-b${order + 1}`,
|
||||
bbox: [90, 90 + order * 40, 700, 140 + order * 40],
|
||||
order,
|
||||
lines: [order],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** A row long enough that a page of it is not weak for want of text: its image is not asked for. */
|
||||
const SHEET_ROW = "Reference: QR-2026-0417 (rev 2)"
|
||||
|
||||
const documentOf = (...pages: Array<ReturnType<typeof pageOf>>) => ({
|
||||
schema_version: "conusai.ocr.document/v1",
|
||||
engine: { name: "fixture-engine", version: "1", detection_model: null, recognition_model: null },
|
||||
page_count: pages.length,
|
||||
pages,
|
||||
})
|
||||
|
||||
/** A one-page OCR document whose lines are `rows`. */
|
||||
const onePage = (rows: string[], source: "text_layer" | "ocr" = "text_layer", confidence = 0.97) =>
|
||||
documentOf(pageOf(0, rows, source, confidence))
|
||||
|
||||
interface Detail {
|
||||
document: {
|
||||
status: string
|
||||
failureCode: string | null
|
||||
pending: number
|
||||
skippedPages: Array<{ page: number; pageClass: string }>
|
||||
}
|
||||
lines: LineView[]
|
||||
}
|
||||
|
||||
async function run(
|
||||
responder: FakeChatResponder | undefined,
|
||||
document: Record<string, unknown>,
|
||||
upload: { filename: string; contentType: string } = { filename: "report.pdf", contentType: "application/pdf" },
|
||||
): Promise<{ app: DocumentsApp; project: SeededProject; detail: Detail }> {
|
||||
const app = await makeDocumentsApp({ document, ...(responder ? { responder } : {}) })
|
||||
const project = await app.seedProject("acme", "owner@acme.test")
|
||||
const file = await uploadAndProcess(app, project, upload.filename, upload.contentType)
|
||||
const detail = (await project.call.callByName("documents.document.get", {
|
||||
...project.slugs,
|
||||
fileId: file.fileId,
|
||||
})) as Detail
|
||||
return { app, project, detail }
|
||||
}
|
||||
|
||||
const reply = (lines: Array<{ label: string; value: string; quote: string }>) => ({
|
||||
content: JSON.stringify({ lines }),
|
||||
})
|
||||
|
||||
type Message = { role: string; content: string | Array<{ type: string; text?: string; image_url?: { url: string } }> }
|
||||
const messagesOf = (request: Record<string, unknown>) => request.messages as Message[]
|
||||
const userParts = (request: Record<string, unknown>) => {
|
||||
const content = messagesOf(request).find((message) => message.role === "user")?.content
|
||||
return typeof content === "string" ? [] : (content ?? [])
|
||||
}
|
||||
|
||||
describe("what the model is sent", () => {
|
||||
it("asks once per page, with the page's text and the schema of the module's line", async () => {
|
||||
const { app } = await run(undefined, onePage(["Reference: QR-1", "Issued on: 2026-04-01"]))
|
||||
expect(app.model.requests).toHaveLength(1)
|
||||
const request = app.model.requests[0] as Record<string, unknown>
|
||||
expect(request.model).toBe("fake-model")
|
||||
expect(request.temperature).toBe(0)
|
||||
|
||||
const [system] = messagesOf(request)
|
||||
expect(system?.role).toBe("system")
|
||||
const [text, ...rest] = userParts(request)
|
||||
expect(text?.text).toContain("Page 1 of 1.")
|
||||
expect(text?.text).toContain("Reference: QR-1\nIssued on: 2026-04-01")
|
||||
// a text-layer PDF page needs no image
|
||||
expect(rest).toEqual([])
|
||||
|
||||
const format = request.response_format as {
|
||||
type: string
|
||||
json_schema: { schema: { properties: { lines: { items: { required: string[]; properties: object } } } } }
|
||||
}
|
||||
expect(format.type).toBe("json_schema")
|
||||
const item = format.json_schema.schema.properties.lines.items
|
||||
expect(item.required.sort()).toEqual(["label", "quote", "value"])
|
||||
// the page, the position and the confidence are the code's to set, so the model is not asked for them
|
||||
expect(Object.keys(item.properties).sort()).toEqual(["label", "quote", "value"])
|
||||
})
|
||||
|
||||
it("adds the page image, as a content part, for a scanned page of an image upload", async () => {
|
||||
const { app } = await run(undefined, onePage(["Reference: QR-1"], "ocr", 0.7), {
|
||||
filename: "scan.png",
|
||||
contentType: "image/png",
|
||||
})
|
||||
const parts = userParts(app.model.requests[0] as Record<string, unknown>)
|
||||
expect(parts.map((part) => part.type)).toEqual(["text", "image_url"])
|
||||
expect(parts[1]?.image_url?.url).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("fetches the image of a scanned page of a PDF from the platform's page route and sends it with the text", async () => {
|
||||
const { app, detail } = await run(undefined, onePage(["Reference: QR-1"], "ocr", 0.7))
|
||||
const parts = userParts(app.model.requests[0] as Record<string, unknown>)
|
||||
expect(parts.map((part) => part.type)).toEqual(["text", "image_url"])
|
||||
expect(parts[0]?.text).toContain(LOW_TRUST_NOTE)
|
||||
expect(parts[1]?.image_url?.url).toMatch(/^data:image\/png;base64,iVBORw0KGgo/)
|
||||
// the page was drawn once, for the job that read the file, at the default dpi
|
||||
expect(await pagesDrawn(app)).toEqual([{ job: 1, page: 1, dpi: 150 }])
|
||||
expect(detail.lines.map((line) => line.value)).toEqual(["QR-1"])
|
||||
})
|
||||
|
||||
it("sends a table page as image and text, and a text page as text alone without asking for its image", async () => {
|
||||
const { app } = await run(
|
||||
undefined,
|
||||
documentOf(pageOf(0, ["Item | Qty", "Bolts | 12"], "ocr", 0.97, { pageClass: "table" }), pageOf(1, [SHEET_ROW])),
|
||||
)
|
||||
const kinds = app.model.requests.map((request) =>
|
||||
userParts(request as Record<string, unknown>).map((part) => part.type),
|
||||
)
|
||||
expect(kinds).toContainEqual(["text", "image_url"])
|
||||
expect(kinds).toContainEqual(["text"])
|
||||
expect(await pagesDrawn(app)).toEqual([{ job: 1, page: 1, dpi: 150 }])
|
||||
})
|
||||
|
||||
it("leaves the header that repeats on the pages out of the text the model is shown", async () => {
|
||||
const header = "ACME: Specification sheet"
|
||||
const { app, detail } = await run(
|
||||
undefined,
|
||||
documentOf(
|
||||
pageOf(0, [header, "Reference: QR-1"], "text_layer", 0.97, { repeated: [header] }),
|
||||
pageOf(1, [header, "Issued on: 2026-04-01"], "text_layer", 0.97, { repeated: [header] }),
|
||||
),
|
||||
)
|
||||
for (const request of app.model.requests) {
|
||||
expect(userParts(request as Record<string, unknown>)[0]?.text).not.toContain("ACME")
|
||||
}
|
||||
expect(detail.lines.map((line) => line.label)).toEqual(["Reference", "Issued on"])
|
||||
})
|
||||
|
||||
it("puts the reader's block id in the evidence of a line, and its block's box", async () => {
|
||||
const { project, detail } = await run(undefined, onePage(["Reference: QR-1", "Issued on: 2026-04-01"]))
|
||||
expect(detail.lines.map((line) => line.blockId)).toEqual(["p1-b1", "p1-b2"])
|
||||
expect(detail.lines[1]?.box).toEqual({ x: 0.09, y: 130 / 1400, width: 0.61, height: 50 / 1400 })
|
||||
const proposals = (await project.call.callByName("proposals.proposal.list", {
|
||||
...project.slugs,
|
||||
status: "pending",
|
||||
})) as { proposals: Array<{ evidence: Array<{ blockId?: string }> }> }
|
||||
expect(proposals.proposals.flatMap((proposal) => proposal.evidence.map((item) => item.blockId)).sort()).toEqual([
|
||||
"p1-b1",
|
||||
"p1-b2",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the pages left out", () => {
|
||||
const labels = Array.from({ length: 300 }, (_, at) => `A-${at} 2400`)
|
||||
|
||||
it("never asks the model about a drawing or an empty page, and reports them on the document", async () => {
|
||||
const { app, detail } = await run(
|
||||
undefined,
|
||||
documentOf(
|
||||
pageOf(0, [SHEET_ROW]),
|
||||
pageOf(1, labels, "text_layer", 0.97, { pageClass: "drawing" }),
|
||||
pageOf(2, [], "ocr", 0.97, { pageClass: "empty" }),
|
||||
),
|
||||
)
|
||||
expect(app.model.requests).toHaveLength(1)
|
||||
expect(detail.document).toMatchObject({
|
||||
status: "extracted",
|
||||
skippedPages: [
|
||||
{ page: 2, pageClass: "drawing" },
|
||||
{ page: 3, pageClass: "empty" },
|
||||
],
|
||||
})
|
||||
expect(detail.lines.map((line) => line.value)).toEqual(["QR-2026-0417 (rev 2)"])
|
||||
// no image was drawn for a page nobody reads
|
||||
expect(await pagesDrawn(app)).toEqual([])
|
||||
})
|
||||
|
||||
it("reads a scan beside a drawing: the scan produces lines, the drawing none", async () => {
|
||||
const { detail } = await run(
|
||||
undefined,
|
||||
documentOf(
|
||||
pageOf(0, labels, "text_layer", 0.97, { pageClass: "drawing" }),
|
||||
pageOf(1, ["Delivery no: DN-77", "Received by: A. Sample"], "ocr", 0.8),
|
||||
),
|
||||
)
|
||||
expect(detail.lines.map((line) => [line.page, line.value])).toEqual([
|
||||
[2, "DN-77"],
|
||||
[2, "A. Sample"],
|
||||
])
|
||||
expect(detail.document.skippedPages).toEqual([{ page: 1, pageClass: "drawing" }])
|
||||
})
|
||||
|
||||
it("reads a drawing when the deployment's list does not exclude it", async () => {
|
||||
const app = await makeDocumentsApp({
|
||||
document: documentOf(pageOf(0, ["Scale: 1:50"], "ocr", 0.9, { pageClass: "drawing" })),
|
||||
excludedPageClasses: [],
|
||||
})
|
||||
const project = await app.seedProject("acme", "owner@acme.test")
|
||||
const file = await uploadAndProcess(app, project, "plan.pdf", "application/pdf")
|
||||
const detail = (await project.call.callByName("documents.document.get", {
|
||||
...project.slugs,
|
||||
fileId: file.fileId,
|
||||
})) as Detail
|
||||
expect(detail.document.skippedPages).toEqual([])
|
||||
expect(detail.lines.map((line) => line.value)).toEqual(["1:50"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("a quote taken from the image of a scanned page", () => {
|
||||
it("stays needs_review with the scanned-quote note when the page text does not hold it", async () => {
|
||||
const { detail } = await run(
|
||||
() => reply([{ label: "Qty", value: "12", quote: "Qty 12 pcs" }]),
|
||||
onePage(["Delivery note"], "ocr", 0.7),
|
||||
)
|
||||
expect(detail.lines).toHaveLength(1)
|
||||
expect(detail.lines[0]).toMatchObject({ verification: "needs_review", note: "scanned_quote", confidence: 0 })
|
||||
})
|
||||
|
||||
it("carries no note for a quote the text holds", async () => {
|
||||
const { detail } = await run(undefined, onePage(["Reference: QR-1"], "ocr", 0.7))
|
||||
expect(detail.lines[0]).toMatchObject({ verification: "verified", note: null, blockId: "p1-b1" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("what is done with the answer", () => {
|
||||
const rows = ["Sąskaita: INV-2026-07", "Suma: 1 250,00 EUR", "Pastaba: apmokėti per 14 dienų"]
|
||||
|
||||
it("verifies each quote on the page it was asked about, folding only whitespace, case and diacritics", async () => {
|
||||
const { detail } = await run(
|
||||
() =>
|
||||
reply([
|
||||
{ label: "Sąskaita", value: "INV-2026-07", quote: "Sąskaita: INV-2026-07" },
|
||||
// case, diacritics and whitespace differ from the page: still the same words
|
||||
{ label: "Suma", value: "1 250,00 EUR", quote: "suma: 1 250,00\n eur" },
|
||||
{ label: "Pastaba", value: "apmokėti per 14 dienų", quote: "PASTABA: APMOKETI PER 14 DIENU" },
|
||||
]),
|
||||
onePage(rows),
|
||||
)
|
||||
expect(detail.lines.map((line) => [line.label, line.verification])).toEqual([
|
||||
["Sąskaita", "verified"],
|
||||
["Suma", "verified"],
|
||||
["Pastaba", "verified"],
|
||||
])
|
||||
// a verified line is located from the reader's own lines, and carries the reader's confidence
|
||||
expect(detail.lines[0]?.box).not.toBeNull()
|
||||
expect(detail.lines[0]?.confidence).toBe(0.97)
|
||||
expect(detail.lines.every((line) => line.page === 1 && line.status === "pending")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps a line whose quote is not on the page as needs_review, with no box and no confidence", async () => {
|
||||
const { detail } = await run(
|
||||
() =>
|
||||
reply([
|
||||
{ label: "Sąskaita", value: "INV-2026-07", quote: "Sąskaita: INV-2026-07" },
|
||||
// a changed digit is a different word: folding does not forgive it
|
||||
{ label: "Suma", value: "1 520,00 EUR", quote: "Suma: 1 520,00 EUR" },
|
||||
{ label: "Made up", value: "x", quote: "Nothing like this is written here" },
|
||||
]),
|
||||
onePage(rows),
|
||||
)
|
||||
expect(detail.lines.map((line) => [line.label, line.verification])).toEqual([
|
||||
["Sąskaita", "verified"],
|
||||
["Suma", "needs_review"],
|
||||
["Made up", "needs_review"],
|
||||
])
|
||||
const flagged = detail.lines.filter((line) => line.verification === "needs_review")
|
||||
expect(flagged.every((line) => line.box === null && line.confidence === 0 && line.status === "pending")).toBe(true)
|
||||
// kept, not dropped and not approved: the document is waiting for a person
|
||||
expect(detail.document).toMatchObject({ status: "extracted", pending: 3 })
|
||||
})
|
||||
|
||||
it("verifies against the page the model was asked about, not against another page", async () => {
|
||||
const two = {
|
||||
...onePage(["Reference: QR-1"]),
|
||||
page_count: 2,
|
||||
pages: [...onePage(["Reference: QR-1"]).pages, { ...onePage(["Total: 5 EUR"]).pages[0], index: 1 }],
|
||||
}
|
||||
// the model answers every page with page 1's line: on page 2 it is not there
|
||||
const { detail } = await run(() => reply([{ label: "Reference", value: "QR-1", quote: "Reference: QR-1" }]), two)
|
||||
expect(detail.lines.map((line) => [line.page, line.verification])).toEqual([
|
||||
[1, "verified"],
|
||||
[2, "needs_review"],
|
||||
])
|
||||
})
|
||||
|
||||
it("drops a line the model repeats", async () => {
|
||||
const line = { label: "Reference", value: "QR-1", quote: "Reference: QR-1" }
|
||||
const { detail } = await run(() => reply([line, line]), onePage(["Reference: QR-1"]))
|
||||
expect(detail.lines).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the model does not answer", () => {
|
||||
const document = onePage(["Reference: QR-1"])
|
||||
|
||||
it("a refusal is asked again once, then ends the document with a typed code and no proposals", async () => {
|
||||
const { app, detail } = await run(() => ({ content: null, refusal: "I cannot help with that." }), document)
|
||||
expect(app.model.requests).toHaveLength(2)
|
||||
expect(detail.document).toMatchObject({ status: "failed", failureCode: "extraction_refused" })
|
||||
expect(detail.lines).toEqual([])
|
||||
})
|
||||
|
||||
it("a reply cut off at the token ceiling is asked again with more room, and the second answer is used", async () => {
|
||||
let calls = 0
|
||||
const { app, detail } = await run((request) => {
|
||||
calls += 1
|
||||
return calls === 1 ? { content: '{"lines": [{"label"', finishReason: "length" } : deterministicModel(request)
|
||||
}, document)
|
||||
expect(app.model.requests.map((request) => request.max_tokens)).toEqual([4096, 8192])
|
||||
expect(detail.document.status).toBe("extracted")
|
||||
expect(detail.lines).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("a reply that is always cut off ends the document as truncated", async () => {
|
||||
const { app, detail } = await run(() => ({ content: '{"lines": [', finishReason: "length" }), document)
|
||||
expect(app.model.requests).toHaveLength(2)
|
||||
expect(detail.document).toMatchObject({ status: "failed", failureCode: "extraction_truncated" })
|
||||
})
|
||||
|
||||
it("a reply outside the schema is asked again once, by the gateway, and then ends the document as invalid", async () => {
|
||||
const { app, detail } = await run(() => ({ content: JSON.stringify({ lines: "none" }) }), document)
|
||||
// two asks in all: the gateway's retry with the validation errors is the one retry
|
||||
expect(app.model.requests).toHaveLength(2)
|
||||
expect(detail.document).toMatchObject({ status: "failed", failureCode: "extraction_invalid" })
|
||||
const retry = messagesOf(app.model.requests[1] as Record<string, unknown>).at(-1)
|
||||
expect(retry?.content).toContain("did not match the required schema")
|
||||
})
|
||||
|
||||
it("records no document text in the failure: the code is all the document keeps", async () => {
|
||||
const { detail } = await run(() => ({ content: null, refusal: "Reference: QR-1" }), document)
|
||||
expect(JSON.stringify(detail.document)).not.toContain("QR-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the cache", () => {
|
||||
it("cross-file OCR reuse requires verified engine identity while identical model inputs remain cached", async () => {
|
||||
const { app, project } = await run(undefined, onePage(["Reference: QR-1", "Issued on: 2026-04-01"]))
|
||||
expect(app.ocr.submitted).toHaveLength(1)
|
||||
expect(app.model.requests).toHaveLength(1)
|
||||
const stored = app.store.keysOf({ level: "project", orgId: project.orgId } as never)
|
||||
// the OCR result, the job it came from (so its pages can still be drawn), and the page's model reply
|
||||
expect(stored.filter((key) => key.startsWith("documents/cache/"))).toHaveLength(3)
|
||||
|
||||
// the same bytes under another name: a second file, the same content hash
|
||||
const second = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "copy.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4 fixture report.pdf"),
|
||||
})
|
||||
await app.deliver()
|
||||
await app.runJobs()
|
||||
|
||||
expect(app.ocr.submitted).toHaveLength(2)
|
||||
expect(app.model.requests).toHaveLength(1)
|
||||
const view = (await project.call.callByName("documents.document.get", {
|
||||
...project.slugs,
|
||||
fileId: second.fileId,
|
||||
})) as Detail
|
||||
expect(view.lines.map((line) => line.value)).toEqual(["QR-1", "2026-04-01"])
|
||||
})
|
||||
|
||||
it("a change of the page's text is a different request", async () => {
|
||||
const { app, project } = await run(undefined, onePage(["Reference: QR-1"]))
|
||||
app.platform.ocr.setDocument(onePage(["Reference: QR-2"]))
|
||||
await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "other.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4 something else"),
|
||||
})
|
||||
await app.deliver()
|
||||
await app.runJobs()
|
||||
expect(app.model.requests).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,530 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The document-review flow end to end on the fake platform (plan item AT2.3): an uploaded file is
|
||||
// read by an OCR job, becomes proposals, a person approves, edits or rejects each line, and only
|
||||
// approved lines are exported. Every step runs through the kernel's caller, the files module's
|
||||
// processor point, the proposals module's writer and the outbox relay's behaviour, so what is proven
|
||||
// is the wiring and not a unit.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { type LineView, lines as linesTable, REVIEW_PROCEDURES, type ReadDocument } from "@app/modules/documents"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { orgMemberships } from "@conusai-kit/tenancy"
|
||||
import { uploadViaProcedures } from "@conusai-kit/testing"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { sheetRows } from "../modules/documents/__tests__/support/unzip.js"
|
||||
import {
|
||||
bytesOf,
|
||||
closeApps,
|
||||
type DocumentsApp,
|
||||
makeDocumentsApp,
|
||||
type SeededProject,
|
||||
uploadAndProcess,
|
||||
} from "./support/documents-app.js"
|
||||
|
||||
const document = JSON.parse(
|
||||
readFileSync(new URL("../modules/documents/__tests__/fixtures/ocr-document.json", import.meta.url), "utf8"),
|
||||
) as ReadDocument & Record<string, unknown>
|
||||
|
||||
afterEach(closeApps)
|
||||
|
||||
interface Detail {
|
||||
document: {
|
||||
documentId: string | null
|
||||
status: string
|
||||
failureCode: string | null
|
||||
pending: number
|
||||
approved: number
|
||||
pageCount: number | null
|
||||
skippedPages: unknown[]
|
||||
}
|
||||
lines: LineView[]
|
||||
}
|
||||
|
||||
async function fresh(): Promise<{ app: DocumentsApp; project: SeededProject }> {
|
||||
const app = await makeDocumentsApp({ document })
|
||||
return { app, project: await app.seedProject("acme", "owner@acme.test") }
|
||||
}
|
||||
|
||||
const detail = async (project: SeededProject, fileId: string): Promise<Detail> =>
|
||||
(await project.call.callByName("documents.document.get", { ...project.slugs, fileId })) as Detail
|
||||
|
||||
const decide = (project: SeededProject, procedure: string, input: Record<string, unknown>) =>
|
||||
project.call.callByName(procedure, { ...project.slugs, ...input })
|
||||
|
||||
const exportRows = async (project: SeededProject, fileId: string) => {
|
||||
const made = (await decide(project, "documents.export.create", { fileId })) as {
|
||||
body: string
|
||||
lineCount: number
|
||||
filename: string
|
||||
}
|
||||
return { ...made, rows: sheetRows(Buffer.from(made.body, "base64")) }
|
||||
}
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return error
|
||||
throw error
|
||||
}
|
||||
throw new Error("expected a refusal")
|
||||
}
|
||||
|
||||
describe("uploading a document", () => {
|
||||
it("answers before OCR runs: the request enqueues, and the worker reads", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "report.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4 fixture"),
|
||||
})
|
||||
// The upload has returned and the reader has not been asked anything.
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
expect(app.queue.of("documents.ocr")).toHaveLength(0)
|
||||
// The stored file's event is what enqueues the job, once.
|
||||
await app.deliver()
|
||||
expect(app.queue.of("documents.ocr")).toHaveLength(1)
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
await app.runJobs()
|
||||
expect(app.ocr.submitted).toHaveLength(1)
|
||||
const inbox = (await project.call.callByName("notifications.inbox.list", { org: project.orgSlug })) as {
|
||||
items: Array<{ code: string; href: string | null }>
|
||||
}
|
||||
expect(inbox.items.filter((item) => item.code === "job_completed")).toHaveLength(1)
|
||||
expect(inbox.items.find((item) => item.code === "job_completed")?.href).toBe(
|
||||
`/${project.orgSlug}/${project.workspaceSlug}/${project.projectSlug}/documents/${file.fileId}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("is not read by this module when it is not a document type it reads", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "notes.csv",
|
||||
contentType: "text/csv",
|
||||
bytes: bytesOf("a,b"),
|
||||
})
|
||||
await app.deliver()
|
||||
expect(app.queue.of("documents.ocr")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("lists an upload nobody has read yet as queued", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "report.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4"),
|
||||
})
|
||||
const { documents } = (await decide(project, "documents.document.list", {})) as {
|
||||
documents: Array<{ fileId: string; status: string }>
|
||||
}
|
||||
expect(documents).toEqual([expect.objectContaining({ documentId: null, fileId: file.fileId, status: "queued" })])
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the live collection's read-back", () => {
|
||||
it("reads a document again by the id of its row, with its counts, as the list shows it", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const { documents } = (await decide(project, "documents.document.list", {})) as {
|
||||
documents: Array<{ documentId: string | null; fileId: string }>
|
||||
}
|
||||
const listed = documents.find((entry) => entry.fileId === file.fileId)
|
||||
expect(listed?.documentId).toEqual(expect.any(String))
|
||||
const { items } = (await decide(project, "documents.document.getMany", { ids: [listed?.documentId] })) as {
|
||||
items: unknown[]
|
||||
}
|
||||
expect(items).toEqual([listed])
|
||||
})
|
||||
|
||||
it("answers nothing for an id of another project or of nothing", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const documentId = (await detail(project, file.fileId)).document.documentId
|
||||
const other = await app.seedProject("bravo", "owner@bravo.test")
|
||||
const ask = (asker: SeededProject, ids: string[]) =>
|
||||
asker.call.callByName("documents.document.getMany", { ...asker.slugs, ids }) as Promise<{ items: unknown[] }>
|
||||
expect((await ask(other, [documentId as string])).items).toEqual([])
|
||||
expect((await ask(project, ["00000000-0000-0000-0000-000000000000"])).items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the extraction", () => {
|
||||
it("turns the fixture document into one pending line per labelled line, each with its page and verbatim quote", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const { document: view, lines } = await detail(project, file.fileId)
|
||||
|
||||
// the fixture's third page is a drawing: left out, and said so on the document
|
||||
expect(view).toMatchObject({
|
||||
status: "extracted",
|
||||
pending: 4,
|
||||
approved: 0,
|
||||
pageCount: 3,
|
||||
skippedPages: [{ page: 3, pageClass: "drawing" }],
|
||||
})
|
||||
expect(lines.map((line) => [line.page, line.label, line.value, line.status])).toEqual([
|
||||
[1, "Reference", "QR-2026-04", "pending"],
|
||||
[1, "Issued on", "2026-04-01", "pending"],
|
||||
[2, "Total amount", "1 250,00 EUR", "pending"],
|
||||
[2, "Signed by", "A. Example", "pending"],
|
||||
])
|
||||
for (const line of lines) {
|
||||
expect(document.pages[line.page - 1]?.text).toContain(line.quote)
|
||||
expect(line.box).not.toBeNull()
|
||||
// The line says which engine and extractor produced it: the Art. 50 disclosure's subject.
|
||||
expect(line.model).toBe("fake-model")
|
||||
expect(line.source).toMatchObject({
|
||||
ocrEngine: { name: "fixture-engine", version: document.engine.version },
|
||||
model: "fake-model",
|
||||
fileSha256: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
expect(line.promptVersion).toBe("extract-lines.v2")
|
||||
}
|
||||
})
|
||||
|
||||
it("submits the document once, with a key that names the file and its content", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
expect(app.ocr.submitted).toHaveLength(1)
|
||||
expect(app.ocr.submitted[0]?.idempotencyKey).toMatch(
|
||||
new RegExp(`^documents\\.ocr\\.${file.fileId}\\.[0-9a-f]{64}$`),
|
||||
)
|
||||
})
|
||||
|
||||
it("writes nothing the reviewer has not approved", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
expect(
|
||||
await app.data
|
||||
.forScope({ level: "project", actor: project.owner, ...scopeIds(project) })
|
||||
.of(linesTable)
|
||||
.count(),
|
||||
).toBe(0)
|
||||
// and so an export has nothing to say yet
|
||||
const error = await refusal(decide(project, "documents.export.create", { fileId: file.fileId }))
|
||||
expect(error.status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
describe("retrying the job", () => {
|
||||
it("a job run twice adds no proposal and submits no second document", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const job = app.queue.jobs.find((entry) => entry.job === "documents.ocr")
|
||||
expect(job).toBeDefined()
|
||||
// The queue delivers the same job again (a lapsed claim): run its handler once more.
|
||||
const again = app.queue.jobs.length
|
||||
await app.queue.enqueue(job?.scope as never, "documents.ocr", job?.payload as never)
|
||||
expect(app.queue.jobs).toHaveLength(again + 1)
|
||||
await app.runJobs()
|
||||
expect(app.ocr.submitted).toHaveLength(1)
|
||||
expect((await detail(project, file.fileId)).lines).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("a transient failure while waiting resumes the stored OCR job instead of submitting another", async () => {
|
||||
const { app, project } = await fresh()
|
||||
app.ocr.failWaitOnce(new Error("the platform is unreachable"))
|
||||
const file = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "report.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4"),
|
||||
})
|
||||
await app.deliver()
|
||||
await expect(app.runJobs()).rejects.toThrow("unreachable")
|
||||
expect((await detail(project, file.fileId)).document.status).toBe("processing")
|
||||
|
||||
// pg-boss retries a job that throws: the same payload, the same scope.
|
||||
const job = app.queue.jobs.find((entry) => entry.job === "documents.ocr")
|
||||
await app.queue.enqueue(job?.scope as never, "documents.ocr", job?.payload as never, { key: "retry" })
|
||||
await app.runJobs()
|
||||
expect(app.ocr.submitted).toHaveLength(1)
|
||||
expect(app.ocr.waited).toHaveLength(2)
|
||||
expect((await detail(project, file.fileId)).document).toMatchObject({ status: "extracted", pending: 4 })
|
||||
})
|
||||
|
||||
it("marks the document failed, with a code and no proposals, when the platform says the job failed", async () => {
|
||||
const { app, project } = await fresh()
|
||||
app.platform.ocr.failJobs(true)
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const { document: view, lines } = await detail(project, file.fileId)
|
||||
expect(view).toMatchObject({ status: "failed", failureCode: "ocr_failed" })
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
|
||||
it("marks the document failed when the stored bytes are not the ones that were uploaded", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "report.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4 original"),
|
||||
})
|
||||
for (const [key, object] of app.store.objects)
|
||||
app.store.objects.set(key, { ...object, bytes: new TextEncoder().encode("tampered") })
|
||||
await app.deliver()
|
||||
await app.runJobs()
|
||||
expect((await detail(project, file.fileId)).document).toMatchObject({
|
||||
status: "failed",
|
||||
failureCode: "file_changed",
|
||||
})
|
||||
const inbox = (await project.call.callByName("notifications.inbox.list", { org: project.orgSlug })) as {
|
||||
items: Array<{ code: string }>
|
||||
}
|
||||
expect(inbox.items.filter((item) => item.code === "job_failed")).toHaveLength(1)
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the review", () => {
|
||||
it("approving a line writes the record and one audit event; the other lines stay pending", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const [first] = (await detail(project, file.fileId)).lines
|
||||
await decide(project, REVIEW_PROCEDURES.approve, {
|
||||
proposalId: first?.lineId,
|
||||
expectedVersion: first?.expectedVersion,
|
||||
})
|
||||
await app.deliver()
|
||||
|
||||
const after = await detail(project, file.fileId)
|
||||
expect(after.document).toMatchObject({ pending: 3, approved: 1 })
|
||||
expect(after.lines.find((line) => line.lineId === first?.lineId)?.status).toBe("approved")
|
||||
const approvals = app.audit.events.filter((event) => event.action === "proposals.proposal_approved")
|
||||
expect(approvals).toHaveLength(1)
|
||||
// ids and hashes only: no label, value or quote in an audit event
|
||||
expect(JSON.stringify(approvals[0])).not.toContain("QR-2026-04")
|
||||
})
|
||||
|
||||
it("rejecting a line changes no record, and the line stays rejected with its audit event", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const [first] = (await detail(project, file.fileId)).lines
|
||||
await decide(project, REVIEW_PROCEDURES.reject, { proposalId: first?.lineId, reason: "not a field" })
|
||||
await app.deliver()
|
||||
|
||||
const after = await detail(project, file.fileId)
|
||||
expect(after.lines.find((line) => line.lineId === first?.lineId)?.status).toBe("rejected")
|
||||
expect(after.document).toMatchObject({ pending: 3, approved: 0 })
|
||||
expect(app.audit.events.filter((event) => event.action === "proposals.proposal_rejected")).toHaveLength(1)
|
||||
// nothing was written to the approved table: the export has nothing to say
|
||||
expect((await refusal(decide(project, "documents.export.create", { fileId: file.fileId }))).status).toBe(409)
|
||||
})
|
||||
|
||||
it("an edit replaces the line with the reviewer's words, which are then approved like any other", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const [, second] = (await detail(project, file.fileId)).lines
|
||||
const edited = (await decide(project, "documents.line.edit", {
|
||||
lineId: second?.lineId,
|
||||
expectedVersion: second?.expectedVersion,
|
||||
label: "Issue date",
|
||||
value: "2026-04-02",
|
||||
})) as LineView
|
||||
// the same line, with the reviewer's words in it
|
||||
expect(edited).toMatchObject({
|
||||
lineId: second?.lineId,
|
||||
expectedVersion: (second?.expectedVersion ?? 0) + 1,
|
||||
label: "Issue date",
|
||||
value: "2026-04-02",
|
||||
edited: true,
|
||||
status: "pending",
|
||||
})
|
||||
// the quote still names the words the machine read, not the reviewer's
|
||||
expect(edited.quote).toBe(second?.quote)
|
||||
const stale = await refusal(
|
||||
decide(project, "documents.line.edit", {
|
||||
lineId: second?.lineId,
|
||||
expectedVersion: second?.expectedVersion,
|
||||
label: "Stale correction",
|
||||
value: "outdated",
|
||||
}),
|
||||
)
|
||||
expect(stale.status).toBe(409)
|
||||
|
||||
const listed = (await detail(project, file.fileId)).lines
|
||||
expect(listed).toHaveLength(4)
|
||||
expect(listed.find((line) => line.lineId === second?.lineId)).toMatchObject({ label: "Issue date", edited: true })
|
||||
await app.deliver()
|
||||
// one audit event for the edit, with no word of it
|
||||
const audited = app.audit.events.filter((event) => event.action === "proposals.proposal_edited")
|
||||
expect(audited).toHaveLength(1)
|
||||
expect(JSON.stringify(audited[0])).not.toContain("Issue date")
|
||||
|
||||
await decide(project, REVIEW_PROCEDURES.approve, {
|
||||
proposalId: edited.lineId,
|
||||
expectedVersion: edited.expectedVersion,
|
||||
})
|
||||
const { rows } = await exportRows(project, file.fileId)
|
||||
expect(rows).toEqual([
|
||||
["Label", "Value", "Page", "Source quote", "Edited by a reviewer"],
|
||||
["Issue date", "2026-04-02", 1, "Issued on: 2026-04-01", "yes"],
|
||||
])
|
||||
})
|
||||
|
||||
it("a line that has been decided cannot be edited", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const [first] = (await detail(project, file.fileId)).lines
|
||||
await decide(project, REVIEW_PROCEDURES.reject, { proposalId: first?.lineId })
|
||||
const error = await refusal(
|
||||
decide(project, "documents.line.edit", {
|
||||
lineId: first?.lineId,
|
||||
expectedVersion: first?.expectedVersion,
|
||||
label: "x",
|
||||
value: "y",
|
||||
}),
|
||||
)
|
||||
expect(error.status).toBe(409)
|
||||
})
|
||||
|
||||
it("approves every pending line in one decision", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const lines = (await detail(project, file.fileId)).lines
|
||||
const ids = lines.map((line) => line.lineId)
|
||||
const expectations = Object.fromEntries(
|
||||
ids.map((id, index) => [id, { expectedVersion: lines[index]?.expectedVersion }]),
|
||||
)
|
||||
await decide(project, REVIEW_PROCEDURES.approveMany, { proposalIds: ids, expectations })
|
||||
expect((await detail(project, file.fileId)).document).toMatchObject({ pending: 0, approved: 4 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("the export", () => {
|
||||
it("contains the approved lines and only those, as an Excel workbook", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project, "quarterly.report.pdf")
|
||||
const lines = (await detail(project, file.fileId)).lines
|
||||
await decide(project, REVIEW_PROCEDURES.approve, {
|
||||
proposalId: lines[2]?.lineId,
|
||||
expectedVersion: lines[2]?.expectedVersion,
|
||||
})
|
||||
await decide(project, REVIEW_PROCEDURES.approve, {
|
||||
proposalId: lines[0]?.lineId,
|
||||
expectedVersion: lines[0]?.expectedVersion,
|
||||
})
|
||||
await decide(project, REVIEW_PROCEDURES.reject, { proposalId: lines[1]?.lineId })
|
||||
|
||||
const made = await exportRows(project, file.fileId)
|
||||
expect(made.filename).toBe("quarterly.report-approved.xlsx")
|
||||
expect(made.lineCount).toBe(2)
|
||||
expect(made.rows.slice(1)).toEqual([
|
||||
["Reference", "QR-2026-04", 1, "Reference: QR-2026-04", "no"],
|
||||
["Total amount", "1 250,00 EUR", 2, "Total amount: 1 250,00 EUR", "no"],
|
||||
])
|
||||
})
|
||||
|
||||
it("writes one audit event naming no content", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const [first] = (await detail(project, file.fileId)).lines
|
||||
await decide(project, REVIEW_PROCEDURES.approve, {
|
||||
proposalId: first?.lineId,
|
||||
expectedVersion: first?.expectedVersion,
|
||||
})
|
||||
await decide(project, "documents.export.create", { fileId: file.fileId })
|
||||
// The transport records a procedure's declared audit event (apps/web router); here the
|
||||
// contract's own declaration is the proof, since no transport runs.
|
||||
expect(app.registry.procedures.get("documents.export.create")?.def.audit).toBe("documents.export_created")
|
||||
expect(app.registry.procedures.get("documents.line.edit")?.def.audit).toBe("documents.line_edited")
|
||||
})
|
||||
})
|
||||
|
||||
describe("who may do what", () => {
|
||||
it("lets a viewer read a document and refuses them (404, as a stranger) an edit, an approval and an export", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const [first] = (await detail(project, file.fileId)).lines
|
||||
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
|
||||
|
||||
const read = (await viewer.call.callByName("documents.document.get", {
|
||||
...project.slugs,
|
||||
fileId: file.fileId,
|
||||
})) as Detail
|
||||
expect(read.lines).toHaveLength(4)
|
||||
for (const [name, input] of [
|
||||
[
|
||||
"documents.line.edit",
|
||||
{ lineId: first?.lineId, expectedVersion: first?.expectedVersion, label: "x", value: "y" },
|
||||
],
|
||||
[REVIEW_PROCEDURES.approve, { proposalId: first?.lineId, expectedVersion: first?.expectedVersion }],
|
||||
["documents.export.create", { fileId: file.fileId }],
|
||||
] as const) {
|
||||
const error = await refusal(viewer.call.callByName(name, { ...project.slugs, ...input }))
|
||||
// The kernel answers a member who lacks the permission exactly as it answers a stranger
|
||||
// (spec §3.3): 404, so a refusal does not say which procedures exist for whom.
|
||||
expect(error.status, name).toBe(404)
|
||||
}
|
||||
expect((await detail(project, file.fileId)).document.approved).toBe(0)
|
||||
})
|
||||
|
||||
it("lets a member review: the permission of the line's writer is the member role's", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const [first] = (await detail(project, file.fileId)).lines
|
||||
const member = await app.addMember(project, "member@acme.test", "member")
|
||||
await member.call.callByName(REVIEW_PROCEDURES.approve, {
|
||||
...project.slugs,
|
||||
proposalId: first?.lineId,
|
||||
expectedVersion: first?.expectedVersion,
|
||||
})
|
||||
expect((await detail(project, file.fileId)).document.approved).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
const scopeIds = (project: SeededProject) => ({
|
||||
orgId: project.orgId,
|
||||
workspaceId: project.workspaceId,
|
||||
projectId: project.projectId,
|
||||
})
|
||||
|
||||
describe("current worker authority before provider costs", () => {
|
||||
it("keeps default missing native evidence off and persists an honest final outcome", async () => {
|
||||
const app = await makeDocumentsApp({ document, nativeAdmission: false })
|
||||
const project = await app.seedProject("off", "off@example.test")
|
||||
const file = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "off.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4 synthetic"),
|
||||
})
|
||||
await app.deliver()
|
||||
await app.runJobs({ finalAttempt: true })
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
expect(app.model.requests).toHaveLength(0)
|
||||
expect((await detail(project, file.fileId)).document).toMatchObject({
|
||||
status: "failed",
|
||||
failureCode: "processing_exhausted",
|
||||
})
|
||||
})
|
||||
it("rechecks the original uploader's current role instead of forging a human actor", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "revoked.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4 synthetic"),
|
||||
})
|
||||
await app.deliver()
|
||||
const repo = app.data.forScope({ level: "org", orgId: project.orgId, actor: project.owner }).of(orgMemberships)
|
||||
const member = (await repo.list({ limit: 1 })).items[0]
|
||||
if (!member) throw new Error("the uploader fixture must have an organization membership")
|
||||
await repo.update(member.id, { role: "viewer" }, member.version)
|
||||
await expect(app.runJobs()).rejects.toMatchObject({ status: 403 })
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
expect(app.model.requests).toHaveLength(0)
|
||||
})
|
||||
it("a lost claim never makes a provider call or marks a permanent failure", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const file = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "lease.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: bytesOf("%PDF-1.4 synthetic"),
|
||||
})
|
||||
await app.deliver()
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
await expect(app.runJobs({ finalAttempt: true, signal: abort.signal })).rejects.toBeDefined()
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
expect(app.model.requests).toHaveLength(0)
|
||||
expect((await detail(project, file.fileId)).document.status).not.toBe("failed")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The OCR adapter against the fake platform of @conusai-kit/testing, through the real
|
||||
// `@conusai-sdk/ocr` client (kit-platform's `DocumentReader`): the job is submitted, polled and read
|
||||
// over HTTP exactly as the platform's async job API does it, so what is proven here is the adapter's
|
||||
// calls and not a stub of them.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { OcrJobFailed, sdkDocumentOcr } from "@app/modules/documents"
|
||||
import { OcrError } from "@conusai-sdk/ocr"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { closeApps, makeDocumentsApp, uploadAndProcess } from "./support/documents-app.js"
|
||||
import { startOcr } from "./support/ocr.js"
|
||||
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL("../modules/documents/__tests__/fixtures/ocr-document.json", import.meta.url), "utf8"),
|
||||
) as Record<string, unknown>
|
||||
|
||||
const open: Array<{ close(): Promise<void> }> = []
|
||||
afterEach(async () => {
|
||||
await closeApps()
|
||||
await Promise.all(open.splice(0).map((platform) => platform.close()))
|
||||
})
|
||||
|
||||
async function ocr() {
|
||||
const started = await startOcr(fixture)
|
||||
open.push(started.platform)
|
||||
return started
|
||||
}
|
||||
|
||||
const stateOf = async (platform: { url: string }) =>
|
||||
(await (await fetch(`${platform.url}/__e2e/state`)).json()) as { ocr: unknown[] }
|
||||
|
||||
describe("the OCR adapter on the fake platform", () => {
|
||||
it("submits a document, polls its job and returns the document the platform read", async () => {
|
||||
const { ocr: port } = await ocr()
|
||||
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
||||
filename: "a.pdf",
|
||||
idempotencyKey: "k-1",
|
||||
})
|
||||
const read = await port.wait(jobId)
|
||||
expect(read.engine.name).toBe("fixture-engine")
|
||||
expect(read.pages).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("answers a repeated idempotency key with the same job, so a retried job pays once", async () => {
|
||||
const { ocr: port, platform } = await ocr()
|
||||
const bytes = new TextEncoder().encode("%PDF-1.4 same")
|
||||
const first = await port.submit(bytes, { filename: "a.pdf", idempotencyKey: "k-same" })
|
||||
const second = await port.submit(bytes, { filename: "a.pdf", idempotencyKey: "k-same" })
|
||||
expect(second.jobId).toBe(first.jobId)
|
||||
expect((await stateOf(platform)).ocr).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("reads a job the platform failed as permanent", async () => {
|
||||
const { ocr: port, platform } = await ocr()
|
||||
platform.ocr.failJobs(true)
|
||||
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
||||
filename: "a.pdf",
|
||||
idempotencyKey: "k-f",
|
||||
})
|
||||
await expect(port.wait(jobId)).rejects.toBeInstanceOf(OcrJobFailed)
|
||||
})
|
||||
|
||||
it("draws a page of a finished job through the platform's page route: bytes, a media type, the asked dpi", async () => {
|
||||
const { ocr: port, platform } = await ocr()
|
||||
platform.ocr.setPageImage(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 7]))
|
||||
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
||||
filename: "a.pdf",
|
||||
idempotencyKey: "k-p",
|
||||
})
|
||||
await port.wait(jobId)
|
||||
const image = await port.page(jobId, 2)
|
||||
expect(image?.mediaType).toBe("image/png")
|
||||
expect([...(image?.bytes ?? [])]).toEqual([0x89, 0x50, 0x4e, 0x47, 7])
|
||||
const state = (await (await fetch(`${platform.url}/__e2e/state`)).json()) as {
|
||||
ocrPages: Array<{ job: number; page: number; dpi: number }>
|
||||
}
|
||||
expect(state.ocrPages).toEqual([{ job: jobId, page: 2, dpi: 150 }])
|
||||
})
|
||||
|
||||
it("reads a page the platform has no image of (a job it no longer holds, a page past the end) as no image", async () => {
|
||||
const { ocr: port } = await ocr()
|
||||
expect(await port.page(999, 1)).toBeUndefined()
|
||||
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
||||
filename: "a.pdf",
|
||||
idempotencyKey: "k-q",
|
||||
})
|
||||
await port.wait(jobId)
|
||||
// the fixture has three pages
|
||||
expect(await port.page(jobId, 4)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("reads a failure of the page route that is not the page's as retryable", async () => {
|
||||
const down = new OcrError("OCR request failed: network", { type: "network" })
|
||||
const adapter = sdkDocumentOcr({
|
||||
submit: async () => ({ id: 1 }) as never,
|
||||
wait: async () => ({}) as never,
|
||||
page: async () => Promise.reject(down),
|
||||
})
|
||||
await expect(adapter.page(1, 1)).rejects.toBe(down)
|
||||
})
|
||||
|
||||
it("reads anything else as retryable: the error is thrown on, unchanged", async () => {
|
||||
const transient = new OcrError("OCR request failed: network", { type: "network" })
|
||||
const adapter = sdkDocumentOcr({
|
||||
submit: async () => ({ id: 1 }) as never,
|
||||
wait: async () => Promise.reject(transient),
|
||||
page: async () => ({ bytes: new Uint8Array(), contentType: "image/png" }),
|
||||
})
|
||||
await expect(adapter.wait(1)).rejects.toBe(transient)
|
||||
})
|
||||
|
||||
it("carries a whole upload through the worker over HTTP: the fixture becomes proposals", async () => {
|
||||
const app = await makeDocumentsApp({ document: fixture })
|
||||
const project = await app.seedProject("acme", "owner@acme.test")
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const view = (await project.call.callByName("documents.document.get", {
|
||||
...project.slugs,
|
||||
fileId: file.fileId,
|
||||
})) as {
|
||||
document: { status: string; pending: number }
|
||||
}
|
||||
expect(view.document).toMatchObject({ status: "extracted", pending: 4 })
|
||||
expect((await stateOf(app.platform)).ocr).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("marks a document extracted with nothing to review when the platform reads a page with no lines", async () => {
|
||||
const app = await makeDocumentsApp({
|
||||
document: {
|
||||
...fixture,
|
||||
page_count: 1,
|
||||
pages: [{ index: 0, width: 10, height: 10, class: "empty", text: "", lines: [], blocks: [] }],
|
||||
},
|
||||
})
|
||||
const project = await app.seedProject("acme", "owner@acme.test")
|
||||
const file = await uploadAndProcess(app, project)
|
||||
const view = (await project.call.callByName("documents.document.get", {
|
||||
...project.slugs,
|
||||
fileId: file.fileId,
|
||||
})) as {
|
||||
document: { status: string; pending: number }
|
||||
}
|
||||
expect(view.document).toMatchObject({ status: "extracted", pending: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// `.env.example` is the complete, documented list of the variables this app reads (plan item AT4.5):
|
||||
// every variable the code reads is in it, every variable in it is read by something, and each has a
|
||||
// comment above it saying what it is for.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname
|
||||
const example = readFileSync(join(root, ".env.example"), "utf8")
|
||||
|
||||
const declared = [...example.matchAll(/^([A-Z][A-Z0-9_]+)=/gm)].map((match) => match[1] as string)
|
||||
|
||||
const walk = (dir: string): string[] =>
|
||||
readdirSync(dir).flatMap((name) => {
|
||||
if (["node_modules", "dist", ".tanstack", ".logs", "__tests__"].includes(name)) return []
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) return walk(path)
|
||||
return /\.(ts|tsx|mjs)$/.test(name) ? [path] : []
|
||||
})
|
||||
|
||||
/** The variables the code of this repository reads: `process.env.X`, `env.X` and the loaders' quoted names. */
|
||||
function read(): Set<string> {
|
||||
const found = new Set<string>()
|
||||
const files = [
|
||||
...walk(join(root, "apps")),
|
||||
...walk(join(root, "config", "src")),
|
||||
...walk(join(root, "scripts")),
|
||||
...walk(join(root, "e2e", "support")),
|
||||
join(root, "better-auth", "deploy.mjs"), // kit:convex-better-auth
|
||||
]
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8")
|
||||
for (const match of text.matchAll(/\b(?:process\.env|env)\.([A-Z][A-Z0-9_]+)/g)) found.add(match[1] as string)
|
||||
for (const match of text.matchAll(/\b(?:process\.env|env)\[\s*"([A-Z][A-Z0-9_]+)"\s*\]/g))
|
||||
found.add(match[1] as string)
|
||||
}
|
||||
// The names config/src/env.ts holds in constants.
|
||||
for (const match of readFileSync(join(root, "config/src/env.ts"), "utf8").matchAll(/_VAR = "([A-Z][A-Z0-9_]+)"/g)) {
|
||||
found.add(match[1] as string)
|
||||
}
|
||||
for (const match of readFileSync(join(root, "config/src/documents.ts"), "utf8").matchAll(
|
||||
/_VAR = "([A-Z][A-Z0-9_]+)"/g,
|
||||
)) {
|
||||
found.add(match[1] as string)
|
||||
}
|
||||
// The platform contract and the sign-in variables are read by the kit's loaders from the same environment.
|
||||
const kit = (path: string) => readFileSync(join(root, "node_modules", "@conusai-kit", path), "utf8")
|
||||
for (const file of ["host/dist/postgres/config/env.js", "host/dist/postgres/config/settings.js"]) {
|
||||
for (const match of kit(file).matchAll(/_VAR = "([A-Z][A-Z0-9_]+)"/g)) found.add(match[1] as string)
|
||||
}
|
||||
for (const match of kit("auth/dist/config.js").matchAll(/\b((?:AUTH|OIDC)_[A-Z_]+)\b/g)) found.add(match[1] as string)
|
||||
for (const name of [
|
||||
"CONUSAI_API_URL",
|
||||
"CONUSAI_API_TOKEN",
|
||||
"CONUSAI_PROJECT_ID",
|
||||
"CONUSAI_ENVIRONMENT_ID",
|
||||
"CONUSAI_PLATFORM_RELEASE_DESCRIPTOR",
|
||||
"CONUSAI_PLATFORM_SOURCE_COMMIT",
|
||||
"CONUSAI_ORG_ID",
|
||||
"CONUSAI_ORG_SLUG",
|
||||
"CONUSAI_WORKSPACE_SLUG",
|
||||
"CONUSAI_PROJECT_SLUG",
|
||||
"CONUSAI_PUBLIC_URL",
|
||||
"CONUSAI_RELEASE",
|
||||
"CONUSAI_ENVIRONMENT",
|
||||
"CONUSAI_DSN",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
"DEV_WEB_PORT",
|
||||
"DEV_PLATFORM_PORT",
|
||||
"DEV_POSTGRES_PORT",
|
||||
"CONVEX_SELF_HOSTED_URL",
|
||||
"CONVEX_SELF_HOSTED_ADMIN_KEY",
|
||||
"CONVEX_BACKUP_INTERVAL_HOURS",
|
||||
"CONVEX_BACKUP_RETENTION",
|
||||
"CONVEX_CLI",
|
||||
"CONVEX_APP_DIR",
|
||||
]) {
|
||||
found.add(name)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe(".env.example", () => {
|
||||
it("lists every variable the code reads", () => {
|
||||
const missing = [...read()].filter((name) => !declared.includes(name)).sort()
|
||||
// Set by the runtime and not by a person: NODE_ENV is in the file for what it means here, and the
|
||||
// framework's own variable names are not this app's to document.
|
||||
const framework = new Set(["npm_execpath", "PORT", "HOST", "HOSTNAME", "E2E_BASE_URL", "E2E_PLATFORM_URL"])
|
||||
expect(
|
||||
missing.filter((name) => !framework.has(name)),
|
||||
"add these to .env.example, with a comment",
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("lists nothing that nothing reads", () => {
|
||||
const used = read()
|
||||
const fixed = new Set([
|
||||
"POSTGRES_URL",
|
||||
"POSTGRES_READER_URL",
|
||||
"POSTGRES_MIGRATE_URL",
|
||||
"AI_MODEL",
|
||||
"LOG_LEVEL",
|
||||
"WORKER_HEARTBEAT_FILE",
|
||||
"NODE_ENV",
|
||||
])
|
||||
const unused = declared.filter((name) => !used.has(name) && !fixed.has(name))
|
||||
expect(unused, "remove these from .env.example, or the code that reads them is not found by this test").toEqual([])
|
||||
})
|
||||
|
||||
it("has no variable twice", () => {
|
||||
expect(declared.filter((name, index) => declared.indexOf(name) !== index)).toEqual([])
|
||||
})
|
||||
|
||||
it("explains every variable: a comment sits above it, or above the block of variables it belongs to", () => {
|
||||
const lines = example.split("\n")
|
||||
const undocumented: string[] = []
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const name = /^([A-Z][A-Z0-9_]+)=/.exec(line)?.[1]
|
||||
if (!name) continue
|
||||
let above = index - 1
|
||||
while (above >= 0 && /^[A-Z][A-Z0-9_]+=/.test(lines[above] as string)) above -= 1
|
||||
if (!(lines[above] ?? "").startsWith("#")) undocumented.push(name)
|
||||
}
|
||||
expect(undocumented).toEqual([])
|
||||
})
|
||||
|
||||
it("holds no real secret: the values are the development ones or empty", () => {
|
||||
for (const name of ["CONUSAI_API_TOKEN", "AUTH_SESSION_SECRET", "OIDC_WEB_CLIENT_SECRET", "BETTER_AUTH_SECRET"]) {
|
||||
const value = new RegExp(`^${name}=(.*)$`, "m").exec(example)?.[1] ?? ""
|
||||
expect(value === "" || /dev|replace-me/.test(value), `${name} looks like a real secret`).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Every place outside the tests that stands up an app's fake platform either serves the app's
|
||||
// deployment (`deployment:` -- its business gateway, captured e-mail and notifications, as `pnpm dev`
|
||||
// does) or says, in a marker comment just above the call, why its app needs none. A new stack that
|
||||
// forgets it fails here by name instead of refusing every business procedure (TEMPLATE-LESSON proc-5).
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { join, relative } from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname
|
||||
const MARKER = "// fake-platform: no deployment:"
|
||||
const SKIP = new Set(["node_modules", "dist", "test", "__tests__", ".logs", ".dev", ".git"])
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
if (SKIP.has(name)) return []
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) return sources(path)
|
||||
return /\.(ts|tsx|mjs|js)$/.test(name) && !/\.test\./.test(name) ? [path] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** The text of each `startFakePlatform(…)` call in a file, with the lines just above it. */
|
||||
function calls(text: string): Array<{ line: number; call: string; above: string }> {
|
||||
const found: Array<{ line: number; call: string; above: string }> = []
|
||||
for (let at = text.indexOf("startFakePlatform("); at >= 0; at = text.indexOf("startFakePlatform(", at + 1)) {
|
||||
let depth = 0
|
||||
let end = at + "startFakePlatform".length
|
||||
for (; end < text.length; end++) {
|
||||
if (text[end] === "(") depth++
|
||||
if (text[end] === ")" && --depth === 0) break
|
||||
}
|
||||
const line = text.slice(0, at).split("\n").length
|
||||
found.push({
|
||||
line,
|
||||
call: text.slice(at, end + 1),
|
||||
above: text
|
||||
.split("\n")
|
||||
.slice(Math.max(0, line - 4), line - 1)
|
||||
.join("\n"),
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe("every fake platform the template stands up", () => {
|
||||
const sites = sources(ROOT).flatMap((path) =>
|
||||
calls(readFileSync(path, "utf8")).map((site) => ({ ...site, file: relative(ROOT, path) })),
|
||||
)
|
||||
|
||||
it("finds the stacks it holds to the rule", () => {
|
||||
expect(sites.map((site) => site.file).sort()).toEqual(
|
||||
expect.arrayContaining(["e2e/support/stack.ts", "scripts/dev.mjs"]),
|
||||
)
|
||||
})
|
||||
|
||||
it("serves the app's deployment, or says why its app needs none", () => {
|
||||
const missing = sites
|
||||
.filter((site) => !/\bdeployment\s*:/.test(site.call) && !site.above.includes(MARKER))
|
||||
.map((site) => `${site.file}:${site.line}`)
|
||||
expect(missing, `pass \`deployment\` to startFakePlatform, or state why not with "${MARKER} <reason>"`).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tab icon is the ConusAI mark, the file @conusai-kit/brand ships. Not a copy: the web build emits the kit's bytes
|
||||
// at /favicon.png, and the document head points there.
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import { appBrand } from "@app/config"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const web = new URL("../apps/web/", import.meta.url)
|
||||
|
||||
describe("the favicon", () => {
|
||||
it("is the path the build emits", () => {
|
||||
expect(appBrand.logo.favicon).toBe("/favicon.png")
|
||||
})
|
||||
|
||||
it("is emitted from the kit's file, and the template holds no icon of its own", () => {
|
||||
const config = readFileSync(new URL("vite.config.ts", web), "utf8")
|
||||
expect(config).toContain(`"@conusai-kit/brand/logo/favicon.png"`)
|
||||
expect(existsSync(new URL("public/favicon.svg", web))).toBe(false)
|
||||
})
|
||||
|
||||
it("resolves to a PNG in the kit", () => {
|
||||
const file = createRequire(new URL("package.json", web)).resolve("@conusai-kit/brand/logo/favicon.png")
|
||||
expect(readFileSync(file).subarray(1, 4).toString()).toBe("PNG")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Synthetic admission for injected adapter-port tests only. The fake HTTP service
|
||||
// still exercises response mapping; this observer proves no installed/live state.
|
||||
import type { ServiceReadiness } from "@conusai-kit/core"
|
||||
export const simulatedBusinessAdmission: ServiceReadiness = {
|
||||
profile: "postgres",
|
||||
observe: async () => ({
|
||||
desiredGeneration: 1,
|
||||
observedGeneration: 1,
|
||||
observedAt: Date.now(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
entitled: true,
|
||||
authorized: true,
|
||||
configured: true,
|
||||
compatible: true,
|
||||
dependenciesReady: true,
|
||||
partitionBound: true,
|
||||
ready: true,
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { createTable } from "@conusai-kit/data-kysely"
|
||||
import { createMysqlTable } from "@conusai-kit/data-kysely/mysql"
|
||||
import { createMongoCollection } from "@conusai-kit/data-mongo"
|
||||
import { appUserIdentities, tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
|
||||
|
||||
describe("app_user_identity profile contracts", () => {
|
||||
it.each(["postgres", "mariadb", "mongodb", "convex"] as const)("is registered for %s", (profile) => {
|
||||
const entities = entitiesOf(buildRegistry([tenancyModule]), profile)
|
||||
expect(entities.filter((entity) => entity.table === appUserIdentities.table)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("creates and eagerly backfills the Postgres alias table in a forward migration", () => {
|
||||
const sql = read("migrations/postgres/0006_app-user-identity.sql")
|
||||
for (const statement of createTable(appUserIdentities)) expect(sql).toContain(`${statement};`)
|
||||
expect(sql).toContain('insert into "app_user_identity"')
|
||||
expect(sql).toContain('from "users"')
|
||||
expect(sql).toContain('"user_id",\n "migration_id"\n)\nselect\n "id",')
|
||||
expect(read("migrations/postgres/0001_kernel.sql")).not.toContain('create table "app_user_identity"')
|
||||
})
|
||||
|
||||
it("uses bounded MariaDB unique-key columns and preserves user IDs during backfill", () => {
|
||||
const sql = read("profiles/mariadb/migrations/0006_app-user-identity.sql")
|
||||
expect(sql).toContain("`issuer` varchar(255) not null")
|
||||
expect(sql).toContain("`subject` varchar(255) not null")
|
||||
expect(sql).toContain("unique (`issuer`, `subject`)")
|
||||
expect(sql).toContain("insert into `app_user_identity`")
|
||||
expect(sql).toContain("from `users`")
|
||||
// The generated table contract has the same fields, constraints and indexes; the migration
|
||||
// deliberately narrows the two string columns so InnoDB can index their full values.
|
||||
const generated = createMysqlTable(appUserIdentities).join(";\n")
|
||||
for (const fragment of [
|
||||
"create table `app_user_identity`",
|
||||
"`user_id` char(36) not null",
|
||||
"`app_user_identity_user_id_idx`",
|
||||
]) {
|
||||
expect(generated).toContain(fragment)
|
||||
expect(sql).toContain(fragment)
|
||||
}
|
||||
})
|
||||
|
||||
it("commits the generated MongoDB collection and unique/user indexes", () => {
|
||||
const commands = JSON.parse(read("profiles/mongodb/migrations/0006_app-user-identity.json"))
|
||||
expect(commands).toEqual(JSON.parse(JSON.stringify(createMongoCollection(appUserIdentities))))
|
||||
expect(commands[1]).toMatchObject({ createIndex: { unique: true, keys: { issuer: 1, subject: 1 } } })
|
||||
})
|
||||
|
||||
it("generates the Convex table and routes identity operations through private transactions", () => {
|
||||
const schema = read("convex/schema.ts")
|
||||
expect(schema).toContain('"app_user_identity": defineTable')
|
||||
expect(schema).toContain('.index("by_unique_issuer_subject", ["issuer", "subject"])')
|
||||
expect(schema).toContain('.index("by_userId", ["userId"])')
|
||||
const system = read("convex/system.ts")
|
||||
expect(system).toContain("export const appUserResolve = internalMutation")
|
||||
expect(system).toContain("resolveAppUser(data(ctx)")
|
||||
expect(system).toContain("export const identityMappingsApply = internalMutation")
|
||||
expect(system).toContain("applyIdentityMappings(data(ctx)")
|
||||
expect(system).toContain("export const identityIssuerCoverage = internalQuery")
|
||||
expect(system).not.toContain("export const userInsert")
|
||||
expect(system).not.toContain("export const userByIdentity")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A role has one name. The invitation page says the role a link joins with by the name Settings > Members gives the
|
||||
// same role (kit-tenancy's roleName over the built-in roles: a member is an editor), in English and in Lithuanian, from
|
||||
// the app's own catalogues.
|
||||
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { roleName } from "@conusai-kit/tenancy/ui"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({ pageContext: vi.fn(), call: vi.fn() }))
|
||||
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getRequest: () => new Request("http://app.test/"),
|
||||
setResponseStatus: vi.fn(),
|
||||
setResponseHeader: vi.fn(),
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/page", () => ({ pageContext: mocks.pageContext }))
|
||||
vi.mock("@conusai-kit/auth", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
loadAuthConfig: () => ({ provider: "oidc", devLogin: true }),
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/better-auth", () => ({
|
||||
MIN_PASSWORD_LENGTH: 12,
|
||||
betterAuthFormLabels: () => ({ submit: "Go" }),
|
||||
}))
|
||||
vi.mock("@app/modules/account/ui", () => ({
|
||||
analyticsLabels: () => ({}),
|
||||
deletionLabels: () => ({}),
|
||||
exportLabels: () => ({}),
|
||||
pageLabels: () => ({ title: "Account" }),
|
||||
}))
|
||||
vi.mock("@conusai-kit/tenancy", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
invitationRead: "invitation.read",
|
||||
}))
|
||||
|
||||
const { appCatalogues } = await import("../apps/web/src/ui/messages")
|
||||
const { invitationData } = await import("../apps/web/src/server/account-pages")
|
||||
|
||||
describe("the invitation page", () => {
|
||||
it.each(["en", "lt"] as const)("names the role as the members table does, in %s", async (locale) => {
|
||||
const i18n = createI18n(locale, appCatalogues)
|
||||
mocks.pageContext.mockResolvedValue({ i18n, caller: { call: mocks.call }, path: "/here", signedIn: false })
|
||||
for (const [role, roleKey] of [
|
||||
["viewer", "viewer"],
|
||||
["member", "editor"],
|
||||
["admin", "admin"],
|
||||
["owner", "owner"],
|
||||
] as const) {
|
||||
mocks.call.mockResolvedValue({
|
||||
invitationId: "inv-1",
|
||||
state: "open",
|
||||
email: "guest@example.test",
|
||||
organization: { slug: "acme", name: "Acme" },
|
||||
role,
|
||||
roleKey,
|
||||
expiresAt: "2026-11-10T09:00:00.000Z",
|
||||
})
|
||||
const inTable = roleName(i18n, { key: roleKey, name: roleKey, builtIn: true })
|
||||
expect(inTable, `${locale} ${roleKey} has a translated name`).not.toBe(roleKey)
|
||||
const { lead } = await invitationData({ org: "acme", token: "token" })
|
||||
expect(lead).toBe(i18n.message("invite.lead", { email: "guest@example.test", org: "Acme", role: inTable }))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,517 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Wall three (spec §3.3): the generated suite calls every tenant-scoped procedure of this app's
|
||||
// registry as a member of organization A with organization B's slugs and ids, and fails any
|
||||
// procedure that answers with B's data or writes anything at all.
|
||||
//
|
||||
// It runs on the in-memory adapter, which is the repository predicate alone: that is wall one.
|
||||
// Wall two, what Postgres refuses on its own, is proven against a real Postgres by
|
||||
// @conusai-kit/data-kysely's own gate, and wall three is proven here for every procedure this
|
||||
// app registers -- including the ones a module adds tomorrow, because the list comes from the
|
||||
// registry and a procedure without a fixture fails the suite by name.
|
||||
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
type Actor,
|
||||
buildRegistry,
|
||||
type CallerOptions,
|
||||
Container,
|
||||
createCaller,
|
||||
type TransportCaller,
|
||||
} from "@conusai-kit/core"
|
||||
import {
|
||||
createMemoryDataSource,
|
||||
DATA_SOURCE,
|
||||
entitiesOf,
|
||||
JOB_QUEUE,
|
||||
OBJECT_STORE,
|
||||
SYSTEM_DATA,
|
||||
} from "@conusai-kit/data"
|
||||
import { GUIDES_PLATFORM, GUIDES_REGISTRY } from "@conusai-kit/guides" // kit:guides
|
||||
import { guidesIsolationExempt, guidesIsolationFixtures, recordingGuidesPlatform } from "@conusai-kit/guides/testing" // kit:guides
|
||||
import { PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
|
||||
import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support
|
||||
import * as supportTesting from "@conusai-kit/support/testing" // kit:support
|
||||
import { createTenancyHooks, TENANCY, users } from "@conusai-kit/tenancy"
|
||||
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
|
||||
import {
|
||||
describeIsolation,
|
||||
fakeJobQueue,
|
||||
fakeObjectStore,
|
||||
type IsolationFixture,
|
||||
type IsolationTenant,
|
||||
runIsolation,
|
||||
snapshotOf,
|
||||
uploadViaProcedures,
|
||||
} from "@conusai-kit/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { accountIsolationExempt } from "../modules/account/__tests__/exempt.js"
|
||||
import { documents } from "../modules/documents/index.js" // kit:documents
|
||||
import { appModules } from "../modules/index.js"
|
||||
import * as knowledge from "../modules/knowledge/index.js" // kit:knowledge
|
||||
import { isolationFilesOf } from "./support/module-isolations.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
|
||||
/** What a module's isolation file exports: the shape this suite reads. */
|
||||
interface ModuleIsolation {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixtures and the seed rows a module brings in `modules/<id>/__tests__/isolation.ts`, found by
|
||||
* their place: a module added with `pnpm new:module` is covered without a line here, and a
|
||||
* procedure it registers with no fixture fails the suite by name. Only registered modules count: a
|
||||
* module folder kept but not registered serves no procedure (`isolationFilesOf`). (The older
|
||||
* modules' fixtures are listed below; they move into their own folders as each is next touched.)
|
||||
*/
|
||||
const modulesDir = join(import.meta.dirname, "..", "modules")
|
||||
const moduleIsolations: ModuleIsolation[] = await Promise.all(
|
||||
isolationFilesOf(
|
||||
modulesDir,
|
||||
registry.modules.map((module) => module.id),
|
||||
).map(async (file) => {
|
||||
const loaded = (await import(/* @vite-ignore */ file)) as { isolation: ModuleIsolation }
|
||||
return loaded.isolation
|
||||
}),
|
||||
)
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
|
||||
/** The guides module's platform, recording: a refused cross-tenant call must never reach it. */ // kit:guides
|
||||
const guidesPlatform = recordingGuidesPlatform() // kit:guides
|
||||
/** The support module's platform, recording: a refused cross-tenant mint must never reach it. */ // kit:support
|
||||
const supportPlatform = supportTesting.recordingSupportPlatform() // kit:support
|
||||
/** The app wired the way apps/web wires it, with the in-memory adapter in place of Postgres. */
|
||||
function makeApp() {
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
// The files module's store: a seeded upload needs one, and a refused cross-tenant call never reaches it.
|
||||
root.value(OBJECT_STORE, fakeObjectStore())
|
||||
// A module whose writes enqueue work (a job started by a procedure) needs a queue to be driven at all.
|
||||
root.value(JOB_QUEUE, fakeJobQueue())
|
||||
root.value(PROPOSALS_REGISTRY, registry)
|
||||
root.value(GUIDES_PLATFORM, guidesPlatform) // kit:guides
|
||||
root.value(GUIDES_REGISTRY, registry) // kit:guides
|
||||
root.value(SUPPORT_PLATFORM, supportPlatform) // kit:support
|
||||
// kit:knowledge:begin
|
||||
// The knowledge module's ports, faked: the search answers nothing (the suite asserts a
|
||||
// cross-tenant call is refused or answered empty), and the enablement checks pass.
|
||||
root.value(knowledge.KNOWLEDGE_SEARCH, {
|
||||
readiness: async () => ({ state: "ready" as const, reasons: [] }),
|
||||
index: async () => undefined,
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
})
|
||||
root.value(knowledge.KNOWLEDGE_READINESS, { readiness: async () => ({ state: "ready" as const, reasons: [] }) })
|
||||
root.value(knowledge.KNOWLEDGE_ENABLEMENT, {
|
||||
checkEligibility: async () => ({ ok: true as const }),
|
||||
indexingQuota: async () => Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
// kit:knowledge:end
|
||||
const caller = (
|
||||
actor: Actor,
|
||||
admission: Pick<CallerOptions, "prepareServices" | "serviceReadiness"> = {},
|
||||
): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, ...admission }, { actor })
|
||||
}
|
||||
return { data, caller }
|
||||
}
|
||||
|
||||
const app = makeApp()
|
||||
|
||||
async function signUp(email: string): Promise<Actor> {
|
||||
const user = await app.data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
async function tenant(slug: string): Promise<IsolationTenant> {
|
||||
const owner = await signUp(`owner@${slug}.test`)
|
||||
const call = app.caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as {
|
||||
orgId: string
|
||||
slug: string
|
||||
workspaceSlug: string
|
||||
}
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string; slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "tenancy",
|
||||
})) as { projectId: string; slug: string }
|
||||
const invite = (await call.callByName("tenancy.member.invite", {
|
||||
org: slug,
|
||||
email: `invited-${slug}@example.test`,
|
||||
role: "member",
|
||||
})) as { invitationId: string; token: string }
|
||||
const key = (await call.callByName("auth.apiKey.create", { org: slug, name: "ci" })) as { key: { id: string } }
|
||||
// A custom role of this tenant's own: the role procedures' fixtures name its key from the other side.
|
||||
const role = (await call.callByName("tenancy.role.create", {
|
||||
org: slug,
|
||||
name: `Auditor ${slug}`,
|
||||
level: "org",
|
||||
permissions: ["tenancy.org.read", "tenancy.member.read"],
|
||||
})) as { key: string }
|
||||
// One upload and one proposal in the project, so a fixture names rows that exist in the other
|
||||
// tenant: the call has to be refused for being another tenant's, not for naming nothing.
|
||||
const inProject = { org: slug, workspace: workspace.slug, project: project.slug }
|
||||
const file = await uploadViaProcedures(call, inProject, {
|
||||
filename: "seed.pdf",
|
||||
contentType: "application/pdf",
|
||||
bytes: new TextEncoder().encode("%PDF-1.4"),
|
||||
})
|
||||
// kit:documents:begin
|
||||
const proposal = (await call.callByName(
|
||||
"proposals.proposal.create",
|
||||
{
|
||||
...inProject,
|
||||
targetKey: "documents.line",
|
||||
change: {
|
||||
lineKey: "a".repeat(64),
|
||||
label: "Reference",
|
||||
value: "QR-1",
|
||||
page: 1,
|
||||
position: 0,
|
||||
quote: "Reference: QR-1",
|
||||
confidence: 0.9,
|
||||
verification: "verified",
|
||||
edited: false,
|
||||
},
|
||||
evidence: [{ fileId: file.fileId, page: 1, quote: "Reference: QR-1" }],
|
||||
model: "seed",
|
||||
promptVersion: "seed.v1",
|
||||
},
|
||||
{ idempotencyKey: `seed.${slug}` },
|
||||
)) as { proposalId: string }
|
||||
// A document row, as the OCR job makes one: the live collection's read-back names it by its id.
|
||||
const documentRow = await app.data
|
||||
.forScope({
|
||||
level: "project",
|
||||
actor: owner,
|
||||
orgId: org.orgId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectId: project.projectId,
|
||||
})
|
||||
.of(documents)
|
||||
.insert({
|
||||
fileId: file.fileId,
|
||||
filename: "seed.pdf",
|
||||
status: "processing",
|
||||
ocrJobId: null,
|
||||
pageCount: null,
|
||||
proposedCount: null,
|
||||
failureCode: null,
|
||||
skippedPages: null,
|
||||
finishedAt: null,
|
||||
})
|
||||
// kit:documents:end
|
||||
// kit:knowledge:begin
|
||||
// A knowledge_document row, as an index request makes one (indexing needs the module switched on, which
|
||||
// the seeding caller cannot do): the live collection's read-back names it by its id.
|
||||
const knowledgeDocument = await app.data
|
||||
.forScope({
|
||||
level: "project",
|
||||
actor: owner,
|
||||
orgId: org.orgId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectId: project.projectId,
|
||||
})
|
||||
.of(knowledge.documents)
|
||||
.insert({
|
||||
collectionName: "contracts",
|
||||
fileId: file.fileId,
|
||||
filename: "seed.pdf",
|
||||
status: "queued",
|
||||
error: null,
|
||||
indexedAt: null,
|
||||
requestedBy: owner.userId,
|
||||
})
|
||||
// kit:knowledge:end
|
||||
// What each module that brings fixtures seeds in this tenant: its ids are what the other tenant's fixtures name.
|
||||
const seeded: Record<string, string> = {}
|
||||
for (const isolation of moduleIsolations) Object.assign(seeded, await isolation.seed(call, inProject))
|
||||
return {
|
||||
orgSlug: org.slug,
|
||||
workspaceSlug: workspace.slug,
|
||||
projectSlug: project.slug,
|
||||
orgId: org.orgId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectId: project.projectId,
|
||||
userId: owner.userId,
|
||||
email: `owner@${slug}.test`,
|
||||
extra: {
|
||||
invitationId: invite.invitationId,
|
||||
token: invite.token,
|
||||
roleKey: role.key,
|
||||
apiKeyId: key.key.id,
|
||||
fileId: file.fileId,
|
||||
proposalId: proposal.proposalId, // kit:documents
|
||||
documentId: documentRow.id, // kit:documents
|
||||
knowledgeDocumentId: knowledgeDocument.id, // kit:knowledge
|
||||
...seeded,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let seeded: Promise<{ a: IsolationTenant; b: IsolationTenant }> | undefined
|
||||
const tenants = () => {
|
||||
seeded ??= (async () => ({ a: await tenant("alpha"), b: await tenant("bravo") }))()
|
||||
return seeded
|
||||
}
|
||||
|
||||
/** The three slugs a project-scoped procedure's input carries, from the tenant it must not reach. */
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
|
||||
/** The other tenant's seeded rows; the ghost tenant has none, so its ids are well-formed and absent. */
|
||||
const NOWHERE = "00000000-0000-0000-0000-000000000000"
|
||||
const fileOf = (t: IsolationTenant) => t.extra.fileId ?? NOWHERE
|
||||
const proposalOf = (t: IsolationTenant) => t.extra.proposalId ?? NOWHERE
|
||||
const documentOf = (t: IsolationTenant) => t.extra.documentId ?? NOWHERE // kit:documents
|
||||
|
||||
/** One fixture per tenant-scoped procedure, built from the *other* tenant's ids. */
|
||||
const fixtures: Record<string, IsolationFixture> = {
|
||||
// The tenancy module's, which this suite cannot be run without (one list, in @conusai-kit/tenancy/testing).
|
||||
...tenancyIsolationFixtures(),
|
||||
...Object.assign({}, ...moduleIsolations.map((isolation) => isolation.fixtures)),
|
||||
...guidesIsolationFixtures, // kit:guides
|
||||
...supportTesting.supportIsolationFixtures, // kit:support
|
||||
"auth.apiKey.create": { input: (t) => ({ org: t.orgSlug, name: "intruder" }) },
|
||||
"auth.apiKey.list": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"auth.apiKey.revoke": {
|
||||
input: (t) => ({ org: t.orgSlug, id: t.extra.apiKeyId ?? "00000000-0000-0000-0000-000000000000" }),
|
||||
},
|
||||
"files.file.beginUpload": {
|
||||
input: (t) => ({ ...inProjectOf(t), filename: "intruder.pdf", contentType: "application/pdf", size: 8 }),
|
||||
},
|
||||
"files.file.putPart": {
|
||||
input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t), part: 1, body: new TextEncoder().encode("%PDF-1.4") }),
|
||||
},
|
||||
"files.file.completeUpload": {
|
||||
input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t), parts: [{ number: 1, etag: "etag-intruder" }] }),
|
||||
},
|
||||
"files.file.get": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
|
||||
"files.file.download": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
|
||||
"files.file.list": { input: (t) => inProjectOf(t) },
|
||||
"files.file.delete": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
|
||||
"proposals.proposal.create": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
targetKey: "documents.line",
|
||||
change: {
|
||||
lineKey: "b".repeat(64),
|
||||
label: "Intruder",
|
||||
value: "x",
|
||||
page: 1,
|
||||
position: 0,
|
||||
quote: "Intruder: x",
|
||||
confidence: 0.5,
|
||||
edited: false,
|
||||
},
|
||||
// Nothing of the other tenant's: `create` reads no id it is given, so the only cross-tenant
|
||||
// case it has is the slugs, which the resolver refuses. An id of B's here would be echoed
|
||||
// back in the answer, which is the caller's own input and not B's data.
|
||||
evidence: [{ fileId: NOWHERE, page: 1 }],
|
||||
model: "intruder",
|
||||
promptVersion: "intruder.v1",
|
||||
}),
|
||||
},
|
||||
"proposals.proposal.edit": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
proposalId: proposalOf(t),
|
||||
expectedVersion: 1,
|
||||
change: {
|
||||
lineKey: "c".repeat(64),
|
||||
label: "Intruder",
|
||||
value: "x",
|
||||
page: 1,
|
||||
position: 0,
|
||||
quote: "Intruder: x",
|
||||
confidence: 0.5,
|
||||
edited: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"proposals.proposal.get": { input: (t) => ({ ...inProjectOf(t), proposalId: proposalOf(t) }) },
|
||||
"proposals.proposal.list": { input: (t) => inProjectOf(t) },
|
||||
"proposals.proposal.approve": {
|
||||
input: (t) => ({ ...inProjectOf(t), proposalId: proposalOf(t), expectedVersion: 1 }),
|
||||
},
|
||||
"proposals.proposal.approveMany": {
|
||||
input: (t) => {
|
||||
const proposalId = proposalOf(t)
|
||||
return { ...inProjectOf(t), proposalIds: [proposalId], expectations: { [proposalId]: { expectedVersion: 1 } } }
|
||||
},
|
||||
},
|
||||
"proposals.proposal.reject": {
|
||||
input: (t) => ({ ...inProjectOf(t), proposalId: proposalOf(t), reason: "intruder" }),
|
||||
},
|
||||
// kit:documents:begin
|
||||
"documents.document.list": { input: (t) => inProjectOf(t) },
|
||||
"documents.document.get": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
|
||||
// The live collection's read-back: another tenant's document id answers nothing.
|
||||
"documents.document.getMany": { input: (t) => ({ ...inProjectOf(t), ids: [documentOf(t)] }) },
|
||||
"documents.line.edit": {
|
||||
input: (t) => ({ ...inProjectOf(t), lineId: proposalOf(t), expectedVersion: 1, label: "Intruder", value: "x" }),
|
||||
},
|
||||
"documents.export.create": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
|
||||
// kit:documents:end
|
||||
"branding.brand.get": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"branding.brand.set": { input: (t) => ({ org: t.orgSlug, primary: "#000000", displayName: "Intruder" }) },
|
||||
"branding.logo.set": {
|
||||
input: (t) => ({ org: t.orgSlug, variant: "light", contentType: "image/png", body: "iVBORw0KGgoAAAAA" }),
|
||||
},
|
||||
"branding.logo.clear": { input: (t) => ({ org: t.orgSlug, variant: "light" }) },
|
||||
"branding.logo.get": { input: (t) => ({ org: t.orgSlug, variant: "light" }) },
|
||||
}
|
||||
|
||||
const alsoCheck = [...TENANCY_ALSO_CHECK]
|
||||
|
||||
const exempt = {
|
||||
...tenancyIsolationExempt,
|
||||
"auth.session.current": "it answers only about the signed-in user's own row",
|
||||
...accountIsolationExempt,
|
||||
...guidesIsolationExempt, // kit:guides
|
||||
...supportTesting.supportIsolationExempt, // kit:support
|
||||
}
|
||||
|
||||
const options = async () => {
|
||||
const { a, b } = await tenants()
|
||||
return {
|
||||
registry,
|
||||
seed: async () => ({ a, b }),
|
||||
caller: (actor: Actor) => app.caller(actor),
|
||||
snapshot: () => snapshotOf(app.data, entities, { outbox: app.data.outbox }),
|
||||
fixtures,
|
||||
alsoCheck,
|
||||
exempt,
|
||||
}
|
||||
}
|
||||
|
||||
describeIsolation(
|
||||
{
|
||||
registry,
|
||||
seed: async () => tenants(),
|
||||
caller: (actor) => ({
|
||||
call: async () => undefined as never,
|
||||
callByName: async (name, input) => app.caller(actor).callByName(name, input),
|
||||
}),
|
||||
snapshot: async () => snapshotOf(app.data, entities, { outbox: app.data.outbox }),
|
||||
fixtures,
|
||||
alsoCheck,
|
||||
exempt,
|
||||
},
|
||||
{ describe, it },
|
||||
)
|
||||
|
||||
describe("the generated isolation suite", () => {
|
||||
it("checks current membership before preparing or observing service admission", async () => {
|
||||
const { a, b } = await tenants()
|
||||
const prepareServices = vi.fn()
|
||||
const observe = vi.fn(async () => {
|
||||
throw new Error("synthetic service unavailable")
|
||||
})
|
||||
const admission = { prepareServices, serviceReadiness: { profile: "postgres" as const, observe } }
|
||||
const outsider = app.caller({ userId: a.userId }, admission)
|
||||
await expect(outsider.callByName("inventory.item.list", inProjectOf(b))).rejects.toMatchObject({ status: 404 })
|
||||
expect(prepareServices).not.toHaveBeenCalled()
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
|
||||
const member = await signUp("removed-member@isolation.test")
|
||||
const owner = app.caller({ userId: a.userId })
|
||||
const invite = (await owner.callByName("tenancy.member.invite", {
|
||||
org: a.orgSlug,
|
||||
email: "removed-member@isolation.test",
|
||||
role: "viewer",
|
||||
})) as { token: string }
|
||||
await app.caller(member).callByName("tenancy.invitation.accept", { org: a.orgSlug, token: invite.token })
|
||||
await expect(app.caller(member, admission).callByName("inventory.item.list", inProjectOf(a))).rejects.toThrow()
|
||||
expect(prepareServices).toHaveBeenCalledOnce()
|
||||
expect(observe).toHaveBeenCalledOnce()
|
||||
await owner.callByName("tenancy.member.remove", { org: a.orgSlug, userId: member.userId })
|
||||
prepareServices.mockClear()
|
||||
observe.mockClear()
|
||||
// A new request must resolve fresh membership, before consulting the unavailable service.
|
||||
await expect(app.caller(member, admission).callByName("inventory.item.list", inProjectOf(a))).rejects.toMatchObject(
|
||||
{ status: 404 },
|
||||
)
|
||||
expect(prepareServices).not.toHaveBeenCalled()
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("covers every procedure this app registers", async () => {
|
||||
const result = await runIsolation(await options())
|
||||
expect(result.failures).toEqual([])
|
||||
// One case per live collection too (TS6.5): its read-back, by the collection's name.
|
||||
expect(result.passed.length).toBe(
|
||||
registry.isolationFixtures.length + alsoCheck.length + Object.keys(registry.collections).length,
|
||||
)
|
||||
expect(result.passed).toContain("live notes.note") // kit:notes
|
||||
})
|
||||
|
||||
// kit:notes:begin
|
||||
it("covers every procedure of the notes and files modules, by name", async () => {
|
||||
const result = await runIsolation(await options())
|
||||
const covered = new Set(result.passed)
|
||||
const names = [...registry.procedures.keys()].filter((name) => /^(notes|files)\./.test(name))
|
||||
expect(names.length).toBe(15)
|
||||
for (const name of names) expect(covered.has(name), name).toBe(true)
|
||||
})
|
||||
|
||||
it("fails, naming it, when a module's own fixture is missing", async () => {
|
||||
const base = await options()
|
||||
const { "notes.attachment.remove": _removed, ...rest } = fixtures
|
||||
const result = await runIsolation({ ...base, fixtures: rest })
|
||||
expect(result.failures.map((failure) => failure.procedure)).toEqual(["notes.attachment.remove"])
|
||||
})
|
||||
// kit:notes:end
|
||||
|
||||
// kit:guides:begin
|
||||
it("reaches the guides platform with no cross-tenant call", async () => {
|
||||
await runIsolation(await options())
|
||||
expect(guidesPlatform.calls).toEqual([])
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
it("reaches the support platform with no cross-tenant call", async () => {
|
||||
await runIsolation(await options())
|
||||
expect(supportPlatform.calls).toEqual([])
|
||||
})
|
||||
|
||||
// kit:support:end
|
||||
it("fails, naming it, when a procedure has no fixture", async () => {
|
||||
const base = await options()
|
||||
const { "tenancy.member.list": _removed, ...rest } = fixtures
|
||||
const result = await runIsolation({ ...base, fixtures: rest })
|
||||
expect(result.failures.map((failure) => failure.procedure)).toEqual(["tenancy.member.list"])
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
|
||||
...(await import("./support/native-readiness.js")).selectedModules(
|
||||
(await importOriginal<typeof import("../modules/inclusion.js")>()).OPTIONAL_MODULE_IDS,
|
||||
),
|
||||
}))
|
||||
@@ -0,0 +1,323 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The committed kernel schema, and the rule that keeps it committable.
|
||||
//
|
||||
// `migrations/postgres/0001_kernel.sql` is `schemaStatements()` over the kernel's own entities,
|
||||
// written once. It is a snapshot of the *kernel's* tables alone, not of the whole registry, so
|
||||
// adding a module does not change this file -- a module ships its own numbered migration, which
|
||||
// is what a forward-only runner needs (it refuses a migration whose text changed after it ran).
|
||||
// A kit upgrade that changes a kernel table does change it, and that is exactly the moment a
|
||||
// customer has to see a new migration in review.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { authModule } from "@conusai-kit/auth"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { type AnyEntityDef, defineEntity, entitiesOf, managedColumnsFor } from "@conusai-kit/data"
|
||||
import {
|
||||
columnName,
|
||||
createTable,
|
||||
enableRowLevelSecurity,
|
||||
isNullable,
|
||||
postgresType,
|
||||
quoted,
|
||||
schemaStatements,
|
||||
} from "@conusai-kit/data-kysely"
|
||||
import { createMysqlTable, mysqlQuoted, mysqlType } from "@conusai-kit/data-kysely/mysql"
|
||||
import { createMongoCollection, type MongoCommand, mongoValidator } from "@conusai-kit/data-mongo"
|
||||
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
||||
import {
|
||||
appUserIdentities,
|
||||
invitations,
|
||||
orgMemberships,
|
||||
projectMemberships,
|
||||
projects,
|
||||
tenancyModule,
|
||||
tenancyRoles,
|
||||
workspaceMemberships,
|
||||
} from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { kernelSchema } from "../apps/worker/src/migrations.js"
|
||||
import { SPDX_LINE } from "../scripts/licence.mjs"
|
||||
|
||||
/**
|
||||
* The columns the R-wave roles migration (0008) added to tables 0001 created: a role key on the
|
||||
* memberships and invitations, a project's visibility. 0001 is the snapshot from before them.
|
||||
*/
|
||||
const ADDED_BY_0008: ReadonlyArray<readonly [AnyEntityDef, string]> = [
|
||||
[orgMemberships, "roleKey"],
|
||||
[workspaceMemberships, "roleKey"],
|
||||
[invitations, "roleKey"],
|
||||
[projects, "visibility"],
|
||||
]
|
||||
|
||||
/** The column 0009 added to a table 0001 created: a project's description. */
|
||||
const ADDED_BY_0009: ReadonlyArray<readonly [AnyEntityDef, string]> = [[projects, "description"]]
|
||||
|
||||
/** The column 0010 added to a table 0001 created: when an invitation was revoked or re-issued. */
|
||||
const ADDED_BY_0010: ReadonlyArray<readonly [AnyEntityDef, string]> = [[invitations, "revokedAt"]]
|
||||
|
||||
const fieldsOf = (added: ReadonlyArray<readonly [AnyEntityDef, string]>, entity: AnyEntityDef) =>
|
||||
added.filter(([changed]) => changed.table === entity.table).map(([, field]) => field)
|
||||
|
||||
/** An entity as 0001 created it: without the fields a later migration added, nor their indexes. */
|
||||
const asOf0001 = (entity: AnyEntityDef): AnyEntityDef =>
|
||||
without(entity, [
|
||||
...fieldsOf(ADDED_BY_0008, entity),
|
||||
...fieldsOf(ADDED_BY_0009, entity),
|
||||
...fieldsOf(ADDED_BY_0010, entity),
|
||||
])
|
||||
|
||||
/** An entity as 0008 left it: a committed validator is the whole of its table at that point. */
|
||||
const asOf0008 = (entity: AnyEntityDef): AnyEntityDef =>
|
||||
without(entity, [...fieldsOf(ADDED_BY_0009, entity), ...fieldsOf(ADDED_BY_0010, entity)])
|
||||
|
||||
/** An entity as 0009 left it. */
|
||||
const asOf0009 = (entity: AnyEntityDef): AnyEntityDef => without(entity, fieldsOf(ADDED_BY_0010, entity))
|
||||
|
||||
function without(entity: AnyEntityDef, later: readonly string[]): AnyEntityDef {
|
||||
if (later.length === 0) return entity
|
||||
const schema = entity.schema
|
||||
if (!(schema instanceof z.ZodObject)) throw new Error(`${entity.table}: an entity's schema is an object`)
|
||||
const managed = managedColumnsFor(entity.tenancy)
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(schema.shape).filter(([field]) => !managed.includes(field) && !later.includes(field)),
|
||||
)
|
||||
const before = (groups: readonly (readonly string[])[]) =>
|
||||
groups.filter((group) => !group.some((field) => later.includes(field))).map((group) => [...group])
|
||||
return defineEntity({
|
||||
name: entity.name,
|
||||
table: entity.table,
|
||||
tenancy: entity.tenancy,
|
||||
fields,
|
||||
unique: before(entity.unique),
|
||||
indexes: before(entity.indexes),
|
||||
})
|
||||
}
|
||||
|
||||
// Tenancy and sign-in alone: later kit tables have their own forward migrations, because 0001 is
|
||||
// never edited once it has run. app_user_identity belongs to 0006 and the custom roles and project
|
||||
// memberships to 0008, not this historical snapshot.
|
||||
const LATER_TABLES = new Set([appUserIdentities.table, tenancyRoles.table, projectMemberships.table])
|
||||
const kernelEntities = [...entitiesOf(buildRegistry([tenancyModule, authModule]), "postgres"), ...KIT_JOBS_ENTITIES]
|
||||
.filter((entity) => !LATER_TABLES.has(entity.table))
|
||||
.map(asOf0001)
|
||||
|
||||
describe("the kernel migration", () => {
|
||||
it("matches the committed 0001_kernel.sql", async () => {
|
||||
// The file leads with the template's SPDX line (AT5.1); the runner reads it as the comment it is.
|
||||
await expect(`-- ${SPDX_LINE}\n${kernelSchema(kernelEntities)}`).toMatchFileSnapshot(
|
||||
"../migrations/postgres/0001_kernel.sql",
|
||||
)
|
||||
})
|
||||
|
||||
it("puts forced row-level security on every tenant table", () => {
|
||||
const sql = schemaStatements(kernelEntities).join(";\n")
|
||||
for (const entity of kernelEntities) {
|
||||
if (entity.tenancy === "global") continue
|
||||
expect(sql, `${entity.table} is not forced`).toContain(`alter table "${entity.table}" force row level security`)
|
||||
expect(sql).toContain(`create policy "${entity.table}_tenant" on "${entity.table}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it("compares a tenant row against the transaction's organization and nothing else", () => {
|
||||
const sql = schemaStatements(kernelEntities).join(";\n")
|
||||
// The policy reads NULLIF(current_setting(...), '')::uuid: a pooled connection whose previous
|
||||
// transaction set app.org_id sees no rows rather than an error (kit-data-kysely's decision 2).
|
||||
expect(sql).toContain("nullif(current_setting('app.org_id', true), '')::uuid")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the custom roles migration (R-wave, ADR-062)", () => {
|
||||
const shapeOf = (entity: AnyEntityDef) => {
|
||||
if (!(entity.schema instanceof z.ZodObject)) throw new Error(`${entity.table}: an entity's schema is an object`)
|
||||
return entity.schema.shape
|
||||
}
|
||||
|
||||
it("is 0008_tenancy-roles.sql: the two new tables, the added columns with their indexes, and forced RLS", async () => {
|
||||
const statements = [
|
||||
...createTable(tenancyRoles),
|
||||
...createTable(projectMemberships),
|
||||
...ADDED_BY_0008.map(([entity, field]) => {
|
||||
const type = shapeOf(entity)[field]
|
||||
// Added to tables that already hold rows: a forward migration cannot add a NOT NULL column.
|
||||
expect(isNullable(type), `${entity.table}.${field}`).toBe(true)
|
||||
return `alter table ${quoted(entity.table)} add column ${quoted(columnName(field))} ${postgresType(type)}`
|
||||
}),
|
||||
...ADDED_BY_0008.flatMap(([entity, field]) =>
|
||||
createTable(entity).filter(
|
||||
(statement) => statement.startsWith("create index") && statement.includes(`, ${quoted(columnName(field))})`),
|
||||
),
|
||||
),
|
||||
...enableRowLevelSecurity(tenancyRoles),
|
||||
...enableRowLevelSecurity(projectMemberships),
|
||||
]
|
||||
await expect(
|
||||
`-- ${SPDX_LINE}\n${statements.map((statement) => `${statement};`).join("\n\n")}\n`,
|
||||
).toMatchFileSnapshot("../migrations/postgres/0008_tenancy-roles.sql")
|
||||
})
|
||||
|
||||
it("forces row-level security on the organization column of both new tables", () => {
|
||||
const sql = readFileSync(new URL("../migrations/postgres/0008_tenancy-roles.sql", import.meta.url), "utf8")
|
||||
for (const table of [tenancyRoles.table, projectMemberships.table]) {
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
expect(sql).toContain(`create policy "${table}_tenant" on "${table}"`)
|
||||
}
|
||||
expect(sql).not.toContain("drop ")
|
||||
})
|
||||
})
|
||||
|
||||
// The same migration on the dialect profiles (ADR-062: every profile has the new tables under its own
|
||||
// isolation mechanism). Neither engine has row-level security: the wall is the repository's tenant
|
||||
// predicate on `org_id`, which the shared contract suites prove, and each unique group is scoped by the
|
||||
// organization column as every kernel table's is.
|
||||
describe("the custom roles migration on MariaDB and MongoDB", () => {
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
|
||||
const addedColumns = (entity: AnyEntityDef) =>
|
||||
ADDED_BY_0008.filter(([added]) => added === entity).map(([, field]) => columnName(field))
|
||||
|
||||
it("is profiles/mariadb/migrations/0008_tenancy-roles.sql: the two tables, the added columns and their indexes", async () => {
|
||||
const statements = [
|
||||
...createMysqlTable(tenancyRoles),
|
||||
...createMysqlTable(projectMemberships),
|
||||
...ADDED_BY_0008.map(([entity, field]) => {
|
||||
const type = shapeOfEntity(entity)[field]
|
||||
expect(isNullable(type), `${entity.table}.${field}`).toBe(true)
|
||||
return `alter table ${mysqlQuoted(entity.table)} add column ${mysqlQuoted(columnName(field))} ${mysqlType(type)}`
|
||||
}),
|
||||
...ADDED_BY_0008.flatMap(([entity, field]) =>
|
||||
createMysqlTable(entity).filter(
|
||||
(statement) =>
|
||||
statement.startsWith("create index") &&
|
||||
new RegExp(`, ${mysqlQuoted(columnName(field))}(\\(191\\))?\\)$`).test(statement),
|
||||
),
|
||||
),
|
||||
]
|
||||
await expect(
|
||||
`-- ${SPDX_LINE}\n${statements.map((statement) => `${statement};`).join("\n\n")}\n`,
|
||||
).toMatchFileSnapshot("../profiles/mariadb/migrations/0008_tenancy-roles.sql")
|
||||
})
|
||||
|
||||
it("scopes both new tables' unique groups by the organization, and drops nothing", () => {
|
||||
const sql = read("profiles/mariadb/migrations/0008_tenancy-roles.sql")
|
||||
expect(sql).toContain("unique (`org_id`, `key`)")
|
||||
expect(sql).toContain("unique (`org_id`, `project_id`, `user_id`)")
|
||||
expect(sql).not.toContain("drop ")
|
||||
})
|
||||
|
||||
it("is profiles/mongodb/migrations/0008_tenancy-roles.json: two collections, the four validators with the new fields, their indexes", () => {
|
||||
const changed = [...new Set(ADDED_BY_0008.map(([entity]) => entity))]
|
||||
const expected: MongoCommand[] = [
|
||||
...createMongoCollection(tenancyRoles),
|
||||
...createMongoCollection(projectMemberships),
|
||||
// A collection's validator refuses a field it does not list (additionalProperties: false), so the
|
||||
// added fields arrive as each collection's whole current validator.
|
||||
...changed.map((entity) => ({ collMod: entity.table, validator: mongoValidator(asOf0008(entity)) })),
|
||||
...changed.flatMap((entity) =>
|
||||
createMongoCollection(entity).filter(
|
||||
(command) =>
|
||||
"createIndex" in command &&
|
||||
addedColumns(entity).some((column) => Object.keys(command.createIndex.keys).includes(column)),
|
||||
),
|
||||
),
|
||||
]
|
||||
// Compared as data: the committed file is formatted by Biome, the generator's output is not. It ran before
|
||||
// a nullable enum listed null, which 0009 adds, and a file that ran is never edited.
|
||||
expect(JSON.parse(read("profiles/mongodb/migrations/0008_tenancy-roles.json"))).toEqual(
|
||||
JSON.parse(
|
||||
JSON.stringify(expected, (_, item) => (Array.isArray(item) ? item.filter((entry) => entry !== null) : item)),
|
||||
),
|
||||
)
|
||||
for (const entity of changed) {
|
||||
const validator = JSON.stringify(mongoValidator(asOf0008(entity)))
|
||||
for (const column of addedColumns(entity)) expect(validator, `${entity.table}.${column}`).toContain(`"${column}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it("is profiles/mongodb/migrations/0009_nullable-enums.json: an invitation with no workspace may have no workspace role", () => {
|
||||
expect(JSON.parse(read("profiles/mongodb/migrations/0009_nullable-enums.json"))).toEqual([
|
||||
{ collMod: invitations.table, validator: mongoValidator(asOf0009(invitations)) },
|
||||
])
|
||||
const { properties } = mongoValidator(asOf0009(invitations)).$jsonSchema as { properties: Record<string, unknown> }
|
||||
expect(properties.workspace_role).toEqual({
|
||||
bsonType: ["string", "null"],
|
||||
enum: ["viewer", "editor", "admin", null],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// One added column on every profile. Convex takes it from the generated schema (`convex/schema.ts`,
|
||||
// which the Convex generator writes from the entities), and the Postgres reader's whole-row grant is
|
||||
// built from the entity after these files ran.
|
||||
describe("the project description migration", () => {
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
|
||||
const shape = shapeOfEntity(projects)
|
||||
|
||||
it("adds a nullable column to the existing table: a forward migration over rows that exist", () => {
|
||||
for (const [entity, field] of ADDED_BY_0009) {
|
||||
expect(isNullable(shapeOfEntity(entity)[field]), `${entity.table}.${field}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("is 0009_project-description.sql on Postgres", async () => {
|
||||
const statement = `alter table ${quoted(projects.table)} add column ${quoted(columnName("description"))} ${postgresType(shape.description)}`
|
||||
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
|
||||
"../migrations/postgres/0009_project-description.sql",
|
||||
)
|
||||
})
|
||||
|
||||
it("is profiles/mariadb/migrations/0009_project-description.sql", async () => {
|
||||
const statement = `alter table ${mysqlQuoted(projects.table)} add column ${mysqlQuoted(columnName("description"))} ${mysqlType(shape.description)}`
|
||||
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
|
||||
"../profiles/mariadb/migrations/0009_project-description.sql",
|
||||
)
|
||||
})
|
||||
|
||||
it("is profiles/mongodb/migrations/0009_project-description.json: the collection's whole current validator", () => {
|
||||
// A collection's validator refuses a field it does not list (additionalProperties: false).
|
||||
const expected: MongoCommand[] = [{ collMod: projects.table, validator: mongoValidator(asOf0009(projects)) }]
|
||||
expect(JSON.parse(read("profiles/mongodb/migrations/0009_project-description.json"))).toEqual(
|
||||
JSON.parse(JSON.stringify(expected)),
|
||||
)
|
||||
expect(JSON.stringify(mongoValidator(projects))).toContain('"description"')
|
||||
})
|
||||
})
|
||||
|
||||
function shapeOfEntity(entity: AnyEntityDef) {
|
||||
if (!(entity.schema instanceof z.ZodObject)) throw new Error(`${entity.table}: an entity's schema is an object`)
|
||||
return entity.schema.shape
|
||||
}
|
||||
|
||||
// An invitation that was revoked keeps its row, so its link can say so. One nullable column on every
|
||||
// profile; Convex takes it from the generated schema.
|
||||
describe("the invitation revoked migration", () => {
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
|
||||
const shape = shapeOfEntity(invitations)
|
||||
|
||||
it("adds a nullable column to the existing table: a forward migration over rows that exist", () => {
|
||||
for (const [entity, field] of ADDED_BY_0010) {
|
||||
expect(isNullable(shapeOfEntity(entity)[field]), `${entity.table}.${field}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("is 0010_invitation-revoked.sql on Postgres", async () => {
|
||||
const statement = `alter table ${quoted(invitations.table)} add column ${quoted(columnName("revokedAt"))} ${postgresType(shape.revokedAt)}`
|
||||
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
|
||||
"../migrations/postgres/0010_invitation-revoked.sql",
|
||||
)
|
||||
})
|
||||
|
||||
it("is profiles/mariadb/migrations/0010_invitation-revoked.sql", async () => {
|
||||
const statement = `alter table ${mysqlQuoted(invitations.table)} add column ${mysqlQuoted(columnName("revokedAt"))} ${mysqlType(shape.revokedAt)}`
|
||||
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
|
||||
"../profiles/mariadb/migrations/0010_invitation-revoked.sql",
|
||||
)
|
||||
})
|
||||
|
||||
it("is profiles/mongodb/migrations/0010_invitation-revoked.json: the collection's whole current validator", () => {
|
||||
const expected: MongoCommand[] = [{ collMod: invitations.table, validator: mongoValidator(invitations) }]
|
||||
expect(JSON.parse(read("profiles/mongodb/migrations/0010_invitation-revoked.json"))).toEqual(
|
||||
JSON.parse(JSON.stringify(expected)),
|
||||
)
|
||||
expect(JSON.stringify(mongoValidator(invitations))).toContain('"revoked_at"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Retrieval is one platform service (ADR-061, RAG R9): the knowledge module's backend is the platform's
|
||||
// knowledge service, an app keeps no vector store of its own, and a deployment that still names the old
|
||||
// backend is told so by name instead of quietly getting another one.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { KNOWLEDGE_BACKEND_VAR, KNOWLEDGE_BACKENDS, knowledgeBackendOf } from "@app/config"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const read = (path: string): string => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
|
||||
|
||||
describe("the knowledge backend", () => {
|
||||
it("is the platform's by default", () => {
|
||||
expect(knowledgeBackendOf({})).toBe("platform")
|
||||
expect(knowledgeBackendOf({ KNOWLEDGE_BACKEND: "" })).toBe("platform")
|
||||
expect(knowledgeBackendOf({ KNOWLEDGE_BACKEND: " Platform " })).toBe("platform")
|
||||
})
|
||||
|
||||
it("has no local backend to choose, and refuses the retired one by the variable's name", () => {
|
||||
expect(KNOWLEDGE_BACKENDS).toEqual(["platform"])
|
||||
expect(() => knowledgeBackendOf({ KNOWLEDGE_BACKEND: "vectors" })).toThrow(KNOWLEDGE_BACKEND_VAR)
|
||||
})
|
||||
|
||||
it("leaves no vector store in the apps, their dependencies or the deployment contract", () => {
|
||||
for (const file of [
|
||||
"apps/web/src/server/ports.ts",
|
||||
"apps/worker/src/ports.ts",
|
||||
"apps/web/package.json",
|
||||
"apps/worker/package.json",
|
||||
"pnpm-workspace.yaml",
|
||||
"compose.dev.yaml",
|
||||
".env.example",
|
||||
]) {
|
||||
expect(read(file), file).not.toMatch(/qdrant|VECTOR_STORE|pgvector/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
//
|
||||
// The licence layout (plan item AT5.1, spec section 14.3): LICENSE-TEMPLATE says what it is, the
|
||||
// customer's LICENSE is a stub, NOTICE and TEMPLATE-FILES exist and agree with the tree, and every
|
||||
// template file that can hold a comment carries the SPDX header. The negative controls make the check
|
||||
// fail where it should: a scaffold file that loses its header, a file nobody listed, a listed file that
|
||||
// is gone.
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import {
|
||||
hasHeader,
|
||||
headerFor,
|
||||
LIST_FILE,
|
||||
listText,
|
||||
parseList,
|
||||
problems,
|
||||
SPDX_ID,
|
||||
SPDX_LINE,
|
||||
templateFiles,
|
||||
withHeader,
|
||||
write,
|
||||
} from "../scripts/licence.mjs"
|
||||
|
||||
const APP_ROOT = new URL("..", import.meta.url).pathname
|
||||
const read = (path: string) => readFileSync(join(APP_ROOT, path), "utf8")
|
||||
|
||||
const made: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A small tree shaped like the template: a scaffold, a module and the layout's own files. */
|
||||
function fixture(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "licence-"))
|
||||
made.push(root)
|
||||
const all: Record<string, string> = {
|
||||
LICENSE: "yours\n",
|
||||
"LICENSE-TEMPLATE": "placeholder\n",
|
||||
NOTICE: "notice\n",
|
||||
"THIRD-PARTY-NOTICES": "none\n",
|
||||
"package.json": "{}\n",
|
||||
"src/a.ts": "export const a = 1\n",
|
||||
"modules/notes/index.ts": "export const notes = 1\n",
|
||||
...files,
|
||||
}
|
||||
for (const [path, text] of Object.entries(all)) {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true })
|
||||
writeFileSync(join(root, path), text)
|
||||
}
|
||||
write(root)
|
||||
return root
|
||||
}
|
||||
|
||||
describe("what the tool leaves alone", () => {
|
||||
it("claims neither a repository's history nor the app's own documents", () => {
|
||||
const root = fixture({
|
||||
".git/hooks/pre-commit.sh": "exit 0\n",
|
||||
"docs/domain-start.md": "the template's\n",
|
||||
"docs/tender/private.md": "the customer's\n",
|
||||
})
|
||||
const files = templateFiles(root)
|
||||
expect(files).toContain("docs/domain-start.md")
|
||||
expect(files.filter((file) => file.startsWith(".git/") || file.startsWith("docs/tender"))).toEqual([])
|
||||
expect(readFileSync(join(root, "docs/tender/private.md"), "utf8")).toBe("the customer's\n")
|
||||
})
|
||||
|
||||
it("claims a git worktree's history neither: there `.git` is a file (template lesson core-8)", () => {
|
||||
const root = fixture({ ".git": "gitdir: /elsewhere/.git/worktrees/app\n" })
|
||||
expect(templateFiles(root)).not.toContain(".git")
|
||||
})
|
||||
})
|
||||
|
||||
describe("this template's licence layout", () => {
|
||||
it("is whole: every file is listed, every file that can hold a header has it, and the four documents exist", () => {
|
||||
expect(problems(APP_ROOT)).toEqual([])
|
||||
})
|
||||
|
||||
it("says in LICENSE-TEMPLATE that it is a placeholder, until counsel decides (AT-O2)", () => {
|
||||
const text = read("LICENSE-TEMPLATE")
|
||||
expect(text).toMatch(/PLACEHOLDER/)
|
||||
expect(text).toContain("AT-O2")
|
||||
expect(text).toContain(SPDX_ID)
|
||||
expect(text).toMatch(/counsel/i)
|
||||
})
|
||||
|
||||
it("keeps LICENSE the customer's own, a stub that names what it does not cover", () => {
|
||||
const text = read("LICENSE")
|
||||
expect(text).toMatch(/This file is yours/)
|
||||
expect(text).toContain("LICENSE-TEMPLATE")
|
||||
expect(text).toContain("TEMPLATE-FILES")
|
||||
})
|
||||
|
||||
it("lists the template's files and not the customer's", () => {
|
||||
const listed = parseList(read(LIST_FILE))
|
||||
expect(listed).toEqual(templateFiles(APP_ROOT))
|
||||
expect(listed).toContain("apps/web/src/routes/__root.tsx")
|
||||
expect(listed).toContain("LICENSE-TEMPLATE")
|
||||
expect(listed).toContain(LIST_FILE)
|
||||
expect(listed).not.toContain("LICENSE")
|
||||
expect(listed).not.toContain("pnpm-lock.yaml")
|
||||
expect(listed.some((path) => path.startsWith("modules/"))).toBe(false)
|
||||
expect(listed).not.toContain(".conusai/template.json")
|
||||
})
|
||||
|
||||
it("puts the header first in a TypeScript file, a kernel migration, a workflow, the Dockerfile and the guide", () => {
|
||||
expect(read("Dockerfile").split("\n")[0]).toBe(`# ${SPDX_LINE}`)
|
||||
expect(read("test/registry.test.ts").split("\n")[0]).toBe(`// ${SPDX_LINE}`)
|
||||
expect(read("migrations/postgres/0001_kernel.sql").split("\n")[0]).toBe(`-- ${SPDX_LINE}`)
|
||||
expect(read(".gitea/workflows/ci.yml").split("\n")[0]).toBe(`# ${SPDX_LINE}`)
|
||||
expect(read("AGENTS.md").split("\n")[0]).toBe(`<!-- ${SPDX_LINE} -->`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the header", () => {
|
||||
it("is a comment of the file's own kind, and none for a file that cannot hold one", () => {
|
||||
expect(headerFor("a/b.ts")).toBe(`// ${SPDX_LINE}`)
|
||||
expect(headerFor("a/b.mjs")).toBe(`// ${SPDX_LINE}`)
|
||||
expect(headerFor("a/b.css")).toBe(`/* ${SPDX_LINE} */`)
|
||||
expect(headerFor("a/b.sql")).toBe(`-- ${SPDX_LINE}`)
|
||||
expect(headerFor("a/b.md")).toBe(`<!-- ${SPDX_LINE} -->`)
|
||||
expect(headerFor("compose.yaml")).toBe(`# ${SPDX_LINE}`)
|
||||
expect(headerFor("Dockerfile")).toBe(`# ${SPDX_LINE}`)
|
||||
expect(headerFor(".env.example")).toBe(`# ${SPDX_LINE}`)
|
||||
expect(headerFor("package.json")).toBeNull()
|
||||
expect(headerFor("llms.txt")).toBeNull()
|
||||
expect(headerFor("LICENSE-TEMPLATE")).toBeNull()
|
||||
})
|
||||
|
||||
it("goes after a shebang, and is added once", () => {
|
||||
const script = "#!/usr/bin/env bash\nset -e\n"
|
||||
const once = withHeader(script, "run.sh")
|
||||
expect(once).toBe(`#!/usr/bin/env bash\n# ${SPDX_LINE}\nset -e\n`)
|
||||
expect(withHeader(once, "run.sh")).toBe(once)
|
||||
expect(hasHeader(once, "run.sh")).toBe(true)
|
||||
expect(hasHeader(script, "run.sh")).toBe(false)
|
||||
})
|
||||
|
||||
it("goes after the frontmatter of a Markdown file that opens with one (an agent skill), and is added once", () => {
|
||||
const skill = "---\nname: fix\ndescription: Fix it.\n---\n# Fix\n"
|
||||
const once = withHeader(skill, ".claude/skills/fix/SKILL.md")
|
||||
expect(once).toBe(`---\nname: fix\ndescription: Fix it.\n---\n<!-- ${SPDX_LINE} -->\n# Fix\n`)
|
||||
expect(withHeader(once, ".claude/skills/fix/SKILL.md")).toBe(once)
|
||||
expect(hasHeader(once, ".claude/skills/fix/SKILL.md")).toBe(true)
|
||||
// A header above the frontmatter would hide the skill from its tool, so it does not count.
|
||||
expect(hasHeader(`<!-- ${SPDX_LINE} -->\n${skill}`, ".claude/skills/fix/SKILL.md")).toBe(true)
|
||||
expect(hasHeader(skill, ".claude/skills/fix/SKILL.md")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the check fails where it should", () => {
|
||||
it("passes a tree that `write` has laid out, and ignores modules/** and LICENSE", () => {
|
||||
const root = fixture({})
|
||||
expect(problems(root)).toEqual([])
|
||||
expect(parseList(readFileSync(join(root, LIST_FILE), "utf8"))).toEqual([
|
||||
"LICENSE-TEMPLATE",
|
||||
"NOTICE",
|
||||
"TEMPLATE-FILES",
|
||||
"THIRD-PARTY-NOTICES",
|
||||
"package.json",
|
||||
"src/a.ts",
|
||||
])
|
||||
expect(readFileSync(join(root, "modules/notes/index.ts"), "utf8")).not.toContain("SPDX")
|
||||
expect(readFileSync(join(root, "LICENSE"), "utf8")).toBe("yours\n")
|
||||
})
|
||||
|
||||
it("fails, naming the file, when a scaffold file loses its header", () => {
|
||||
const root = fixture({})
|
||||
writeFileSync(join(root, "src/a.ts"), "export const a = 1\n")
|
||||
expect(problems(root)).toEqual([`src/a.ts has no "${SPDX_LINE}" header`])
|
||||
})
|
||||
|
||||
it("fails when a file is not in TEMPLATE-FILES, and when a listed file is gone", () => {
|
||||
const root = fixture({})
|
||||
writeFileSync(join(root, "src/new.ts"), `// ${SPDX_LINE}\nexport {}\n`)
|
||||
expect(problems(root)).toEqual(["TEMPLATE-FILES does not list src/new.ts"])
|
||||
rmSync(join(root, "src/new.ts"))
|
||||
rmSync(join(root, "src/a.ts"))
|
||||
expect(problems(root)).toEqual(["TEMPLATE-FILES lists src/a.ts, which is not a template file"])
|
||||
})
|
||||
|
||||
it("fails when one of the four documents is missing", () => {
|
||||
const root = fixture({})
|
||||
rmSync(join(root, "NOTICE"))
|
||||
expect(problems(root)).toContain("NOTICE is missing")
|
||||
})
|
||||
|
||||
it("is what listText writes: comments, then one path a line", () => {
|
||||
const text = listText(["a", "b"])
|
||||
expect(parseList(text)).toEqual(["a", "b"])
|
||||
expect(text.startsWith("# ")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A write never reloads to show itself (AGENTS.md, "The recipe for a module", steps 5 and 9).
|
||||
//
|
||||
// - No module reloads the document: `location.reload()` throws the page's state away and reads every
|
||||
// loader again, where a live read changes only what changed.
|
||||
// - A module that declares a live collection writes through `useWrite`, which resolves once the
|
||||
// organization's live stream has seen the write, so every live read on the page already shows it.
|
||||
// Its UI re-runs no loader after a write (`usePageNavigation().refresh()`, `router.invalidate()`):
|
||||
// a page of it reads through `useLive` or a `useRead` kept live by the module's entities.
|
||||
//
|
||||
// Which modules declare a collection comes from their own manifests, every module whose source is
|
||||
// here, selected or not: a module that is switched on later is held to the same rule.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { join, relative } from "node:path"
|
||||
import type { ModuleManifest } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname
|
||||
const modulesDir = join(root, "modules")
|
||||
|
||||
const RELOAD = /\blocation\s*(?:\.\s*reload\b|\[\s*["'`]reload["'`]\s*\])/
|
||||
const LOADER_RERUN = /\.\s*refresh\s*\(|\brouter\s*\.\s*invalidate\s*\(/
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
if (!existsSync(dir)) return []
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) return name === "node_modules" || name === "dist" ? [] : sources(path)
|
||||
return /\.(?:ts|tsx|mts)$/.test(name) ? [path] : []
|
||||
})
|
||||
}
|
||||
|
||||
const moduleIds = readdirSync(modulesDir).filter((name) => existsSync(join(modulesDir, name, "index.ts")))
|
||||
|
||||
const isManifest = (value: unknown): value is ModuleManifest =>
|
||||
typeof value === "object" && value !== null && "id" in value && "procedures" in value
|
||||
|
||||
/** The ids of the source modules whose manifests declare at least one live collection. */
|
||||
async function modulesWithCollections(): Promise<string[]> {
|
||||
const found = new Set<string>()
|
||||
for (const id of moduleIds) {
|
||||
const exports = (await import(join(modulesDir, id, "index.ts"))) as Record<string, unknown>
|
||||
for (const value of Object.values(exports)) {
|
||||
if (isManifest(value) && (value.collections?.length ?? 0) > 0) found.add(id)
|
||||
}
|
||||
}
|
||||
return [...found].sort()
|
||||
}
|
||||
|
||||
/** `file:line: text` for each line of `files` that matches `pattern`, outside comments. */
|
||||
function offending(files: readonly string[], pattern: RegExp): string[] {
|
||||
return files.flatMap((file) =>
|
||||
readFileSync(file, "utf8")
|
||||
.split("\n")
|
||||
.flatMap((line, index) => {
|
||||
const code = line.replace(/\/\/.*$/, "")
|
||||
return pattern.test(code) ? [`${relative(root, file)}:${index + 1}: ${line.trim()}`] : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("a write never reloads to show itself", () => {
|
||||
it("no module reloads the document", () => {
|
||||
expect(offending(sources(modulesDir), RELOAD)).toEqual([])
|
||||
})
|
||||
|
||||
// kit:notes:begin
|
||||
it("finds the modules that declare live collections from their manifests", async () => {
|
||||
// notes is the worked example; if it stops declaring one, the next test proves nothing about it.
|
||||
expect(await modulesWithCollections()).toContain("notes")
|
||||
})
|
||||
// kit:notes:end
|
||||
|
||||
it("a module with a live collection re-runs no loader after a write", async () => {
|
||||
const problems = (await modulesWithCollections()).flatMap((id) =>
|
||||
offending(sources(join(modulesDir, id, "ui")), LOADER_RERUN),
|
||||
)
|
||||
expect(problems, "write with useWrite and read with useLive or a live useRead").toEqual([])
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Who may open which live stream (@conusai-kit/host/postgres `createHostLive`, umbrella TS6.2), decided with the
|
||||
// kernel's own tenancy over this app's registry: a member gets the collection for a topic they
|
||||
// resolve, with its ids; a signed-out request is 401; another organization's slug and a workspace
|
||||
// the caller does not resolve are 404; an entity that is not a collection is denied, and so is a
|
||||
// project collection asked for the whole organization; a member removed meanwhile is revoked at the
|
||||
// next re-check, and one whose role lost the read permission of a granted collection is told
|
||||
// `grants_changed`. The stream itself is proven by tests/live-conformance and e2e/notes-live.spec.ts.
|
||||
|
||||
import type { Principal } from "@conusai-kit/auth"
|
||||
import { createHostLive, createLiveGrants } from "@conusai-kit/host/postgres"
|
||||
import { createTenancyHooks, orgMemberships, TENANCY } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { makeApp, type Person } from "../modules/account/__tests__/harness.js"
|
||||
|
||||
const app = makeApp()
|
||||
let principal: Principal | null = null
|
||||
const quiet = {
|
||||
log: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
||||
captureException: () => undefined,
|
||||
span: <T>(_name: string, _attributes: unknown, fn: () => T): T => fn(),
|
||||
}
|
||||
|
||||
const { authorize } = createHostLive({
|
||||
runtime: () => ({ registry: app.registry, observe: quiet }),
|
||||
requestContext: async () => {
|
||||
const container = app.root.child()
|
||||
container.value(TENANCY, createTenancyHooks({ data: app.data, registry: app.registry }))
|
||||
return { principal, container }
|
||||
},
|
||||
log: () => undefined,
|
||||
grants: createLiveGrants(),
|
||||
})
|
||||
|
||||
const as = (person: Person | null) => {
|
||||
principal = person ? ({ actor: person.actor, method: "bearer" } as Principal) : null
|
||||
}
|
||||
const open = (org: string, body: { entities: string[]; topics: Array<{ ws: string; project?: string }> }) =>
|
||||
authorize(new Request(`http://app.test/api/v1/orgs/${org}/live`, { method: "POST" }), {
|
||||
protocol: "clp/1",
|
||||
after: null,
|
||||
...body,
|
||||
})
|
||||
|
||||
async function orgWithProject(person: Person, slug: string) {
|
||||
const call = person.call()
|
||||
const created = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
|
||||
await call.callByName("tenancy.workspace.create", { org: slug, slug: "main", name: "Main", visibility: "org" })
|
||||
await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: "main",
|
||||
slug: "notes",
|
||||
name: "Notes",
|
||||
moduleKey: "notes",
|
||||
})
|
||||
return created.orgId
|
||||
}
|
||||
|
||||
describe("the live stream's authorization", async () => {
|
||||
const ada = await app.signUp("ada-live@example.test")
|
||||
const grace = await app.signUp("grace-live@example.test")
|
||||
const acme = await orgWithProject(ada, "acme-live")
|
||||
await orgWithProject(grace, "globex-live")
|
||||
const topic = { ws: "main", project: "notes" }
|
||||
|
||||
it("grants a member the collection for a topic they resolve, with its ids, and denies the rest", async () => {
|
||||
as(ada)
|
||||
const grant = await open("acme-live", { entities: ["notes.note", "notes.secret"], topics: [topic] })
|
||||
expect(grant.org).toBe(acme)
|
||||
expect(grant.granted.entities).toEqual(["notes.note"])
|
||||
expect(grant.granted.topics[0]?.ids.org).toBe(acme)
|
||||
expect(grant.granted.topics[0]?.ids.ws).toBeTruthy()
|
||||
expect(grant.granted.topics[0]?.ids.project).toBeTruthy()
|
||||
expect(grant.denied).toEqual([{ entity: "notes.secret", reason: "unknown" }])
|
||||
expect(await grant.recheck()).toBeNull()
|
||||
})
|
||||
|
||||
it("denies a project collection for the whole organization", async () => {
|
||||
as(ada)
|
||||
const grant = await open("acme-live", { entities: ["notes.note"], topics: [] })
|
||||
expect(grant.granted.entities).toEqual([])
|
||||
expect(grant.denied).toEqual([{ entity: "notes.note", reason: "scope" }])
|
||||
})
|
||||
|
||||
it("refuses a signed-out request, another organization's slug and a workspace the caller does not resolve", async () => {
|
||||
as(null)
|
||||
await expect(open("acme-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
|
||||
status: 401,
|
||||
})
|
||||
as(grace)
|
||||
await expect(open("acme-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
as(ada)
|
||||
await expect(open("acme-live", { entities: ["notes.note"], topics: [{ ws: "nowhere" }] })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
await expect(open("globex-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it("ends a stream with grants_changed once the member's role no longer reads a granted collection", async () => {
|
||||
const linus = await app.signUp("linus-live@example.test")
|
||||
const invitation = (await ada.call().callByName("tenancy.member.invite", {
|
||||
org: "acme-live",
|
||||
email: linus.email,
|
||||
role: "member",
|
||||
})) as { token: string }
|
||||
await linus.call().callByName("tenancy.invitation.accept", { org: "acme-live", token: invitation.token })
|
||||
as(linus)
|
||||
const grant = await open("acme-live", { entities: ["notes.note"], topics: [topic] })
|
||||
expect(grant.granted.entities).toEqual(["notes.note"])
|
||||
expect(await grant.recheck()).toBeNull()
|
||||
// A custom organization role that reads the organization and nothing of the notes module.
|
||||
const role = (await ada.call().callByName("tenancy.role.create", {
|
||||
org: "acme-live",
|
||||
name: "Directory only",
|
||||
level: "org",
|
||||
permissions: ["tenancy.org.read", "tenancy.workspace.read", "tenancy.project.read"],
|
||||
})) as { key: string }
|
||||
await ada.call().callByName("tenancy.member.assign", {
|
||||
org: "acme-live",
|
||||
userId: linus.actor.userId,
|
||||
level: "org",
|
||||
roleKey: role.key,
|
||||
})
|
||||
expect(await grant.recheck()).toBe("grants_changed")
|
||||
})
|
||||
|
||||
it("revokes a member who was removed, at the next re-check", async () => {
|
||||
as(ada)
|
||||
const grant = await open("acme-live", { entities: ["notes.note"], topics: [topic] })
|
||||
const scope = { level: "org" as const, orgId: acme, actor: ada.actor }
|
||||
const rows = (await app.data.forScope(scope).of(orgMemberships).list({})).items
|
||||
for (const row of rows) await app.data.forScope(scope).of(orgMemberships).delete(row.id)
|
||||
expect(await grant.recheck()).toBe("membership")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The kit's markers stay where the import sorter cannot move other code into them.
|
||||
//
|
||||
// `kit template:prune` removes every line that carries a `// kit:<name>` comment and every line between
|
||||
// `// kit:<name>:begin` and `// kit:<name>:end`. Biome's import sorter (`organizeImports`) reorders an import
|
||||
// section across comments and blank lines, and merges two imports of one source:
|
||||
//
|
||||
// - an import inside a begin/end block can trade places with an app's own import, which the prune then
|
||||
// deletes without a word; so no block holds an import or a re-export;
|
||||
// - a whole-line marker survives the sort only on a one-line import the sorter cannot merge: it merges two
|
||||
// value imports (or two type-only imports, or two re-exports) of one source into one and keeps neither
|
||||
// line's comment. A namespace import is never merged.
|
||||
// A module's imports are one namespace import (`import * as widgets from "@app/modules/widgets" //
|
||||
// kit:widgets`), or one marked name inside a shared import (`filesDeps, // kit:widgets`), which the
|
||||
// sorter keeps with its name.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { join, relative } from "node:path"
|
||||
import ts from "typescript"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname
|
||||
const FOLDERS = ["apps", "config", "modules", "test", "e2e", "evals", "scripts"]
|
||||
const SKIP = new Set(["node_modules", "dist", ".tanstack", "generated", ".logs"])
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
if (!existsSync(dir)) return []
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) return SKIP.has(name) ? [] : sources(path)
|
||||
return /\.(?:ts|tsx|mts|cts|mjs|cjs|js)$/.test(name) && !name.endsWith(".gen.ts") ? [path] : []
|
||||
})
|
||||
}
|
||||
|
||||
const BEGIN = /\/\/\s*kit:([\w-]+):begin\b/
|
||||
const END = /\/\/\s*kit:([\w-]+):end\b/
|
||||
const LINE_MARK = /(?:\/\/|\/\*|\{\/\*)\s*kit:[\w-]+\b(?!:)/
|
||||
|
||||
interface Statement {
|
||||
readonly first: number
|
||||
readonly last: number
|
||||
readonly source: string
|
||||
/** What the sorter merges with what: value imports, type-only imports and re-exports, each among their own. */
|
||||
readonly kind: "value" | "type" | "export"
|
||||
/** `import * as x`: never merged with another import. */
|
||||
readonly namespace: boolean
|
||||
}
|
||||
|
||||
/** The file's static imports and `export … from`, with their 1-based first and last lines. */
|
||||
function importsOf(file: string, text: string): Statement[] {
|
||||
const parsed = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false)
|
||||
const out: Statement[] = []
|
||||
for (const node of parsed.statements) {
|
||||
const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier : undefined
|
||||
if (!specifier || !ts.isStringLiteral(specifier)) continue
|
||||
const first = parsed.getLineAndCharacterOfPosition(node.getStart(parsed)).line + 1
|
||||
const last = parsed.getLineAndCharacterOfPosition(node.getEnd()).line + 1
|
||||
const clause = ts.isImportDeclaration(node) ? node.importClause : undefined
|
||||
const kind = ts.isExportDeclaration(node) ? "export" : clause?.isTypeOnly ? "type" : "value"
|
||||
const namespace = clause?.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)
|
||||
out.push({ first, last, source: specifier.text, kind, namespace })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether the import sorter would merge the two statements into one. */
|
||||
const mergeable = (a: Statement, b: Statement): boolean =>
|
||||
a !== b && a.source === b.source && a.kind === b.kind && !a.namespace && !b.namespace
|
||||
|
||||
/** `file:line: why` for each marker the import sorter can break. */
|
||||
function markerProblems(file: string, text: string): string[] {
|
||||
const lines = text.split("\n")
|
||||
const imports = importsOf(file, text)
|
||||
const where = (line: number) => `${file}:${line}`
|
||||
const problems: string[] = []
|
||||
|
||||
const open: { name: string; line: number }[] = []
|
||||
lines.forEach((line, index) => {
|
||||
const begin = BEGIN.exec(line)
|
||||
const end = END.exec(line)
|
||||
if (begin) open.push({ name: begin[1] as string, line: index + 1 })
|
||||
else if (end) {
|
||||
const at = open.findLastIndex((block) => block.name === end[1])
|
||||
if (at < 0) return
|
||||
const [block] = open.splice(at, 1)
|
||||
for (const statement of imports) {
|
||||
if (block && statement.last >= block.line && statement.first <= index + 1) {
|
||||
problems.push(
|
||||
`${where(statement.first)}: an import of "${statement.source}" inside the kit:${block.name} block of line ${block.line}; the import sorter can move an app's own import into the block, and the prune would delete it`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const statement of imports) {
|
||||
for (let line = statement.first; line <= statement.last; line++) {
|
||||
if (!LINE_MARK.test(lines[line - 1] ?? "")) continue
|
||||
const multiLine = statement.first !== statement.last
|
||||
if (multiLine && (line === statement.first || line === statement.last)) {
|
||||
problems.push(
|
||||
`${where(line)}: a marker on the ${line === statement.first ? "first" : "last"} line of a multi-line import of "${statement.source}"; mark the names inside it, or make it one line`,
|
||||
)
|
||||
} else if (!multiLine && !statement.namespace && imports.some((other) => mergeable(statement, other))) {
|
||||
problems.push(
|
||||
`${where(line)}: a marked import of "${statement.source}" beside another import of it; the import sorter merges them and drops the marker. Mark the names inside one import`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
describe("the kit's markers and the import sorter", () => {
|
||||
it("no marker in the template's sources can be broken by sorting or merging imports", () => {
|
||||
const problems = FOLDERS.flatMap((folder) => sources(join(root, folder))).flatMap((path) =>
|
||||
markerProblems(relative(root, path), readFileSync(path, "utf8")),
|
||||
)
|
||||
expect(problems).toEqual([])
|
||||
})
|
||||
|
||||
it("names an import inside a block, a marker on the edge of a multi-line import and a mergeable marked import", () => {
|
||||
const text = [
|
||||
'import { a } from "@app/modules/account"',
|
||||
"// kit:widgets:begin",
|
||||
'import { w } from "@app/modules/widgets"',
|
||||
"// kit:widgets:end",
|
||||
"import {",
|
||||
" d,",
|
||||
'} from "@app/modules/gadgets" // kit:gadgets',
|
||||
'import { f1 } from "@conusai-kit/files"',
|
||||
'import { f2 } from "@conusai-kit/files" // kit:widgets',
|
||||
"import {",
|
||||
" g1, // kit:widgets",
|
||||
" g2,",
|
||||
'} from "@conusai-kit/data"',
|
||||
'import * as gizmos from "@conusai-kit/gizmos" // kit:gizmos',
|
||||
'import { s } from "@conusai-kit/gizmos"',
|
||||
'import type { T } from "@conusai-kit/auth"',
|
||||
'import { load } from "@conusai-kit/auth" // kit:sprockets',
|
||||
"export const all = [a, w, d, f1, f2, g1, g2, gizmos, s, load]",
|
||||
].join("\n")
|
||||
expect(markerProblems("x.ts", text).map((problem) => problem.split(": ")[0])).toEqual([
|
||||
"x.ts:3",
|
||||
"x.ts:7",
|
||||
"x.ts:9",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tools this app opts in to, listed by the real mount over the real registry through the official
|
||||
// SDK client. The transport's own rules are `@conusai-kit/host/http`'s tests.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createMcpHandler } from "@conusai-kit/host/http"
|
||||
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../modules/index.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
|
||||
describe("this app's MCP tools", () => {
|
||||
it("are the procedures that opted in, and a tool call goes through the scoped caller", async () => {
|
||||
const output = { notes: [], hasMore: false }
|
||||
const callByName = vi.fn(async () => output)
|
||||
const handle = createMcpHandler({
|
||||
registry,
|
||||
contextFor: async () => ({
|
||||
principal: { method: "bearer", actor: { userId: "user-a" } },
|
||||
caller: { callByName } as never,
|
||||
}),
|
||||
checkCsrf: () => undefined,
|
||||
})
|
||||
const transport = new StreamableHTTPClientTransport(new URL("https://app.example.test/api/mcp"), {
|
||||
fetch: (url, init) => handle(new Request(url, init), "sdk-request"),
|
||||
})
|
||||
const client = new Client({ name: "app-template-test", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } })
|
||||
try {
|
||||
await client.connect(transport)
|
||||
const listed = await client.listTools()
|
||||
// The Agent's workspace reads are tools too: what an MCP client of a workspace can list and open.
|
||||
expect(listed.tools.map((tool) => tool.name)).toEqual([
|
||||
"agent.run.status", // kit:agent
|
||||
"assistant.workspace.openProject", // kit:agent
|
||||
"assistant.workspace.projects", // kit:agent
|
||||
"knowledge.search", // kit:knowledge
|
||||
"notes.note.list",
|
||||
])
|
||||
expect(listed.tools.find((tool) => tool.name === "notes.note.list")?.inputSchema.required).toEqual([
|
||||
"org",
|
||||
"workspace",
|
||||
"project",
|
||||
])
|
||||
const result = await client.callTool({
|
||||
name: "notes.note.list",
|
||||
arguments: { org: "org-a", workspace: "ws-a", project: "project-a" },
|
||||
})
|
||||
expect(result.structuredContent).toEqual(output)
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
|
||||
...(await import("./support/native-readiness.js")).selectedModules(["agent", "knowledge"]),
|
||||
}))
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What the migrate job applies, and the one grant list the global reader gets (decision AT-D20).
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { type Migration, OUTBOX_READER_GRANT } from "@conusai-kit/data-kysely"
|
||||
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { CROSS_TENANT_READS, jobSchemaGrants, readerGrants, splitStatements } from "../apps/worker/src/migrations.js"
|
||||
import { appModules } from "../modules/index.js"
|
||||
|
||||
const dir = new URL("../migrations/postgres/", import.meta.url)
|
||||
const files = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
const committed = files.map((name) => readFileSync(new URL(name, dir), "utf8")).join("\n")
|
||||
const registry = buildRegistry([...appModules])
|
||||
|
||||
describe("the committed migrations", () => {
|
||||
it("are numbered, forward only, and start at 0001", () => {
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
expect(files[0]).toMatch(/^0001_/)
|
||||
for (const name of files) expect(name).toMatch(/^\d{4}_[a-z0-9-]+\.sql$/)
|
||||
})
|
||||
|
||||
it("create a table for every entity this app's registry declares", () => {
|
||||
const declared = [...entitiesOf(registry, "postgres"), ...KIT_JOBS_ENTITIES]
|
||||
const inModules = new Set(registry.migrations.postgres.map(({ module }) => module))
|
||||
for (const entity of declared) {
|
||||
// A module that ships its own migration folder is allowed to own its tables there.
|
||||
if (inModules.size > 0 && !committed.includes(`create table "${entity.table}"`)) continue
|
||||
expect(committed, `no migration creates ${entity.table}`).toContain(`create table "${entity.table}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it("split into statements without losing a dollar-quoted body", () => {
|
||||
const statements = splitStatements('create table "a" ("x" int);\n\ndo $$ begin perform 1; end $$;\n')
|
||||
expect(statements).toHaveLength(2)
|
||||
expect(statements[1]).toContain("do $$ begin perform 1; end $$")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the Agent's tables", () => {
|
||||
it("are the kit's, made again for the workspace-level Agent with no conversation carried over", () => {
|
||||
// The kit's file, as the package that depends on it installed it.
|
||||
const kitFile = new URL("../modules/node_modules/@conusai-kit/agent/migrations/0001_assistant.sql", import.meta.url)
|
||||
const kit = readFileSync(kitFile, "utf8").split("\n").slice(1).join("\n")
|
||||
const app = readFileSync(
|
||||
new URL("../modules/agent/migrations/0005_assistant_workspace.sql", import.meta.url),
|
||||
"utf8",
|
||||
)
|
||||
expect(app.endsWith(kit)).toBe(true)
|
||||
for (const table of ["assistant_receipt", "assistant_thread", "assistant_turn", "assistant_conversation"])
|
||||
expect(app).toContain(`drop table "${table}";`)
|
||||
expect(app).toMatch(/create table "assistant_conversation" \([^;]*"project_id" uuid\n\);/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the global reader's grants", () => {
|
||||
const [migration, workspaces] = readerGrants("postgresql://app_reader:secret@db:5432/app") as [Migration, Migration]
|
||||
const sql = migration.statements.join(";\n")
|
||||
const tables = ["outbox", "org_memberships", "projects", "auth_api_keys"]
|
||||
|
||||
it("grant the cross-tenant reads the kit modules declare, the outbox, and no other table", () => {
|
||||
// The list is the modules' own declarations, so adding a read to a module adds the grant here
|
||||
// and a table nobody declared is never granted.
|
||||
expect(CROSS_TENANT_READS.map((read) => read.entity.table).sort()).toEqual([
|
||||
"auth_api_keys",
|
||||
"org_memberships",
|
||||
"projects",
|
||||
"workspaces",
|
||||
])
|
||||
const granted = migration.statements.filter((statement) => statement.startsWith("grant select"))
|
||||
expect(granted).toHaveLength(tables.length)
|
||||
for (const table of tables) expect(sql).toContain(`on "${table}" to "app_reader"`)
|
||||
})
|
||||
|
||||
it("keep the text an installed app recorded: the runner refuses a migration that changed", () => {
|
||||
// Literal on purpose. A read names its columns, so a column added to an entity changes nothing
|
||||
// here. A column the reader really needs is a new migration, never an edit of this list.
|
||||
const granted = (table: string) =>
|
||||
migration.statements.find(
|
||||
(statement) => statement.startsWith("grant select") && statement.includes(` on "${table}" `),
|
||||
)
|
||||
expect(granted("org_memberships")).toBe(
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "user_id", "role", "role_key") on "org_memberships" to "app_reader"',
|
||||
)
|
||||
expect(granted("projects")).toBe(
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "workspace_id", "slug", "name", "module_key", "archived_at", "visibility") on "projects" to "app_reader"',
|
||||
)
|
||||
expect(granted("auth_api_keys")).toBe(
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "name", "token_hash", "prefix", "user_id", "expires_at", "revoked_at") on "auth_api_keys" to "app_reader"',
|
||||
)
|
||||
})
|
||||
|
||||
it("grant a read added later by a migration of its own, so the first keeps the text it shipped with", () => {
|
||||
expect(migration.name).toBe("0002_reader_grants")
|
||||
expect(workspaces.name).toBe("0004_reader_grants_workspaces")
|
||||
expect(workspaces.statements).toEqual([
|
||||
'grant select ("id", "version", "created_at", "updated_at", "org_id", "slug", "name", "is_default", "visibility") on "workspaces" to "app_reader"',
|
||||
'create policy "workspaces_global_reader" on "workspaces" for select to "app_reader" using (true)',
|
||||
])
|
||||
})
|
||||
|
||||
it("never reach a project's description, which is a person's free text", () => {
|
||||
expect(sql).not.toContain('"description"')
|
||||
})
|
||||
|
||||
it("let the reader write nothing but the columns the relay keeps its books in", () => {
|
||||
// A grant on the whole row would let the reader rewrite an event's organization, name or
|
||||
// payload, which the relay then hands to a subscriber running as a system actor.
|
||||
const update = migration.statements.filter((statement) => statement.startsWith("grant update"))
|
||||
expect(update).toHaveLength(1)
|
||||
expect(update[0]).toContain('on "outbox" to "app_reader"')
|
||||
for (const column of OUTBOX_READER_GRANT.update ?? []) expect(update[0]).toContain(`"${column}"`)
|
||||
expect(update[0]).not.toContain('"payload"')
|
||||
expect(update[0]).not.toContain('"org_id"')
|
||||
expect(update[0]).not.toContain('"name"')
|
||||
expect(sql).not.toMatch(/grant [^;]*insert/)
|
||||
expect(sql).not.toMatch(/grant [^;]*delete/)
|
||||
})
|
||||
|
||||
it("give the reader a policy of its own, because row-level security binds it too", () => {
|
||||
// The reader owns nothing and has NOBYPASSRLS (data-services DS21), so without a policy
|
||||
// naming it the forced policy on app.org_id would answer zero rows for every read.
|
||||
for (const table of tables) {
|
||||
expect(sql).toContain(
|
||||
`create policy "${table}_global_reader" on "${table}" for select to "app_reader" using (true)`,
|
||||
)
|
||||
}
|
||||
expect(sql).toContain('create policy "outbox_global_reader_update"')
|
||||
})
|
||||
|
||||
it("name the role from the reader's own URL, quoted", () => {
|
||||
expect(readerGrants("postgresql://other_reader:s@db:5432/app")[0]?.statements[0]).toContain('"other_reader"')
|
||||
})
|
||||
|
||||
it("grant the runtime role the job schema a transactional enqueue needs", () => {
|
||||
const jobs = jobSchemaGrants("postgresql://app_app:secret@db:5432/app").statements.join(";\n")
|
||||
expect(jobs).toContain('grant usage on schema "pgboss" to "app_app"')
|
||||
expect(jobs).toContain('alter default privileges in schema "pgboss"')
|
||||
expect(jobs).not.toContain("app_reader")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { readFileSync } from "node:fs"
|
||||
import { buildRegistry, type ModuleManifest, type Token } from "@conusai-kit/core"
|
||||
import { generateConvex } from "@conusai-kit/data-convex"
|
||||
import { guidesModule } from "@conusai-kit/guides" // kit:guides
|
||||
import { supportModule } from "@conusai-kit/support" // kit:support
|
||||
import { accountDataOf } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { composedClientModules } from "../apps/web/src/ui/client-module-list.js"
|
||||
import { helpFor } from "../apps/web/src/ui/help.js"
|
||||
import { convexModules } from "../convex/lib/convexModules.mjs"
|
||||
import { agentModule, assistantAppModule } from "../modules/agent/index.js" // kit:agent
|
||||
import { custodyModule } from "../modules/custody.js"
|
||||
import { documentsModule } from "../modules/documents/index.js" // kit:documents
|
||||
import { moduleInclusion, OPTIONAL_MODULE_IDS, validateInclusion } from "../modules/inclusion.js"
|
||||
import { appModules } from "../modules/index.js"
|
||||
import { knowledgeModule } from "../modules/knowledge/index.js" // kit:knowledge
|
||||
import { modulePages } from "../modules/pages.js"
|
||||
|
||||
describe("fresh module admission", () => {
|
||||
it.runIf(moduleInclusion.everAdopted.length === 0)(
|
||||
"registers no optional procedure, job, permission, page or rights obligation",
|
||||
() => {
|
||||
expect(moduleInclusion).toEqual({ schemaVersion: 1, selected: [], everAdopted: [] })
|
||||
const config = JSON.parse(readFileSync(new URL("../conusai.json", import.meta.url), "utf8"))
|
||||
expect(
|
||||
config.token.permissions.filter((scope: string) =>
|
||||
/^(ocr:|ai:|ai_tools:|knowledge[.:]|guides:|support:)/.test(scope),
|
||||
),
|
||||
).toEqual([])
|
||||
const registry = buildRegistry([...appModules])
|
||||
expect(appModules.some((m) => OPTIONAL_MODULE_IDS.some((id) => id === m.id) || m.id === "assistant")).toBe(false)
|
||||
expect(Object.keys(modulePages)).toEqual(["notes"])
|
||||
expect(accountDataOf(registry).some((share) => ["guides", "support", "assistant"].includes(share.key))).toBe(
|
||||
false,
|
||||
)
|
||||
},
|
||||
)
|
||||
it.runIf(moduleInclusion.everAdopted.length === 0)("mounts no optional help section/client", () => {
|
||||
expect(
|
||||
helpFor({ locale: "en" } as never, { title: "Help", sections: "Sections", guides: "Guides", support: "Support" })
|
||||
.any,
|
||||
).toBe(false)
|
||||
expect(composedClientModules.modules).toEqual([])
|
||||
expect(composedClientModules.providers).toEqual([])
|
||||
expect(composedClientModules.slots).toEqual([])
|
||||
})
|
||||
it("refuses unbounded fields, IDs, versions and selected-without-custody", () => {
|
||||
for (const value of [
|
||||
{ schemaVersion: 2, selected: [], everAdopted: [] },
|
||||
{ schemaVersion: 1, selected: ["agent"], everAdopted: [] },
|
||||
{ schemaVersion: 1, selected: [], everAdopted: ["customer"] },
|
||||
{ schemaVersion: 1, selected: [], everAdopted: [], subject: "synthetic" },
|
||||
])
|
||||
expect(() => validateInclusion(value)).toThrow()
|
||||
})
|
||||
})
|
||||
describe("retained manifests", () => {
|
||||
it.each<ModuleManifest>([
|
||||
documentsModule, // kit:documents
|
||||
knowledgeModule, // kit:knowledge
|
||||
agentModule, // kit:agent
|
||||
assistantAppModule, // kit:agent
|
||||
guidesModule, // kit:guides
|
||||
supportModule, // kit:support
|
||||
])("retains $id schema under its original identity without interactive admission", (original) => {
|
||||
const retained = custodyModule(original)
|
||||
expect(retained.id).toBe(original.id)
|
||||
expect(retained.repositories).toBe(original.repositories)
|
||||
expect(retained.migrations).toBe(original.migrations)
|
||||
for (const key of [
|
||||
"procedures",
|
||||
"permissions",
|
||||
"collections",
|
||||
"ui",
|
||||
"serviceRequirements",
|
||||
"extensionPoints",
|
||||
] as const)
|
||||
expect(retained[key]).toBeUndefined()
|
||||
expect((retained.extends ?? []).every((entry) => entry.point.id === "tenancy.account")).toBe(true)
|
||||
})
|
||||
it("retains exactly the original rights hooks and retention/removal jobs", () => {
|
||||
const withRights: ModuleManifest[] = [
|
||||
guidesModule, // kit:guides
|
||||
supportModule, // kit:support
|
||||
assistantAppModule, // kit:agent
|
||||
]
|
||||
for (const manifest of withRights)
|
||||
expect(custodyModule(manifest).extends).toEqual(
|
||||
manifest.extends?.filter((entry) => entry.point.id === "tenancy.account"),
|
||||
)
|
||||
// kit:agent:begin
|
||||
expect(Object.keys(custodyModule(agentModule).jobs ?? {})).toEqual(["agent.runs.expire"])
|
||||
expect(Object.keys(custodyModule(assistantAppModule).jobs ?? {})).toEqual(["assistant.conversations.expire"])
|
||||
const schedules = custodyModule(agentModule).schedules
|
||||
expect(typeof schedules === "function" ? schedules({}) : []).toHaveLength(1)
|
||||
// kit:agent:end
|
||||
// kit:knowledge:begin
|
||||
const retained = custodyModule(knowledgeModule)
|
||||
expect(Object.keys(retained.jobs ?? {})).toEqual(["knowledge.remove"])
|
||||
expect(retained.events?.subscribes?.map((entry) => entry.event.name)).toEqual([
|
||||
"files.FileDeleted",
|
||||
"knowledge.documentRemoveRequested",
|
||||
])
|
||||
// kit:knowledge:end
|
||||
})
|
||||
})
|
||||
|
||||
it.runIf(moduleInclusion.everAdopted.length === 0)(
|
||||
"does not construct optional provider ports on a fresh web/worker boot",
|
||||
async () => {
|
||||
const { Container } = await import("@conusai-kit/core")
|
||||
const { bindWebPorts } = await import("../apps/web/src/server/ports.js")
|
||||
const { bindModulePorts } = await import("../apps/worker/src/ports.js")
|
||||
const optionalPorts: Token<unknown>[] = [
|
||||
(await import("../modules/documents/index.js")).DOCUMENT_OCR, // kit:documents
|
||||
(await import("@conusai-kit/guides")).GUIDES_PLATFORM, // kit:guides
|
||||
(await import("@conusai-kit/support")).SUPPORT_PLATFORM, // kit:support
|
||||
(await import("../modules/knowledge/index.js")).KNOWLEDGE_SEARCH, // kit:knowledge
|
||||
]
|
||||
const platform = {
|
||||
mode: "fake",
|
||||
deployment: { CONUSAI_API_URL: "http://127.0.0.1:1/api", CONUSAI_API_TOKEN: "synthetic" },
|
||||
objectStore: {},
|
||||
audit: {},
|
||||
usage: {},
|
||||
}
|
||||
for (const key of ["models", "documents", "model"])
|
||||
Object.defineProperty(platform, key, {
|
||||
get: () => {
|
||||
throw new Error("optional provider constructed at boot")
|
||||
},
|
||||
})
|
||||
const registry = buildRegistry([...appModules])
|
||||
for (const bind of [bindWebPorts, bindModulePorts]) {
|
||||
const container = new Container()
|
||||
bind(container, platform as never, registry)
|
||||
for (const token of optionalPorts) expect(container.has(token)).toBe(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
describe("committed Convex projection", () => {
|
||||
const root = new URL("..", import.meta.url)
|
||||
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 })',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The isolation suite reads the fixtures of registered modules only (template lesson core-2): a module folder
|
||||
// kept in the repository but not registered must not fail it.
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterEach, expect, it } from "vitest"
|
||||
import { isolationFilesOf } from "./support/module-isolations.js"
|
||||
|
||||
const made: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function modulesWith(layout: Record<string, boolean>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "isolation-modules-"))
|
||||
made.push(dir)
|
||||
for (const [id, hasFixtures] of Object.entries(layout)) {
|
||||
mkdirSync(join(dir, id, "__tests__"), { recursive: true })
|
||||
if (hasFixtures) writeFileSync(join(dir, id, "__tests__", "isolation.ts"), "export const isolation = {}\n")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
it("reads the fixtures of the registered modules and skips a held module's", () => {
|
||||
const dir = modulesWith({ widgets: true, held: true, plain: false })
|
||||
expect(isolationFilesOf(dir, ["widgets", "plain"])).toEqual([join(dir, "widgets", "__tests__", "isolation.ts")])
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Every table of a module the kit ships has a committed, forward-only migration, and each is exactly
|
||||
// `schemaStatements()` over that module's own entities (decision AT-D20: the entity definitions are
|
||||
// the one source of a column, so a hand-written column cannot drift from the schema a module validates
|
||||
// its rows with). A kit upgrade that changes a table changes a snapshot here, which is the moment a
|
||||
// customer must see a new migration in review.
|
||||
//
|
||||
// `0002` and `0003` are the grants the migrate job builds at run time (apps/worker/src/migrations.ts),
|
||||
// so the files of the kit's modules take the next numbers.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { appRoot, moduleMigrationFolder } from "@app/config"
|
||||
import { ACCOUNT_ENTITIES, accountModule } from "@app/modules/account"
|
||||
import { DOCUMENTS_ENTITIES, documentsModule } from "@app/modules/documents" // kit:documents
|
||||
import { authModule } from "@conusai-kit/auth"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { mysqlSchemaStatements } from "@conusai-kit/data-kysely/mysql"
|
||||
import { MONGO_KERNEL_COMMANDS, mongoSchemaCommands } from "@conusai-kit/data-mongo"
|
||||
import { filesModule } from "@conusai-kit/files"
|
||||
import { liveSchemaStatements } from "@conusai-kit/live-server"
|
||||
import { proposalsModule } from "@conusai-kit/proposals"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { SPDX_LINE } from "../scripts/licence.mjs"
|
||||
|
||||
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own two. */
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
/** The same for a module's MariaDB folder (spec §5.3: one numbered SQL folder per dialect). */
|
||||
function mysqlTablesOf(entities: Parameters<typeof mysqlSchemaStatements>[0]): string {
|
||||
const kernel = mysqlSchemaStatements([]).length
|
||||
return `${mysqlSchemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
type MongoCommands = ReturnType<typeof mongoSchemaCommands>
|
||||
|
||||
/** The same for a module's MongoDB folder: `mongoSchemaCommands` leads with the kernel's own collections. */
|
||||
function mongoCommandsOf(entities: Parameters<typeof mongoSchemaCommands>[0]): MongoCommands {
|
||||
return mongoSchemaCommands(entities).slice(MONGO_KERNEL_COMMANDS.length)
|
||||
}
|
||||
|
||||
/** A file of the account module, which the package ships: the folder is the one the migrate job reads. */
|
||||
const accountMigration = (profile: "mariadb" | "mongodb", name: string): string => {
|
||||
const path = accountModule.migrations?.[profile] ?? ""
|
||||
return readFileSync(join(moduleMigrationFolder({ module: "account", path }, appRoot()), name), "utf8")
|
||||
}
|
||||
|
||||
// kit:documents:begin
|
||||
/**
|
||||
* A validator as the generator wrote it before a nullable enum listed null: the files that ran then stay what
|
||||
* they were, and a later file carries the current validator.
|
||||
*/
|
||||
const withoutListedNull = <T>(value: T): T =>
|
||||
JSON.parse(JSON.stringify(value, (_, item) => (Array.isArray(item) ? item.filter((entry) => entry !== null) : item)))
|
||||
// kit:documents:end
|
||||
|
||||
const entitiesOfModule = (module: Parameters<typeof buildRegistry>[0][number]) =>
|
||||
entitiesOf(buildRegistry([tenancyModule, module]), "postgres").filter(
|
||||
(entity) => !entitiesOf(buildRegistry([tenancyModule]), "postgres").some((own) => own.table === entity.table),
|
||||
)
|
||||
|
||||
describe("the account module's dialect migrations (data-subject rights on every profile)", () => {
|
||||
// First shipped with both columns 0003 added on Postgres (steps, provider), so each dialect's 0001
|
||||
// is the ledger and the revocation outbox as they stood then; what `identity.erase` added since
|
||||
// (identity_subjects, identity_note, and the `held` state) is each dialect's 0002: a migration that
|
||||
// has run is never edited, and the files together are exactly the entities.
|
||||
it("ships the MariaDB tables as mysqlSchemaStatements generates them", () => {
|
||||
const mysqlMigration = (name: string) => accountMigration("mariadb", name)
|
||||
const added = ",\n `identity_subjects` json,\n `identity_note` text\n"
|
||||
const generated = mysqlTablesOf(ACCOUNT_ENTITIES)
|
||||
expect(generated).toContain(added)
|
||||
expect(generated.replace(added, "\n")).toBe(mysqlMigration("0001_account.sql"))
|
||||
expect(mysqlMigration("0002_identity_erasure.sql")).toBe(
|
||||
"alter table `account_deletion` add column `identity_subjects` json;\n\nalter table `account_deletion` add column `identity_note` text;\n",
|
||||
)
|
||||
})
|
||||
|
||||
it("ships the MongoDB collections as mongoSchemaCommands generates them", () => {
|
||||
// Compared as data: the committed file is formatted by Biome, the generator's output is not.
|
||||
const mongoMigration = (name: string): MongoCommands => JSON.parse(accountMigration("mongodb", name))
|
||||
const current = mongoCommandsOf(ACCOUNT_ENTITIES)
|
||||
const create = current.find(
|
||||
(command) => "createCollection" in command && command.createCollection === "account_deletion",
|
||||
)
|
||||
if (!create || !("createCollection" in create)) throw new Error("no createCollection for account_deletion")
|
||||
|
||||
const first = mongoMigration("0001_account.json")
|
||||
expect(JSON.stringify(first)).not.toContain("identity_subjects")
|
||||
expect(
|
||||
first.filter((command) => !("createCollection" in command && command.createCollection === "account_deletion")),
|
||||
).toEqual(current.filter((command) => command !== create))
|
||||
const shipped = structuredClone(create.validator)
|
||||
const fields = shipped.$jsonSchema.properties as Record<string, unknown>
|
||||
fields.identity_note = { bsonType: ["string", "null"] }
|
||||
expect(mongoMigration("0002_identity_erasure.json")).toEqual([{ collMod: "account_deletion", validator: shipped }])
|
||||
expect(mongoMigration("0003_identity_note_enum.json")).toEqual([
|
||||
{ collMod: "account_deletion", validator: create.validator },
|
||||
])
|
||||
})
|
||||
|
||||
it("is declared by the manifest for every profile, so the migrate job finds it", () => {
|
||||
expect(accountModule.migrations).toEqual({
|
||||
postgres: "@conusai-kit/account/migrations/postgres",
|
||||
mariadb: "@conusai-kit/account/migrations/mariadb",
|
||||
mongodb: "@conusai-kit/account/migrations/mongodb",
|
||||
})
|
||||
for (const profile of ["postgres", "mariadb", "mongodb"] as const) {
|
||||
expect(
|
||||
entitiesOf(buildRegistry([tenancyModule, authModule, accountModule]), profile).map(({ table }) => table),
|
||||
).toEqual(expect.arrayContaining(["account_deletion", "account_session_revocation"]))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("the live change log (umbrella TS6.2)", () => {
|
||||
it("0007_live.sql is @conusai-kit/live-server's schema: the log, its policies, the capture and the prune", async () => {
|
||||
const sql = `${liveSchemaStatements()
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
await expect(`-- ${SPDX_LINE}\n${sql}`).toMatchFileSnapshot("../migrations/postgres/0007_live.sql")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the migrations of the modules the kit ships", () => {
|
||||
it("0004_files.sql creates the files table", async () => {
|
||||
await expect(`-- ${SPDX_LINE}\n${tablesOf(entitiesOfModule(filesModule))}`).toMatchFileSnapshot(
|
||||
"../migrations/postgres/0004_files.sql",
|
||||
)
|
||||
})
|
||||
|
||||
it("0005_proposals.sql creates the proposals table", async () => {
|
||||
await expect(`-- ${SPDX_LINE}\n${tablesOf(entitiesOfModule(proposalsModule))}`).toMatchFileSnapshot(
|
||||
"../migrations/postgres/0005_proposals.sql",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// kit:documents:begin
|
||||
describe("the migration of the documents module", () => {
|
||||
const migration = (name: string) =>
|
||||
readFileSync(new URL(`../modules/documents/migrations/${name}`, import.meta.url), "utf8")
|
||||
|
||||
// A migration that has run is never edited, so a column added later is a file of its own: the first
|
||||
// file stays what it was, the second adds the column, and together they are exactly the entities.
|
||||
it("0001 is its two tables as first shipped, and 0002 adds the one column since", () => {
|
||||
expect(migration("0002_skipped_pages.sql")).toBe(
|
||||
'alter table "documents_document" add column "skipped_pages" jsonb;\n',
|
||||
)
|
||||
const generated = tablesOf(DOCUMENTS_ENTITIES)
|
||||
expect(generated).toContain(' "skipped_pages" jsonb,\n')
|
||||
expect(generated.replace(' "skipped_pages" jsonb,\n', "")).toBe(migration("0001_documents.sql"))
|
||||
})
|
||||
|
||||
it("is declared by the manifest, so the migrate job finds it", () => {
|
||||
expect(documentsModule.migrations).toEqual({
|
||||
postgres: "migrations",
|
||||
mariadb: "adapters/kysely/mysql/migrations",
|
||||
mongodb: "adapters/mongo/migrations",
|
||||
})
|
||||
const sql = migration("0001_documents.sql")
|
||||
for (const table of ["documents_document", "documents_line"]) {
|
||||
expect(sql).toContain(`create table "${table}"`)
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
}
|
||||
})
|
||||
|
||||
it("ships the same migrations in the MariaDB dialect (AT3.1)", () => {
|
||||
const mysqlMigration = (name: string) =>
|
||||
readFileSync(new URL(`../modules/documents/adapters/kysely/mysql/migrations/${name}`, import.meta.url), "utf8")
|
||||
expect(mysqlMigration("0002_skipped_pages.sql")).toBe(
|
||||
"alter table `documents_document` add column `skipped_pages` json;\n",
|
||||
)
|
||||
const generated = mysqlTablesOf(DOCUMENTS_ENTITIES)
|
||||
expect(generated).toContain(" `skipped_pages` json,\n")
|
||||
expect(generated.replace(" `skipped_pages` json,\n", "")).toBe(mysqlMigration("0001_documents.sql"))
|
||||
})
|
||||
|
||||
it("ships the same migrations as MongoDB commands: 0001 the collections as first shipped, 0002 the added field (AT3.2)", () => {
|
||||
const mongoMigration = (name: string): MongoCommands =>
|
||||
JSON.parse(
|
||||
readFileSync(new URL(`../modules/documents/adapters/mongo/migrations/${name}`, import.meta.url), "utf8"),
|
||||
)
|
||||
const current = mongoCommandsOf(DOCUMENTS_ENTITIES)
|
||||
const createDocument = current.find(
|
||||
(command) => "createCollection" in command && command.createCollection === "documents_document",
|
||||
)
|
||||
if (!createDocument || !("createCollection" in createDocument))
|
||||
throw new Error("no createCollection for documents_document")
|
||||
|
||||
// A migration that has run is never edited, so a field added later is a collMod of its own:
|
||||
// 0001/0002 predate processing_exhausted; 0003 adds null, and 0004 carries the current model.
|
||||
const first = mongoMigration("0001_documents.json")
|
||||
const created = first.find(
|
||||
(command) => "createCollection" in command && command.createCollection === "documents_document",
|
||||
)
|
||||
if (!created || !("createCollection" in created)) throw new Error("0001 does not create documents_document")
|
||||
expect(JSON.stringify(created.validator)).not.toContain("skipped_pages")
|
||||
const historical = structuredClone(createDocument)
|
||||
const failure = historical.validator.$jsonSchema.properties as { failure_code: { enum: unknown[] } }
|
||||
failure.failure_code.enum = failure.failure_code.enum.filter((code) => code !== "processing_exhausted")
|
||||
const withoutSkippedPages = withoutListedNull(historical)
|
||||
const properties = withoutSkippedPages.validator.$jsonSchema.properties as Record<string, unknown>
|
||||
expect(JSON.stringify(properties)).toContain("skipped_pages")
|
||||
delete properties.skipped_pages
|
||||
expect(JSON.stringify(created.validator)).toBe(JSON.stringify(withoutSkippedPages.validator))
|
||||
// The other collection and every index are exactly the generated ones.
|
||||
const rest = (commands: MongoCommands) =>
|
||||
JSON.stringify(
|
||||
commands.filter(
|
||||
(command) => !("createCollection" in command && command.createCollection === "documents_document"),
|
||||
),
|
||||
)
|
||||
expect(rest(first)).toBe(rest(current))
|
||||
|
||||
const second = mongoMigration("0002_skipped_pages.json")
|
||||
expect(second).toEqual([{ collMod: "documents_document", validator: withoutListedNull(historical.validator) }])
|
||||
// The third lets a nullable failure code be null, which the validators before it refused.
|
||||
expect(mongoMigration("0003_nullable_failure_code.json")).toEqual([
|
||||
{ collMod: "documents_document", validator: historical.validator },
|
||||
])
|
||||
// The latest cumulative validator, rather than every historical version, matches today's model.
|
||||
expect(mongoMigration("0004_processing_exhausted.json")).toEqual([
|
||||
{ collMod: "documents_document", validator: createDocument.validator },
|
||||
])
|
||||
})
|
||||
})
|
||||
// kit:documents:end
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A business module is its service: every procedure is a call to it, so the module runs only on the profiles
|
||||
// its business requirement names, and its manifest says so in `profiles`, which is what the Convex generator
|
||||
// and the profile tests read (template lesson core-17). Without it, CRM was projected into the Convex backend
|
||||
// once an app selected it. A native requirement (OCR, AI) is different: the module degrades without it, and
|
||||
// `documents` deploys to Convex on purpose.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { ModuleManifest } from "@conusai-kit/core"
|
||||
import { expect, it } from "vitest"
|
||||
|
||||
const modulesDir = join(import.meta.dirname, "..", "modules")
|
||||
const moduleIds = readdirSync(modulesDir).filter((name) => existsSync(join(modulesDir, name, "index.ts")))
|
||||
|
||||
const isManifest = (value: unknown): value is ModuleManifest =>
|
||||
typeof value === "object" && value !== null && "id" in value && "procedures" in value
|
||||
|
||||
it("a business module claims no profile its service does not run on", async () => {
|
||||
const problems: string[] = []
|
||||
for (const id of moduleIds) {
|
||||
const exports = (await import(join(modulesDir, id, "index.ts"))) as Record<string, unknown>
|
||||
for (const manifest of Object.values(exports).filter(isManifest)) {
|
||||
for (const requirement of manifest.serviceRequirements ?? []) {
|
||||
if (requirement.transport !== "business" || requirement.profiles === undefined) continue
|
||||
const supported = new Set<string>(requirement.profiles)
|
||||
const claimed = manifest.profiles ?? ["every profile"]
|
||||
const extra = claimed.filter((profile) => !supported.has(profile))
|
||||
if (extra.length > 0) {
|
||||
problems.push(
|
||||
`${manifest.id}: claims ${extra.join(", ")}; ${requirement.service} runs on ${requirement.profiles.join(", ")}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(problems).toEqual([])
|
||||
}, 120_000)
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Module cron comes from the manifests (TM-R3): a module declares `schedules` beside its `jobs`, and
|
||||
// `apps/worker/src/schedules.ts` names no module, so adding one edits nothing under apps/.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { appModules } from "@app/modules"
|
||||
import { buildRegistry, defineModule } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { appSchedules } from "../apps/worker/src/schedules.js"
|
||||
|
||||
describe("the worker's module cron", () => {
|
||||
it("is every schedule the manifests declare, each with its module's id", () => {
|
||||
const schedules = appSchedules({})
|
||||
expect(schedules.map((schedule) => schedule.module)).toEqual(expect.arrayContaining(["files", "account"]))
|
||||
const registry = buildRegistry([...appModules])
|
||||
for (const schedule of schedules) expect(registry.jobs[schedule.job]?.module).toBe(schedule.module)
|
||||
})
|
||||
|
||||
it("names no module in the scaffold's file", () => {
|
||||
const source = readFileSync(new URL("../apps/worker/src/schedules.ts", import.meta.url), "utf8")
|
||||
expect(source).not.toMatch(/@app\/modules\/|_SCHEDULES|defineSchedules/)
|
||||
})
|
||||
|
||||
it("refuses a schedule of a job the module does not declare, at boot", () => {
|
||||
const module = defineModule({
|
||||
id: "reports",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
jobs: { "reports.weekly": { payload: z.null(), handler: async () => undefined } },
|
||||
schedules: [{ job: "reports.nightly", cron: "0 2 * * *", scope: null }],
|
||||
})
|
||||
expect(() => buildRegistry([module])).toThrow(/reports\.nightly/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The worker admits a job that leaves the process only for the person it acts for (apps/worker/src/
|
||||
// native-admission.ts): the job's intent names the permissions that person must still hold. A module
|
||||
// job that declares a platform operation and has no intent is refused every time it runs, so every
|
||||
// such job has one.
|
||||
|
||||
import * as documents from "@app/modules/documents" // kit:documents
|
||||
import * as knowledge from "@app/modules/knowledge" // kit:knowledge
|
||||
import type { ModuleManifest } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { JOB_INTENT_PERMISSIONS } from "../apps/worker/src/native-admission.js"
|
||||
|
||||
const manifests: ModuleManifest[] = [
|
||||
documents.documentsModule, // kit:documents
|
||||
knowledge.knowledgeModule, // kit:knowledge
|
||||
]
|
||||
|
||||
describe("the worker's native job admission", () => {
|
||||
it("has an intent for every module job that declares a platform operation", () => {
|
||||
for (const manifest of manifests) {
|
||||
const jobs = Object.keys(manifest.jobs ?? {})
|
||||
const declared = (manifest.serviceRequirements ?? [])
|
||||
.filter((requirement) => requirement.transport === "platform")
|
||||
.flatMap((requirement) => Object.keys(requirement.operations))
|
||||
.filter((operation) => jobs.includes(operation))
|
||||
for (const job of declared) expect(JOB_INTENT_PERMISSIONS[job]?.length, job).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
// kit:knowledge:begin
|
||||
it("admits the automatic PDF intake only while its administrator may still manage knowledge", () => {
|
||||
expect(JOB_INTENT_PERMISSIONS[knowledge.AUTO_INDEX_JOB]).toEqual(["knowledge.knowledge.manage"])
|
||||
})
|
||||
// kit:knowledge:end
|
||||
})
|
||||
@@ -0,0 +1,366 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The notes module through the kernel's caller, the files module, the outbox relay's behaviour and
|
||||
// the worker's job runner: what is proven is the wiring and not a unit. The three things its plan item
|
||||
// asks for are here by name -- an upload records one usage event and one audit event, the flag
|
||||
// switches attachments off, and a tenant's notes and files are its own.
|
||||
|
||||
import { MAX_ATTACHMENTS, NOTES_CREATED_METER, type NotesFlags, type NoteView } from "@app/modules/notes"
|
||||
import { AppError, Container } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { FILES_AUDIT, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
|
||||
import { NOTIFICATIONS_REPOS } from "@conusai-kit/notifications"
|
||||
import { uploadViaProcedures } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { bytesOf, makeNotesApp, type NotesApp, type SeededProject } from "./support/notes-app.js"
|
||||
|
||||
interface Detail {
|
||||
note: NoteView
|
||||
attachments: Array<{ attachmentId: string; fileId: string; filename: string; contentType: string; size: number }>
|
||||
attachmentsEnabled: boolean
|
||||
}
|
||||
|
||||
async function fresh(flags?: NotesFlags): Promise<{ app: NotesApp; project: SeededProject }> {
|
||||
const app = makeNotesApp(flags ? { flags } : {})
|
||||
return { app, project: await app.seedProject("acme", "owner@acme.test") }
|
||||
}
|
||||
|
||||
const run = (project: SeededProject, procedure: string, input: Record<string, unknown> = {}) =>
|
||||
project.call.callByName(procedure, { ...project.slugs, ...input })
|
||||
|
||||
const create = async (project: SeededProject, title = "Kick-off", body = "Agenda") =>
|
||||
(await run(project, "notes.note.create", { title, body })) as NoteView
|
||||
|
||||
const detail = async (project: SeededProject, noteId: string) =>
|
||||
(await run(project, "notes.note.get", { noteId })) as Detail
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return error
|
||||
throw error
|
||||
}
|
||||
throw new Error("expected a refusal")
|
||||
}
|
||||
|
||||
describe("notes", () => {
|
||||
it("creates, lists, reads, edits and deletes a note", async () => {
|
||||
const { project } = await fresh()
|
||||
const note = await create(project)
|
||||
expect(note).toMatchObject({ title: "Kick-off", body: "Agenda", version: 1, attachmentCount: 0 })
|
||||
|
||||
const listed = (await run(project, "notes.note.list")) as { notes: NoteView[] }
|
||||
expect(listed.notes.map((entry) => entry.noteId)).toEqual([note.noteId])
|
||||
|
||||
const edited = (await run(project, "notes.note.update", {
|
||||
noteId: note.noteId,
|
||||
title: "Kick-off call",
|
||||
body: "Agenda and owners",
|
||||
expectedVersion: note.version,
|
||||
})) as NoteView
|
||||
expect(edited).toMatchObject({ title: "Kick-off call", version: 2 })
|
||||
expect((await detail(project, note.noteId)).note.body).toBe("Agenda and owners")
|
||||
|
||||
await run(project, "notes.note.delete", { noteId: note.noteId })
|
||||
expect(((await run(project, "notes.note.list")) as { notes: NoteView[] }).notes).toEqual([])
|
||||
expect((await refusal(run(project, "notes.note.get", { noteId: note.noteId }))).status).toBe(404)
|
||||
})
|
||||
|
||||
it("refuses a second editor's stale save with 409, so nothing is overwritten silently", async () => {
|
||||
const { project } = await fresh()
|
||||
const note = await create(project)
|
||||
await run(project, "notes.note.update", { noteId: note.noteId, title: "A", body: "", expectedVersion: 1 })
|
||||
const stale = await refusal(
|
||||
run(project, "notes.note.update", { noteId: note.noteId, title: "B", body: "", expectedVersion: 1 }),
|
||||
)
|
||||
expect(stale.status).toBe(409)
|
||||
expect((await detail(project, note.noteId)).note.title).toBe("A")
|
||||
})
|
||||
|
||||
it("lets a viewer read and nothing else, and answers a write as the kernel answers a stranger: 404", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
|
||||
const asViewer = (procedure: string, input: Record<string, unknown>) =>
|
||||
viewer.call.callByName(procedure, { ...project.slugs, ...input })
|
||||
expect(((await asViewer("notes.note.list", {})) as { notes: unknown[] }).notes).toHaveLength(1)
|
||||
expect((await refusal(asViewer("notes.note.create", { title: "x", body: "" }))).status).toBe(404)
|
||||
expect(
|
||||
(await refusal(asViewer("notes.note.update", { noteId: note.noteId, title: "x", body: "", expectedVersion: 1 })))
|
||||
.status,
|
||||
).toBe(404)
|
||||
expect((await refusal(asViewer("notes.note.delete", { noteId: note.noteId }))).status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe("what a note records, exactly once", () => {
|
||||
it("writes one audit event and one usage fact per note created, and none of its text", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project, "Secret plan", "Body text")
|
||||
expect(app.audit.events).toEqual([])
|
||||
await app.deliver()
|
||||
expect(app.audit.events.map((event) => event.action)).toEqual(["notes.note_created"])
|
||||
expect(app.audit.events[0]).toMatchObject({
|
||||
targetId: `${project.orgId}/${note.noteId}`,
|
||||
actorId: project.owner.userId,
|
||||
})
|
||||
expect(JSON.stringify(app.audit.events)).not.toContain("Secret plan")
|
||||
expect(JSON.stringify(app.audit.events)).not.toContain("Body text")
|
||||
expect(app.usage.facts).toEqual([
|
||||
{
|
||||
meter: NOTES_CREATED_METER,
|
||||
quantity: 1,
|
||||
customer: project.orgId,
|
||||
idempotencyKey: `${NOTES_CREATED_METER}.${note.noteId}`,
|
||||
},
|
||||
])
|
||||
// The record is published: a second pass of the relay delivers nothing more.
|
||||
expect(await app.deliver()).toBe(0)
|
||||
})
|
||||
|
||||
it("gives a redelivered record the same audit event id and usage key, so the platform sees one", async () => {
|
||||
const { app, project } = await fresh()
|
||||
await create(project)
|
||||
const record = (await app.data.outbox.pending(10))[0]
|
||||
expect(record).toBeDefined()
|
||||
const subscriber = app.registry.events["notes.NoteCreated"]?.subscribers[0]
|
||||
const container = new Container()
|
||||
const relayScope = { level: "org" as const, actor: systemActor("app.relay"), orgId: record?.orgId ?? "" }
|
||||
container.value(FILES_SCOPE, relayScope)
|
||||
// What the worker binds for every delivery: the relay's own transaction, which the notes module's
|
||||
// notification is written through (adapters/notifications.ts).
|
||||
container.value(NOTIFICATIONS_REPOS, app.data.forScope(relayScope))
|
||||
container.value(FILES_AUDIT, app.audit)
|
||||
container.value(FILES_USAGE, app.usage)
|
||||
const deliverOnce = () =>
|
||||
subscriber?.handler(
|
||||
{ name: "notes.NoteCreated", payload: record?.payload },
|
||||
{ container, correlationId: record?.id ?? "" },
|
||||
)
|
||||
await deliverOnce()
|
||||
await deliverOnce()
|
||||
expect(app.audit.events).toHaveLength(2)
|
||||
expect(app.audit.events[0]?.eventId).toBeTruthy()
|
||||
expect(app.audit.events[0]?.eventId).toBe(app.audit.events[1]?.eventId)
|
||||
expect(app.usage.facts[0]?.idempotencyKey).toBe(app.usage.facts[1]?.idempotencyKey)
|
||||
})
|
||||
|
||||
it("audits an edit once per version, so two edits are two events", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
await run(project, "notes.note.update", { noteId: note.noteId, title: "A", body: "", expectedVersion: 1 })
|
||||
await run(project, "notes.note.update", { noteId: note.noteId, title: "B", body: "", expectedVersion: 2 })
|
||||
await app.deliver()
|
||||
const edits = app.audit.events.filter((event) => event.action === "notes.note_updated")
|
||||
expect(edits).toHaveLength(2)
|
||||
expect(new Set(edits.map((event) => event.eventId)).size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("attachments", () => {
|
||||
it("records one usage event and one audit event for an upload, and one more audit event for the link", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
await app.deliver()
|
||||
app.audit.events.length = 0
|
||||
app.usage.facts.length = 0
|
||||
|
||||
const stored = await app.upload(project, "minutes.txt", "agreed on Friday")
|
||||
await app.deliver()
|
||||
expect(app.audit.events.map((event) => event.action)).toEqual(["files.file_uploaded"])
|
||||
expect(app.usage.facts).toEqual([
|
||||
expect.objectContaining({ meter: "files_bytes_stored", quantity: stored.size, customer: project.orgId }),
|
||||
])
|
||||
|
||||
const attached = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
|
||||
attachmentId: string
|
||||
filename: string
|
||||
}
|
||||
expect(attached.filename).toBe("minutes.txt")
|
||||
await app.deliver()
|
||||
expect(app.audit.events.map((event) => event.action)).toEqual(["files.file_uploaded", "notes.attachment_added"])
|
||||
expect(app.usage.facts).toHaveLength(1)
|
||||
|
||||
const seen = await detail(project, note.noteId)
|
||||
expect(seen.attachments).toEqual([
|
||||
expect.objectContaining({ fileId: stored.fileId, filename: "minutes.txt", contentType: "text/plain" }),
|
||||
])
|
||||
expect(seen.note.attachmentCount).toBe(1)
|
||||
})
|
||||
|
||||
it("links a file once: attaching it again answers with the first link and records nothing more", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
const stored = await app.upload(project)
|
||||
const first = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
|
||||
attachmentId: string
|
||||
}
|
||||
const second = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
|
||||
attachmentId: string
|
||||
}
|
||||
expect(second.attachmentId).toBe(first.attachmentId)
|
||||
await app.deliver()
|
||||
expect(app.audit.events.filter((event) => event.action === "notes.attachment_added")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("refuses a file that is not this project's, and one that does not exist, alike", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
const other = await app.seedProject("globex", "owner@globex.test")
|
||||
const foreign = await app.upload(other)
|
||||
expect(
|
||||
(await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: foreign.fileId }))).status,
|
||||
).toBe(404)
|
||||
expect(
|
||||
(
|
||||
await refusal(
|
||||
run(project, "notes.attachment.add", { noteId: note.noteId, fileId: "00000000-0000-0000-0000-000000000000" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(404)
|
||||
})
|
||||
|
||||
it(`allows at most ${MAX_ATTACHMENTS} attachments to one note`, async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
for (let at = 0; at < MAX_ATTACHMENTS; at += 1) {
|
||||
const stored = await app.upload(project, `f${at}.txt`, `body ${at}`)
|
||||
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
|
||||
}
|
||||
const extra = await app.upload(project, "extra.txt", "one too many")
|
||||
expect(
|
||||
(await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: extra.fileId }))).status,
|
||||
).toBe(409)
|
||||
})
|
||||
|
||||
it("refuses a file type a note does not carry, though the files module stores it", async () => {
|
||||
const { project } = await fresh()
|
||||
const note = await create(project)
|
||||
const sheet = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: "plan.csv",
|
||||
contentType: "text/csv",
|
||||
bytes: bytesOf("a,b"),
|
||||
})
|
||||
const refused = await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: sheet.fileId }))
|
||||
expect(refused.status).toBe(422)
|
||||
expect((await detail(project, note.noteId)).attachments).toEqual([])
|
||||
})
|
||||
|
||||
it("removes one link, audits it, and leaves the file in the project", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
const stored = await app.upload(project)
|
||||
const link = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
|
||||
attachmentId: string
|
||||
}
|
||||
await run(project, "notes.attachment.remove", { noteId: note.noteId, attachmentId: link.attachmentId })
|
||||
expect((await detail(project, note.noteId)).attachments).toEqual([])
|
||||
expect(((await run(project, "files.file.list")) as { files: unknown[] }).files).toHaveLength(1)
|
||||
await app.deliver()
|
||||
expect(app.audit.events.map((event) => event.action)).toContain("notes.attachment_removed")
|
||||
})
|
||||
|
||||
it("moves the note's version with each attachment, so its live readers see the change", async () => {
|
||||
// The live stream announces a change of `notes_note`; the attachment count and the attachment list
|
||||
// are read through the note, so linking and unlinking a file each change the note's row.
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
const stored = await app.upload(project)
|
||||
const link = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
|
||||
attachmentId: string
|
||||
}
|
||||
const linked = (await detail(project, note.noteId)).note
|
||||
expect(linked).toMatchObject({ attachmentCount: 1, version: note.version + 1 })
|
||||
await run(project, "notes.attachment.remove", { noteId: note.noteId, attachmentId: link.attachmentId })
|
||||
expect((await detail(project, note.noteId)).note).toMatchObject({
|
||||
attachmentCount: 0,
|
||||
version: linked.version + 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not remove another note's attachment through this note", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const first = await create(project, "First")
|
||||
const second = await create(project, "Second")
|
||||
const stored = await app.upload(project)
|
||||
const link = (await run(project, "notes.attachment.add", { noteId: first.noteId, fileId: stored.fileId })) as {
|
||||
attachmentId: string
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await refusal(
|
||||
run(project, "notes.attachment.remove", { noteId: second.noteId, attachmentId: link.attachmentId }),
|
||||
)
|
||||
).status,
|
||||
).toBe(404)
|
||||
expect((await detail(project, first.noteId)).attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("deleting a note removes its links, audits how many, and keeps the files", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
for (const name of ["a.txt", "b.txt"]) {
|
||||
const stored = await app.upload(project, name, name)
|
||||
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
|
||||
}
|
||||
await run(project, "notes.note.delete", { noteId: note.noteId })
|
||||
await app.deliver()
|
||||
const deleted = app.audit.events.find((event) => event.action === "notes.note_deleted")
|
||||
expect(deleted?.data).toMatchObject({ attachmentCount: 2 })
|
||||
expect(((await run(project, "files.file.list")) as { files: unknown[] }).files).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("drops a deleted file's links when the files module has deleted the object", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const note = await create(project)
|
||||
const stored = await app.upload(project)
|
||||
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
|
||||
await run(project, "files.file.delete", { fileId: stored.fileId })
|
||||
// The marking transaction, then the job that deletes the object and publishes `files.FileDeleted`.
|
||||
await app.deliver()
|
||||
await app.runJobs()
|
||||
await app.deliver()
|
||||
const seen = await detail(project, note.noteId)
|
||||
expect(seen.attachments).toEqual([])
|
||||
expect(seen.note.attachmentCount).toBe(0)
|
||||
// The unlink is a change of the note, so its live readers see the count drop.
|
||||
expect(seen.note.version).toBe(note.version + 2)
|
||||
})
|
||||
|
||||
it("is switched off by the notes.attachments flag: the link is refused and the page is told", async () => {
|
||||
const off: NotesFlags = { enabled: () => false }
|
||||
const { app, project } = await fresh(off)
|
||||
const note = await create(project)
|
||||
const stored = await app.upload(project)
|
||||
expect(
|
||||
(await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId }))).status,
|
||||
).toBe(403)
|
||||
expect((await detail(project, note.noteId)).attachmentsEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it("is on by default, with no flag service bound", async () => {
|
||||
const { project } = await fresh()
|
||||
const note = await create(project)
|
||||
expect((await detail(project, note.noteId)).attachmentsEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tenants", () => {
|
||||
it("never shows one organization's notes or attachments to another", async () => {
|
||||
const { app, project } = await fresh()
|
||||
const other = await app.seedProject("globex", "owner@globex.test")
|
||||
const note = await create(project)
|
||||
const stored = await app.upload(project)
|
||||
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
|
||||
|
||||
expect(((await run(other, "notes.note.list")) as { notes: unknown[] }).notes).toEqual([])
|
||||
expect((await refusal(run(other, "notes.note.get", { noteId: note.noteId }))).status).toBe(404)
|
||||
// The other tenant's own slugs with this tenant's ids: the same 404 as an id nobody has.
|
||||
expect(
|
||||
(await refusal(run(other, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId }))).status,
|
||||
).toBe(404)
|
||||
// This tenant's caller naming the other organization's slugs is refused outright.
|
||||
const crossed = await refusal(project.call.callByName("notes.note.list", { ...other.slugs }))
|
||||
expect([403, 404]).toContain(crossed.status)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A module event delivered through the transactional outbox, end to end: `notes.NoteCreated` is written by
|
||||
// the transaction that writes the note, the relay hands it to the notes module's subscriber in the relay's
|
||||
// own transaction (`NOTIFICATIONS_REPOS`), and the subscriber tells the project's other members through the
|
||||
// notifications kit. What is proven here: one inbox row per record and recipient, also when a delivery is
|
||||
// repeated (the record-derived key), never for the author, and none when the note's transaction rolls back.
|
||||
// The relay's own exactly-once claim (Postgres-family profiles) is proven in kit-jobs-pgboss, not here.
|
||||
|
||||
import { type NoteView, notesModule } from "@app/modules/notes"
|
||||
import type { EventContext, TenantScope } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
import { FILES_SCOPE } from "@conusai-kit/files"
|
||||
import { NOTIFICATIONS_REPOS } from "@conusai-kit/notifications"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { notesDeps } from "../modules/notes/adapters/procedures.js"
|
||||
import { createNote } from "../modules/notes/core/notes.js"
|
||||
import { makeNotesApp, type NotesApp, type SeededProject } from "./support/notes-app.js"
|
||||
|
||||
interface Inbox {
|
||||
items: Array<{ code: string; category: string; key: string; title: string | null; href: string | null }>
|
||||
}
|
||||
|
||||
/** A restricted project: its creator is its first member, and the second person is added as an editor. */
|
||||
async function restrictedProject(app: NotesApp) {
|
||||
const seeded = await app.seedProject("acme", "owner@acme.test")
|
||||
await seeded.call.callByName("tenancy.project.create", {
|
||||
org: seeded.slugs.org,
|
||||
workspace: seeded.slugs.workspace,
|
||||
slug: "launch",
|
||||
name: "Launch",
|
||||
moduleKey: "notes",
|
||||
visibility: "restricted",
|
||||
})
|
||||
const slugs = { ...seeded.slugs, project: "launch" }
|
||||
const member = await app.addMember(seeded, "member@acme.test", "member")
|
||||
await seeded.call.callByName("tenancy.project.set-member", {
|
||||
...slugs,
|
||||
userId: member.actor.userId,
|
||||
roleKey: "editor",
|
||||
})
|
||||
return { seeded, slugs, member }
|
||||
}
|
||||
|
||||
/** The notes module's entries of a person's inbox; the kit's own (an organization ready, an invitation accepted) are not. */
|
||||
const inboxOf = async (call: SeededProject["call"], org: string) => {
|
||||
const inbox = (await call.callByName("notifications.inbox.list", { org })) as Inbox
|
||||
return { items: inbox.items.filter((item) => item.key.startsWith("notes:")) }
|
||||
}
|
||||
|
||||
describe("notes.NoteCreated, through the outbox, to the project's other members", () => {
|
||||
it("gives the project's other member one inbox row with a link to the note, and the author none", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
const note = (await member.call.callByName("notes.note.create", {
|
||||
...slugs,
|
||||
title: "Kick-off",
|
||||
body: "",
|
||||
})) as NoteView
|
||||
|
||||
// Nothing until the relay runs: the event is a record of the note's transaction, not a side effect of the request.
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([])
|
||||
await app.deliver()
|
||||
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([
|
||||
expect.objectContaining({
|
||||
category: "notice",
|
||||
code: "custom",
|
||||
key: `notes:${note.noteId}:created`,
|
||||
title: "New note in Launch",
|
||||
href: `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes/${note.noteId}`,
|
||||
}),
|
||||
])
|
||||
expect((await inboxOf(member.call, slugs.org)).items).toEqual([])
|
||||
|
||||
// The record is published: a second pass delivers nothing, and the inbox still holds one row.
|
||||
expect(await app.deliver()).toBe(0)
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("is the same notification when the relay delivers a record again after a crash", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await member.call.callByName("notes.note.create", { ...slugs, title: "Kick-off", body: "" })
|
||||
const record = (await app.data.outbox.pending(10)).find((entry) => entry.name === "notes.NoteCreated")
|
||||
if (!record) throw new Error("expected the note's outbox record")
|
||||
const scope: TenantScope = { level: "org", actor: systemActor("app.relay"), orgId: record.orgId }
|
||||
const subscribers = app.registry.events["notes.NoteCreated"]?.subscribers ?? []
|
||||
const deliverOnce = async () => {
|
||||
for (const subscriber of subscribers) {
|
||||
const container = app.root.child()
|
||||
container.value(FILES_SCOPE, scope)
|
||||
container.value(NOTIFICATIONS_REPOS, app.data.forScope(scope))
|
||||
const ctx: EventContext = { container, correlationId: record.id }
|
||||
await subscriber.handler({ name: record.name, payload: record.payload }, ctx)
|
||||
}
|
||||
}
|
||||
await deliverOnce()
|
||||
await deliverOnce()
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("tells the other member, not the author, when the project's creator writes the note", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await seeded.call.callByName("notes.note.create", { ...slugs, title: "Plan", body: "" })
|
||||
await app.deliver()
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([])
|
||||
expect((await inboxOf(member.call, slugs.org)).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("writes the title in the recipient's own language", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await seeded.call.callByName("tenancy.me.update", { locale: "lt" })
|
||||
await member.call.callByName("notes.note.create", { ...slugs, title: "Planas", body: "" })
|
||||
await app.deliver()
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items.map((item) => item.title)).toEqual([
|
||||
"Naujas užrašas projekte „Launch“",
|
||||
])
|
||||
})
|
||||
|
||||
it("tells nobody in a project without named members, where the whole workspace would be noise", async () => {
|
||||
const app = makeNotesApp()
|
||||
const seeded = await app.seedProject("acme", "owner@acme.test")
|
||||
const member = await app.addMember(seeded, "member@acme.test", "member")
|
||||
await member.call.callByName("notes.note.create", { ...seeded.slugs, title: "Open", body: "" })
|
||||
await app.deliver()
|
||||
expect((await inboxOf(seeded.call, seeded.slugs.org)).items).toEqual([])
|
||||
})
|
||||
|
||||
it("publishes nothing when the note's transaction rolls back, so nobody is told about a note that is not there", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await member.call.callByName("notes.note.create", { ...slugs, title: "Kept", body: "" })
|
||||
const [kept] = await app.data.outbox.pending(500).then((all) => all.filter((r) => r.name === "notes.NoteCreated"))
|
||||
if (!kept) throw new Error("expected the note's outbox record")
|
||||
await app.deliver()
|
||||
const { noteId, orgId, workspaceId, projectId } = kept.payload as {
|
||||
noteId: string
|
||||
orgId: string
|
||||
workspaceId: string
|
||||
projectId: string
|
||||
}
|
||||
const scope = { level: "project" as const, actor: member.actor, orgId, workspaceId, projectId }
|
||||
// The commit fails after the use case wrote the row and the record: both go, or neither does.
|
||||
const failing: DataSource = {
|
||||
...app.data,
|
||||
unitOfWork: {
|
||||
run: (s, fn) =>
|
||||
app.data.unitOfWork.run(s, async (tx) => {
|
||||
await fn(tx)
|
||||
throw new Error("the commit failed")
|
||||
}),
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
createNote({ ...notesDeps(app.root), data: failing }, scope, { title: "Ghost", body: "" }),
|
||||
).rejects.toThrow("the commit failed")
|
||||
expect(await app.data.outbox.pending(500)).toEqual([])
|
||||
expect(await app.deliver()).toBe(0)
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items.map((item) => item.key)).toEqual([`notes:${noteId}:created`])
|
||||
const titles = ((await member.call.callByName("notes.note.list", slugs)) as { notes: NoteView[] }).notes
|
||||
expect(titles.map((n) => n.title)).toEqual(["Kept"])
|
||||
})
|
||||
|
||||
it("has one subscription per module and event, because the relay claims a delivery per record and module", () => {
|
||||
// A second subscription of one module to one event would be claimed by the first and never run.
|
||||
const app = makeNotesApp()
|
||||
for (const [name, entry] of Object.entries(app.registry.events)) {
|
||||
const modules = entry.subscribers.map((subscriber) => subscriber.module)
|
||||
expect(modules, name).toEqual([...new Set(modules)])
|
||||
}
|
||||
expect(notesModule.requires).toContain("notifications")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Observability of the server processes (spec §9.5, plan item AT4.2): errors, traces and logs
|
||||
// configured from the deployment contract, carrying the request's trace id, with secrets and tokens
|
||||
// taken out before anything leaves the process, and a plain no-op with a clear log line when the
|
||||
// deployment has no DSN. The SDK is the real @conusai-sdk/observe-node; only the network is a
|
||||
// recording `fetch`.
|
||||
|
||||
import {
|
||||
createRedactor,
|
||||
FILTERED,
|
||||
loadAppEnv,
|
||||
observabilityConfig,
|
||||
observabilityLine,
|
||||
secretsOf,
|
||||
startObservability,
|
||||
} from "@app/config"
|
||||
import { ids, withRequest } from "@conusai-sdk/observe-node"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"
|
||||
const TRACEPARENT = `00-${TRACE_ID}-00f067aa0ba902b7-01`
|
||||
const SECRET = "s3cr3t-session-key-0123456789abcdef"
|
||||
const TOKEN = "dt_0123456789abcdef0123456789abcdef"
|
||||
|
||||
const base: Record<string, string> = {
|
||||
NODE_ENV: "test",
|
||||
CONUSAI_FAKE_PLATFORM: "1",
|
||||
CONUSAI_API_URL: "http://127.0.0.1:4010/api",
|
||||
CONUSAI_API_TOKEN: TOKEN,
|
||||
CONUSAI_PROJECT_ID: "42",
|
||||
CONUSAI_ORG_ID: "9",
|
||||
POSTGRES_URL: "postgresql://app_app:hunter2hunter2@db:5432/app",
|
||||
POSTGRES_READER_URL: "postgresql://app_reader:another-long-pass@db:5432/app",
|
||||
AUTH_SESSION_SECRET: SECRET,
|
||||
}
|
||||
|
||||
const appEnv = (extra: Record<string, string> = {}, service: "web" | "worker" = "web") =>
|
||||
loadAppEnv(service, { ...base, ...extra })
|
||||
|
||||
describe("the observability settings", () => {
|
||||
it("reads release, environment and service name from the deployment contract", () => {
|
||||
const env = appEnv({
|
||||
CONUSAI_DSN: "https://pub@errors.example.test/42",
|
||||
CONUSAI_RELEASE: "2026.10.1",
|
||||
CONUSAI_ENVIRONMENT: "preview",
|
||||
OTEL_SERVICE_NAME: "acme-review",
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "https://otel.example.test",
|
||||
})
|
||||
expect(observabilityConfig("web", env.deployment, env.production)).toMatchObject({
|
||||
errors: true,
|
||||
otlp: true,
|
||||
release: "2026.10.1",
|
||||
environment: "preview",
|
||||
serviceName: "acme-review",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to OTEL_SERVICE_VERSION, then to `dev`, and names the run mode when the platform names no environment", () => {
|
||||
const platformRelease = appEnv({ OTEL_SERVICE_VERSION: "abc1234" })
|
||||
expect(observabilityConfig("worker", platformRelease.deployment, false)).toMatchObject({
|
||||
release: "abc1234",
|
||||
environment: "development",
|
||||
serviceName: "app-worker",
|
||||
})
|
||||
expect(observabilityConfig("web", appEnv().deployment, true)).toMatchObject({
|
||||
release: "dev",
|
||||
environment: "production",
|
||||
})
|
||||
})
|
||||
|
||||
it("says in one line what a process without a DSN will not send", () => {
|
||||
const env = appEnv()
|
||||
const line = observabilityLine(observabilityConfig("web", env.deployment, false))
|
||||
expect(line).toContain("errors off (CONUSAI_DSN is not set)")
|
||||
expect(line).toContain("logs and traces off (OTEL_EXPORTER_OTLP_ENDPOINT is not set)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("redaction", () => {
|
||||
const redact = createRedactor(secretsOf({ ...base }))
|
||||
|
||||
it.each([
|
||||
["MONGODB_URL", "mongodb://app:encoded%2Fpassword-long@db:27017/app", "encoded/password-long"],
|
||||
["CONVEX_ADMIN_KEY", "opaque-convex-administrator-value", "opaque-convex-administrator-value"],
|
||||
["CONVEX_SELF_HOSTED_ADMIN_KEY", "opaque-self-hosted-convex-admin-value", "opaque-self-hosted-convex-admin-value"],
|
||||
])("removes %s credentials from profile driver errors", (name, value, credential) => {
|
||||
const profileRedactor = createRedactor(secretsOf({ [name]: value }))
|
||||
expect(profileRedactor.text(`driver rejected ${credential}`)).toBe(`driver rejected ${FILTERED}`)
|
||||
expect(profileRedactor.deep({ exception: { value: `driver rejected ${credential}` } })).toEqual({
|
||||
exception: { value: `driver rejected ${FILTERED}` },
|
||||
})
|
||||
})
|
||||
|
||||
it("takes the process's own secret values out of any string, wherever they appear", () => {
|
||||
expect(redact.text(`session ${SECRET} and token ${TOKEN}`)).toBe(`session ${FILTERED} and token ${FILTERED}`)
|
||||
// A connection string's password goes on its own, too, as the driver quotes it in an error.
|
||||
expect(redact.text("password authentication failed: hunter2hunter2")).toBe(
|
||||
`password authentication failed: ${FILTERED}`,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
["a bearer credential", "sent Authorization: Bearer abcdef0123456789xyz", "abcdef0123456789xyz"],
|
||||
["a credential in a URL", "connect postgresql://user:p4ssw0rd-long@db:5432/app", "p4ssw0rd-long"],
|
||||
["a JSON web token", "id eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJl", "eyJzdWIi"],
|
||||
["an organization API key", "key cak_ab12cd34_0123456789abcdefghij", "0123456789abcdefghij"],
|
||||
["an invitation link", "GET /invitations/acme/Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA", "Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA"],
|
||||
[
|
||||
"an invitation link inside a return_to query",
|
||||
"GET /auth/sign-in?return_to=%2Finvitations%2Facme%2FZm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA",
|
||||
"Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA",
|
||||
],
|
||||
["an authorization code in a query", "GET /auth/callback?code=abc123&state=xyz789", "abc123"],
|
||||
["an e-mail address", "invited ada@example.test", "ada@example.test"],
|
||||
])("removes %s", (_name, text, secret) => {
|
||||
const cleaned = redact.text(text)
|
||||
expect(cleaned).not.toContain(secret)
|
||||
expect(cleaned.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("keeps what is not a secret: the organization in an invitation link, the path, the words", () => {
|
||||
expect(redact.text("GET /invitations/acme/Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA failed")).toContain("/invitations/acme/")
|
||||
expect(redact.text("procedure notes.note.create failed")).toBe("procedure notes.note.create failed")
|
||||
})
|
||||
|
||||
it("filters a value under a key that names a secret, at any depth, and strings inside arrays", () => {
|
||||
const cleaned = redact.deep({
|
||||
headers: { authorization: "Bearer x", "x-csrf-token": "t", accept: "text/html" },
|
||||
nested: { api_key: "k", session: { id: "s" }, list: [{ password: "p" }, "mail bob@example.test"] },
|
||||
count: 3,
|
||||
})
|
||||
expect(cleaned).toEqual({
|
||||
headers: { authorization: FILTERED, "x-csrf-token": FILTERED, accept: "text/html" },
|
||||
nested: { api_key: FILTERED, session: FILTERED, list: [{ password: FILTERED }, "mail [email]"] },
|
||||
count: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it("survives a value that points at itself", () => {
|
||||
const loop: Record<string, unknown> = { name: "loop" }
|
||||
loop.self = loop
|
||||
expect(redact.deep(loop)).toEqual({ name: "loop", self: FILTERED })
|
||||
})
|
||||
|
||||
it("scrubs an error event but leaves the ids that join it to its request", () => {
|
||||
const event = redact.event({
|
||||
message: `failed for ${SECRET}`,
|
||||
request: { url: "https://app.example.test/auth/callback?code=abc&state=def", headers: { cookie: "s=1" } },
|
||||
contexts: {
|
||||
trace: { trace_id: TRACE_ID, span_id: "00f067aa0ba902b7" },
|
||||
conusai: { session_id: "8f6a1c0e-2b8d-4a43-9d6e-5d1f6c2b7a90", request_id: "req-1" },
|
||||
runtime: { token: "x" },
|
||||
},
|
||||
exception: { values: [{ type: "Error", value: "bad Bearer abcdef0123456789xyz" }] },
|
||||
})
|
||||
expect(JSON.stringify(event)).not.toContain(SECRET)
|
||||
expect(event.request?.url).toBe(`https://app.example.test/auth/callback?code=${FILTERED}&state=${FILTERED}`)
|
||||
expect(event.request?.headers?.cookie).toBe(FILTERED)
|
||||
expect(event.exception?.values?.[0]?.value).toBe(`bad Bearer ${FILTERED}`)
|
||||
expect(event.contexts?.trace).toEqual({ trace_id: TRACE_ID, span_id: "00f067aa0ba902b7" })
|
||||
expect(event.contexts?.conusai).toMatchObject({ request_id: "req-1" })
|
||||
expect(event.contexts?.runtime).toEqual({ token: FILTERED })
|
||||
})
|
||||
})
|
||||
|
||||
describe("startObservability", () => {
|
||||
const handlers = new Map<string, unknown[]>()
|
||||
beforeEach(() => {
|
||||
// Record the process handlers the SDK installs instead of installing them in the test worker.
|
||||
vi.spyOn(process, "on").mockImplementation(((event: string, fn: unknown) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), fn])
|
||||
return process
|
||||
}) as never)
|
||||
vi.spyOn(process, "off").mockImplementation((() => process) as never)
|
||||
})
|
||||
afterEach(() => {
|
||||
handlers.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("is a no-op with one clear log line when the deployment has no DSN", async () => {
|
||||
const lines: string[] = []
|
||||
const observe = startObservability(appEnv(), { env: base, out: (line) => lines.push(line) })
|
||||
expect(observe.errors).toBeUndefined()
|
||||
expect(observe.otlp).toBeUndefined()
|
||||
observe.captureException(new Error("nobody listens"))
|
||||
await observe.shutdown()
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(JSON.parse(lines[0] as string)).toMatchObject({
|
||||
level: "info",
|
||||
service: "app-web",
|
||||
message: expect.stringContaining("errors off (CONUSAI_DSN is not set)"),
|
||||
})
|
||||
expect(handlers.size).toBe(0)
|
||||
})
|
||||
|
||||
it("sends an error with the request's trace and request ids, and no secret", async () => {
|
||||
const sent: Array<{ url: string; body: string }> = []
|
||||
const recording = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const body = init?.body
|
||||
const text = typeof body === "string" ? body : Buffer.from(body as Uint8Array).toString("utf8")
|
||||
sent.push({ url: String(url), body: text })
|
||||
return new Response("{}", { status: 200 })
|
||||
}) as typeof fetch
|
||||
const observe = startObservability(
|
||||
appEnv({ CONUSAI_DSN: "https://pub123@errors.example.test/42", CONUSAI_RELEASE: "r-1" }),
|
||||
{ env: base, out: () => undefined, fetch: recording },
|
||||
)
|
||||
expect(observe.errors).toBeDefined()
|
||||
|
||||
withRequest(new Headers({ traceparent: TRACEPARENT, "x-request-id": "req-77" }), () => {
|
||||
observe.captureException(new Error(`db down at postgresql://app_app:hunter2hunter2@db:5432/app with ${TOKEN}`), {
|
||||
procedure: "notes.note.create",
|
||||
})
|
||||
})
|
||||
await observe.errors?.flush(2_000)
|
||||
|
||||
const [request] = sent
|
||||
expect(request?.url).toContain("https://errors.example.test/api/42/envelope/")
|
||||
expect(request?.body).toContain(TRACE_ID)
|
||||
expect(request?.body).toContain("req-77")
|
||||
expect(request?.body).toContain('"release":"r-1"')
|
||||
expect(request?.body).not.toContain("hunter2hunter2")
|
||||
expect(request?.body).not.toContain(TOKEN)
|
||||
expect(request?.body).toContain(FILTERED)
|
||||
await observe.errors?.close()
|
||||
})
|
||||
|
||||
it("sends a log line over OTLP with the request's trace id, redacted, and prints it as a JSON line", async () => {
|
||||
const posts: Array<{ url: string; body: string }> = []
|
||||
const lines: string[] = []
|
||||
const recording = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
posts.push({ url: String(url), body: String(init?.body) })
|
||||
return new Response("{}", { status: 200 })
|
||||
}) as typeof fetch
|
||||
const observe = startObservability(
|
||||
appEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4010/otel", OTEL_SERVICE_NAME: "acme-review" }),
|
||||
{ env: base, out: (line) => lines.push(line), fetch: recording },
|
||||
)
|
||||
expect(observe.otlp?.enabled).toBe(true)
|
||||
|
||||
withRequest(new Headers({ traceparent: TRACEPARENT, "x-request-id": "req-78" }), () => {
|
||||
expect(ids().trace_id).toBe(TRACE_ID)
|
||||
observe.log.warn(`token ${TOKEN} refused`, { "conusai.org_slug": "acme", password: "p" })
|
||||
})
|
||||
await observe.otlp?.flush(2_000)
|
||||
|
||||
const logs = posts.filter((post) => post.url.endsWith("/otel/v1/logs"))
|
||||
expect(logs.length).toBeGreaterThan(0)
|
||||
const body = logs.map((post) => post.body).join("\n")
|
||||
expect(body).toContain(TRACE_ID)
|
||||
expect(body).toContain("acme-review")
|
||||
expect(body).not.toContain(TOKEN)
|
||||
expect(body).not.toContain('"p"')
|
||||
|
||||
const printed = lines
|
||||
.map((line) => JSON.parse(line) as Record<string, string>)
|
||||
.find((line) => line.level === "warn")
|
||||
expect(printed).toMatchObject({ trace_id: TRACE_ID, request_id: "req-78", "conusai.org_slug": "acme" })
|
||||
expect(JSON.stringify(printed)).not.toContain(TOKEN)
|
||||
expect(printed?.password).toBe(FILTERED)
|
||||
await observe.shutdown()
|
||||
})
|
||||
|
||||
it("runs a function as a span under the request's trace and returns what it returns", async () => {
|
||||
const posts: string[] = []
|
||||
const recording = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
if (String(url).endsWith("/otel/v1/traces")) posts.push(String(init?.body))
|
||||
return new Response("{}", { status: 200 })
|
||||
}) as typeof fetch
|
||||
const observe = startObservability(appEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4010/otel" }), {
|
||||
env: base,
|
||||
out: () => undefined,
|
||||
fetch: recording,
|
||||
})
|
||||
const answer = withRequest(new Headers({ traceparent: TRACEPARENT }), () =>
|
||||
observe.span("procedure notes.note.create", { "conusai.org_slug": "acme" }, () => 42),
|
||||
)
|
||||
expect(answer).toBe(42)
|
||||
await observe.otlp?.flush(2_000)
|
||||
const body = posts.join("\n")
|
||||
expect(body).toContain("procedure notes.note.create")
|
||||
expect(body).toContain(TRACE_ID)
|
||||
expect(body).toContain("conusai.org_slug")
|
||||
await observe.shutdown()
|
||||
})
|
||||
|
||||
it("runs a function as itself when traces are off", () => {
|
||||
const observe = startObservability(appEnv(), { env: base, out: () => undefined })
|
||||
expect(observe.span("anything", {}, () => "plain")).toBe("plain")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The browser half of observability (plan item AT4.2): `src/client.tsx` starts
|
||||
// @conusai-sdk/observe only when the server wrote the settings into the document, with the options this
|
||||
// app chose, and an error boundary reports through the same instance. The SDK is the real one with
|
||||
// the recording transport of @conusai-sdk/observe-testing, so what is asserted is what would be sent.
|
||||
|
||||
import { createTestObserve } from "@conusai-sdk/observe-testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
browserObserveOptions,
|
||||
observeMeta,
|
||||
readBrowserSettings,
|
||||
reportClientError,
|
||||
startBrowserObserve,
|
||||
} from "../apps/web/src/observe/browser.js"
|
||||
|
||||
/** A document with the meta element the server writes, as far as the module reads one. */
|
||||
const documentWith = (attributes: Record<string, string> | undefined) => ({
|
||||
querySelector: (selector: string) =>
|
||||
attributes && selector === 'meta[name="app-observe"]'
|
||||
? { getAttribute: (name: string) => attributes[name] ?? null }
|
||||
: null,
|
||||
})
|
||||
|
||||
describe("the settings the server writes into the document", () => {
|
||||
it("round-trips through the meta element", () => {
|
||||
const settings = { release: "2026.10.1", environment: "preview" }
|
||||
expect(readBrowserSettings(documentWith(observeMeta(settings)) as never)).toEqual(settings)
|
||||
})
|
||||
|
||||
it("reads nothing from a document without the element, or with it off", () => {
|
||||
expect(readBrowserSettings(documentWith(undefined) as never)).toBeUndefined()
|
||||
expect(readBrowserSettings(documentWith({ content: "off" }) as never)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("names neither ConusAI nor a build-time variable: the element is the app's own", () => {
|
||||
expect(JSON.stringify(observeMeta({ release: "r", environment: "e" })).toLowerCase()).not.toContain("conusai")
|
||||
})
|
||||
})
|
||||
|
||||
describe("starting the browser SDK", () => {
|
||||
it("starts nothing when the deployment has no observability", () => {
|
||||
const start = vi.fn()
|
||||
expect(startBrowserObserve(documentWith(undefined) as never, start)).toBeUndefined()
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("starts opt-in analytics and traces, with no replay before its separate consent", () => {
|
||||
const start = vi.fn((options) => createTestObserve({ ...options, modules: { errors: false } }))
|
||||
startBrowserObserve(documentWith(observeMeta({ release: "r-9", environment: "production" })) as never, start)
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ release: "r-9", environment: "production", consent: "opt-in", respectGPC: true }),
|
||||
)
|
||||
const options = start.mock.calls[0]?.[0]
|
||||
expect(options?.modules).toEqual({ traces: true, analytics: true })
|
||||
expect(options?.modules).not.toHaveProperty("replay")
|
||||
expect(browserObserveOptions({ release: "r-9", environment: "production" })).toEqual(options)
|
||||
})
|
||||
|
||||
it("sends an error a boundary caught, through the instance the page started", () => {
|
||||
const instance = createTestObserve({ modules: { errors: false } })
|
||||
startBrowserObserve(documentWith(observeMeta({ release: "r", environment: "e" })) as never, () => instance)
|
||||
reportClientError(new Error("the page threw"))
|
||||
expect(instance.mock.kinds()).toContain("error")
|
||||
const [item] = instance.mock.items()
|
||||
expect(JSON.stringify(item)).toContain("the page threw")
|
||||
})
|
||||
})
|
||||
|
||||
describe("a recorded edge grant without a current choice of this app", () => {
|
||||
const withCookie = (cookie: string) => ({
|
||||
...documentWith(observeMeta({ release: "r", environment: "e" })),
|
||||
cookie,
|
||||
})
|
||||
const olderNotice = `app_consent=${encodeURIComponent(
|
||||
JSON.stringify({ analytics: true, replay: false, at: new Date().toISOString(), notice: "2025-01" }),
|
||||
)}`
|
||||
|
||||
it.each([
|
||||
["no choice", "_conusai_consent=granted"],
|
||||
["a malformed choice", "app_consent=%7Bnope; _conusai_consent=granted"],
|
||||
["a choice under an older notice", `${olderNotice}; _conusai_consent=granted`],
|
||||
])("is withdrawn on %s, so the edge stops setting its cookies", (_, cookie) => {
|
||||
const instance = createTestObserve({ consent: "opt-in", modules: { errors: false } })
|
||||
startBrowserObserve(withCookie(cookie) as never, () => instance, {})
|
||||
expect(instance.consent.state()).toBe("denied")
|
||||
})
|
||||
|
||||
it("writes nothing for a visitor the edge holds no grant for", () => {
|
||||
const instance = createTestObserve({ consent: "opt-in", modules: { errors: false } })
|
||||
startBrowserObserve(withCookie("") as never, () => instance, {})
|
||||
expect(instance.consent.state()).toBe("pending")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The OpenAPI 3.1 document and its drift check (AT2.1 done_when).
|
||||
//
|
||||
// `openapi.json` in the repository root is this function's output. Regenerate it with
|
||||
// `pnpm openapi` after changing a procedure's schemas; the diff is then part of the review, which
|
||||
// is the point -- an input schema that changed is an API change somebody's client will feel.
|
||||
|
||||
import { appBrand } from "@app/config"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { openapiDocument, openapiJson } from "@conusai-kit/host/http"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules, moduleSelected } from "../modules/index.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
// The title is the app brand's name, as the mount serves it (router.ts): renaming the app in
|
||||
// config/brand.ts is a customer's first edit and must not turn the drift check red (AT2.7).
|
||||
const info = { title: `${appBrand.name} API`, version: registry.kit }
|
||||
|
||||
describe("the OpenAPI document", () => {
|
||||
it("matches the committed openapi.json", async () => {
|
||||
await expect(openapiJson(registry, info)).toMatchFileSnapshot("../openapi.json")
|
||||
})
|
||||
|
||||
it("is OpenAPI 3.1 with one path per procedure", () => {
|
||||
const document = openapiDocument(registry, info) as { openapi: string; paths: Record<string, unknown> }
|
||||
expect(document.openapi).toBe("3.1.0")
|
||||
expect(Object.keys(document.paths).length).toBe(registry.procedures.size)
|
||||
expect(document.paths["/api/v1/tenancy.org.create"]).toBeTruthy()
|
||||
})
|
||||
|
||||
it("describes every refusal as problem+json", () => {
|
||||
const document = openapiDocument(registry, info) as {
|
||||
paths: Record<string, { post: { responses: Record<string, { content?: Record<string, unknown> }> } }>
|
||||
}
|
||||
const operation = document.paths["/api/v1/tenancy.member.invite"]?.post
|
||||
expect(Object.keys(operation?.responses ?? {})).toEqual(expect.arrayContaining(["200", "401", "404", "422", "429"]))
|
||||
expect(operation?.responses["404"]?.content).toHaveProperty(["application/problem+json"])
|
||||
})
|
||||
|
||||
it("requires an Idempotency-Key exactly where the contract declares one", () => {
|
||||
type Operation = { parameters: { name: string; required?: boolean }[] }
|
||||
const document = openapiDocument(registry, info) as {
|
||||
paths: Record<string, { post?: Operation; put?: Operation }>
|
||||
}
|
||||
for (const [name, { def }] of registry.procedures) {
|
||||
const path = document.paths[`/api/v1/${name}`]
|
||||
const parameters = (path?.post ?? path?.put)?.parameters ?? []
|
||||
const header = parameters.find((parameter) => parameter.name === "Idempotency-Key")
|
||||
expect(Boolean(header), `${name} is idempotent=${def.idempotent}`).toBe(def.idempotent)
|
||||
}
|
||||
})
|
||||
|
||||
it("documents a procedure with a streamed body as a PUT: the bytes as the body, the rest of its input as the query", () => {
|
||||
const document = openapiDocument(registry, info) as {
|
||||
paths: Record<
|
||||
string,
|
||||
{
|
||||
post?: unknown
|
||||
put?: {
|
||||
parameters: { name: string; in: string; required: boolean }[]
|
||||
requestBody: { content: Record<string, { schema: unknown }> }
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
const streamed = [...registry.procedures].filter(([, { def }]) => def.stream !== undefined).map(([name]) => name)
|
||||
const expected = ["files.file.putPart"]
|
||||
if (moduleSelected("guides")) expected.push("guides.runtime.uploadFile")
|
||||
expect(streamed.sort()).toEqual(expected)
|
||||
const path = document.paths["/api/v1/files.file.putPart"]
|
||||
expect(path?.post).toBeUndefined()
|
||||
const query = path?.put?.parameters.filter((parameter) => parameter.in === "query")
|
||||
expect(query?.map((parameter) => parameter.name).sort()).toEqual(["fileId", "org", "part", "project", "workspace"])
|
||||
expect(query?.every((parameter) => parameter.required)).toBe(true)
|
||||
// The body is the one field that is not a parameter, and it is binary.
|
||||
expect(query?.map((parameter) => parameter.name)).not.toContain("body")
|
||||
expect(path?.put?.requestBody.content["application/octet-stream"]?.schema).toEqual({
|
||||
type: "string",
|
||||
format: "binary",
|
||||
})
|
||||
})
|
||||
|
||||
it("documents file downloads as authenticated scoped GET streams rather than JSON or signed URLs", () => {
|
||||
type DownloadOperation = {
|
||||
parameters: { name: string; in: string; required: boolean }[]
|
||||
responses: Record<string, { content?: Record<string, { schema?: unknown }> }>
|
||||
}
|
||||
const document = openapiDocument(registry, info) as {
|
||||
paths: Record<string, { get?: DownloadOperation; post?: unknown }>
|
||||
}
|
||||
const path = document.paths["/api/v1/files.file.download"]
|
||||
expect(path?.post).toBeUndefined()
|
||||
expect(path?.get?.parameters.map(({ name, in: location, required }) => [name, location, required])).toEqual([
|
||||
["org", "query", true],
|
||||
["workspace", "query", true],
|
||||
["project", "query", true],
|
||||
["fileId", "query", true],
|
||||
["offset", "query", false],
|
||||
["length", "query", false],
|
||||
["disposition", "query", false],
|
||||
])
|
||||
expect(path?.get?.responses["200"]?.content).toHaveProperty(
|
||||
["application/octet-stream", "schema", "format"],
|
||||
"binary",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The pages a person sees when there is no page (spec §10.4): the error boundary, the loading state, and
|
||||
// the shared shape of not found and forbidden. Rendered to markup, so what is asserted is what a screen
|
||||
// reader is given: one heading, a sentence, an alert for the failure, and the way out. The browser half
|
||||
// (a 404 and a 403 as statuses, axe on both) is e2e/shell.spec.ts.
|
||||
|
||||
import { createElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { BoundaryMessages, ErrorView } from "../apps/web/src/ui/boundary.js"
|
||||
import { LoadingPage } from "../apps/web/src/ui/loading-page.js"
|
||||
import { StateView } from "../apps/web/src/ui/state-view.js"
|
||||
|
||||
const english = {
|
||||
title: "Something went wrong",
|
||||
body: "The page could not be shown. Try again.",
|
||||
code: "Error code: {digest}",
|
||||
retry: "Try again",
|
||||
home: "Back to the start",
|
||||
}
|
||||
|
||||
const render = (error: Error & { digest?: string }, retry = vi.fn()): string =>
|
||||
renderToStaticMarkup(
|
||||
createElement(BoundaryMessages, { strings: english, children: createElement(ErrorView, { error, retry }) }),
|
||||
)
|
||||
|
||||
describe("the error boundary", () => {
|
||||
it("has one heading, the failure as an alert, a way to try again and a way out", () => {
|
||||
const html = render(new Error("hidden detail"))
|
||||
expect(html.match(/<h1/g)).toHaveLength(1)
|
||||
expect(html).toContain("<h1>Something went wrong</h1>")
|
||||
expect(html).toContain('role="alert">The page could not be shown. Try again.')
|
||||
expect(html).toContain("<button")
|
||||
expect(html).toContain("Try again</button>")
|
||||
expect(html).toContain('href="/"')
|
||||
expect(html).toContain("Back to the start</a>")
|
||||
})
|
||||
|
||||
it("never prints the error's own message, which can carry a tenant's data, and prints the digest an operator searches the log for", () => {
|
||||
const html = render(Object.assign(new Error("password=hunter2 for ada@example.test"), { digest: "3275165072" }))
|
||||
expect(html).not.toContain("hunter2")
|
||||
expect(html).not.toContain("ada@example.test")
|
||||
expect(html).toContain("Error code: 3275165072")
|
||||
})
|
||||
|
||||
it("prints no code when there is no digest", () => {
|
||||
expect(render(new Error("x"))).not.toContain("Error code")
|
||||
})
|
||||
|
||||
it("hands the error up to the boundary above when nothing gave it words", () => {
|
||||
expect(() => renderToStaticMarkup(createElement(ErrorView, { error: new Error("up"), retry: vi.fn() }))).toThrow(
|
||||
"up",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the loading and the empty states", () => {
|
||||
it("announce once, politely, and hide the spinner from a screen reader", () => {
|
||||
const html = renderToStaticMarkup(createElement(LoadingPage, { label: "Loading" }))
|
||||
expect(html).toContain('role="status"')
|
||||
expect(html).toContain('aria-hidden="true"')
|
||||
expect(html).toContain("Loading")
|
||||
expect(html).not.toContain("<h1")
|
||||
expect(html).not.toContain("<main")
|
||||
})
|
||||
|
||||
it("give a state page a landmark, a heading, its words and its actions, in that order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(StateView, {
|
||||
title: "Page not found",
|
||||
actions: createElement("a", { href: "/" }, "Back"),
|
||||
children: createElement("p", null, "Nothing here."),
|
||||
}),
|
||||
)
|
||||
expect(html).toContain('<main data-slot="app-state">')
|
||||
expect(html).toContain('class="kit-empty kit-page-state"')
|
||||
expect(html).toContain("<h1>Page not found</h1>")
|
||||
expect(html).toContain("Nothing here.")
|
||||
expect(html).toContain('<a href="/">Back</a>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { createElement, Suspense } from "react"
|
||||
import { renderToString } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
const loaders = vi.hoisted(() => ({ project: vi.fn(), workspace: vi.fn(), other: vi.fn(), broken: vi.fn() }))
|
||||
vi.mock("@app/modules/pages", () => ({
|
||||
modulePages: { notes: loaders.project, other: loaders.other, broken: loaders.broken },
|
||||
workspacePages: { notes: loaders.workspace },
|
||||
}))
|
||||
const { createPageViews, prepareInitialModuleViews, projectPageViews } = await import("../apps/web/src/ui/page-views")
|
||||
|
||||
describe("initial module view hydration", () => {
|
||||
it("lets the router mount after a failed warmup and preserves the error for its route", async () => {
|
||||
const failure = new Error("module chunk unavailable")
|
||||
loaders.broken.mockRejectedValue(failure)
|
||||
await expect(
|
||||
prepareInitialModuleViews([
|
||||
{ routeId: "/$org/$workspace/$project/$module/$", status: "success", loaderData: { module: "broken" } },
|
||||
]),
|
||||
).resolves.toBeUndefined()
|
||||
await expect(projectPageViews.prepare("broken")).rejects.toBe(failure)
|
||||
expect(loaders.broken).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("renders prepared server markup synchronously and loads no other module", async () => {
|
||||
const selected = vi.fn(async () => ({ View: () => createElement("a", { href: "/ready" }, "Ready") }))
|
||||
const other = vi.fn(async () => ({ View: () => createElement("div", {}, "Other") }))
|
||||
const views = createPageViews({ selected, other })
|
||||
await Promise.all([views.prepare("selected"), views.prepare("selected")])
|
||||
const html = renderToString(
|
||||
createElement(Suspense, { fallback: "loading fallback" }, createElement(views.viewOf("selected"))),
|
||||
)
|
||||
expect(html).toContain('<a href="/ready">Ready</a>')
|
||||
expect(html).not.toContain("loading fallback")
|
||||
expect(selected).toHaveBeenCalledTimes(1)
|
||||
expect(other).not.toHaveBeenCalled()
|
||||
expect(views.viewOf("selected")).toBe(views.viewOf("selected"))
|
||||
expect(views.has("toString")).toBe(false)
|
||||
expect(() => views.viewOf("unregistered")).toThrow("no page for module")
|
||||
})
|
||||
|
||||
it("prepares only successful matched project and workspace page data", async () => {
|
||||
loaders.project.mockResolvedValue({ View: () => createElement("div") })
|
||||
loaders.workspace.mockResolvedValue({ View: () => createElement("div") })
|
||||
await prepareInitialModuleViews([
|
||||
{ routeId: "/$org/$workspace/$project/$module/$", status: "success", loaderData: { module: "notes" } },
|
||||
{ routeId: "/$org/$workspace/modules/$module/$", status: "success", loaderData: { module: "notes" } },
|
||||
{ routeId: "/$org", status: "success", loaderData: { module: "other" } },
|
||||
{ routeId: "/$org/$workspace/$project/$module/$", status: "error", loaderData: { module: "other" } },
|
||||
{ routeId: "/$org/$workspace/modules/$module/$", status: "notFound", loaderData: { module: "notes" } },
|
||||
{ routeId: "/$org/$workspace/modules/$module/$", status: "redirected" },
|
||||
{ routeId: "/$org/$workspace/modules/$module/$", status: "success", loaderData: { module: "unknown" } },
|
||||
])
|
||||
expect(loaders.project).toHaveBeenCalledTimes(1)
|
||||
expect(loaders.workspace).toHaveBeenCalledTimes(1)
|
||||
expect(loaders.other).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's theme from the platform, read on the server and written as the page's stylesheet
|
||||
// (brand-kit BK12). The platform's answers are fakes here: the derivation is the kit's own, and every
|
||||
// test looks at what the app would write, not at how the kit fetches it.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import { deriveTheme, resolvedThemeStyles } from "@conusai-kit/brand"
|
||||
import { createPlatformThemeCache } from "@conusai-kit/brand/platform-theme"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
cloudOrganizationLogo,
|
||||
createPlatformThemeReader,
|
||||
ORGANIZATION_LOGO_PATH,
|
||||
organizationIdentity,
|
||||
type PlatformThemeDeployment,
|
||||
} from "../apps/web/src/server/platform-theme.js"
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
/** The platform's answer for the navy fixture: the kit's derivation as OKLCH role maps (BrandingResponse). */
|
||||
function branding(etag: string) {
|
||||
const styles = resolvedThemeStyles(deriveTheme({ primary: "#001f4d", accent: "#1f6feb" }))
|
||||
const roles = (mode: "light" | "dark") =>
|
||||
Object.fromEntries(Object.entries(styles[mode]).map(([name, value]) => [name.slice(2), value]))
|
||||
return {
|
||||
version: 1,
|
||||
seed: { primary: "#001f4d", accent: "#1f6feb" },
|
||||
light: roles("light"),
|
||||
dark: roles("dark"),
|
||||
logo_url: null,
|
||||
etag,
|
||||
}
|
||||
}
|
||||
|
||||
type Reply = { status: number; body?: unknown; etag?: string }
|
||||
|
||||
/** A fetch that answers from a script and records every request. */
|
||||
function platform(...replies: Reply[]) {
|
||||
const calls: Array<{ url: string; headers: Record<string, string> }> = []
|
||||
const fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), headers: { ...((init?.headers ?? {}) as Record<string, string>) } })
|
||||
const reply = replies.shift()
|
||||
if (!reply) throw new Error("no scripted reply left")
|
||||
if (reply.status === 304) return new Response(null, { status: 304, headers: { etag: reply.etag ?? "" } })
|
||||
return new Response(reply.body === undefined ? null : JSON.stringify(reply.body), {
|
||||
status: reply.status,
|
||||
headers: { "content-type": "application/json", ...(reply.etag ? { etag: reply.etag } : {}) },
|
||||
})
|
||||
}) as typeof globalThis.fetch
|
||||
return { fetch, calls }
|
||||
}
|
||||
|
||||
const deployment = (org: string | undefined): PlatformThemeDeployment => ({
|
||||
CONUSAI_API_URL: "https://platform.example.test/api",
|
||||
CONUSAI_API_TOKEN: "dt_test_fixture",
|
||||
...(org === undefined ? {} : { CONUSAI_ORG_SLUG: org }),
|
||||
})
|
||||
|
||||
describe("the organization's theme on the server", () => {
|
||||
it("writes the organization's roles as the page's stylesheet, dark on the root and light under .light", async () => {
|
||||
const { fetch, calls } = platform({ status: 200, body: branding('"etag-1"'), etag: '"etag-1"' })
|
||||
const css = await createPlatformThemeReader({ fetch })(deployment("acme"))
|
||||
expect(calls[0]?.url).toBe("https://platform.example.test/api/v1/orgs/acme/branding")
|
||||
expect(calls[0]?.headers.authorization).toBe("Bearer dt_test_fixture")
|
||||
expect(css.startsWith(":root{--background:oklch(")).toBe(true)
|
||||
expect(css).toContain(".light{--background:oklch(")
|
||||
expect(css).toContain("--primary:oklch(")
|
||||
})
|
||||
|
||||
it("with no organization slug the app asks nothing and writes nothing: the kit defaults stand", async () => {
|
||||
const { fetch, calls } = platform()
|
||||
expect(await createPlatformThemeReader({ fetch })(deployment(undefined))).toBe("")
|
||||
expect(await createPlatformThemeReader({ fetch })(deployment(" "))).toBe("")
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("a cleared cache reads the organization again, with no validator", async () => {
|
||||
const first = platform({ status: 200, body: branding('"etag-1"'), etag: '"etag-1"' })
|
||||
await createPlatformThemeReader({ fetch: first.fetch })(deployment("acme"))
|
||||
const second = platform({ status: 200, body: branding('"etag-2"'), etag: '"etag-2"' })
|
||||
await createPlatformThemeReader({ fetch: second.fetch })(deployment("acme"))
|
||||
expect(second.calls[0]?.headers["if-none-match"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("an unavailable platform (503) gives the kit defaults, an empty stylesheet", async () => {
|
||||
const { fetch } = platform({ status: 503, body: { title: "Unavailable" } })
|
||||
expect(await createPlatformThemeReader({ fetch })(deployment("acme"))).toBe("")
|
||||
})
|
||||
|
||||
it("a 304 writes the cached theme again with the cached ETag as the validator", async () => {
|
||||
const cache = createPlatformThemeCache()
|
||||
const first = platform({ status: 200, body: branding('"etag-1"'), etag: '"etag-1"' })
|
||||
const fresh = await createPlatformThemeReader({ fetch: first.fetch, cache })(deployment("acme"))
|
||||
const again = platform({ status: 304, etag: '"etag-1"' })
|
||||
const cached = await createPlatformThemeReader({ fetch: again.fetch, cache })(deployment("acme"))
|
||||
expect(again.calls[0]?.headers["if-none-match"]).toBe('"etag-1"')
|
||||
expect(cached).toBe(fresh)
|
||||
})
|
||||
|
||||
it("a changed organization is never written with another organization's theme", async () => {
|
||||
const cache = createPlatformThemeCache()
|
||||
const acme = platform({ status: 200, body: branding('"etag-acme"'), etag: '"etag-acme"' })
|
||||
const acmeCss = await createPlatformThemeReader({ fetch: acme.fetch, cache })(deployment("acme"))
|
||||
const other = platform({ status: 404, body: { title: "Organization Not Found" } })
|
||||
const otherCss = await createPlatformThemeReader({ fetch: other.fetch, cache })(deployment("other"))
|
||||
expect(other.calls[0]?.url).toContain("/v1/orgs/other/branding")
|
||||
expect(other.calls[0]?.headers["if-none-match"]).toBeUndefined()
|
||||
expect(otherCss).toBe("")
|
||||
expect(acmeCss).not.toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the organization's name and logo on Cloud", () => {
|
||||
const PNG = new Uint8Array(
|
||||
readFileSync(createRequire(import.meta.url).resolve("@conusai-kit/brand/logo/favicon.png")),
|
||||
)
|
||||
const on = { CONUSAI_API_URL: "https://platform.example.test/api", CONUSAI_API_TOKEN: "dt", CONUSAI_ORG_SLUG: "logo" }
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it("names the app after the organization and serves its logo from the app, with the token only to the platform", async () => {
|
||||
const seen: Array<{ url: string; authorization?: string }> = []
|
||||
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>
|
||||
seen.push({ url: String(url), ...(headers.authorization ? { authorization: headers.authorization } : {}) })
|
||||
if (String(url).endsWith("/branding")) {
|
||||
return Response.json(
|
||||
{ ...branding('"n"'), name: "Navy UAB", logo_url: "/blob/org/7/logo.png" },
|
||||
{ headers: { etag: '"n"' } },
|
||||
)
|
||||
}
|
||||
return new Response(PNG, { headers: { "content-type": "image/png" } })
|
||||
})
|
||||
expect(await organizationIdentity(on)).toEqual({ name: "Navy UAB", logo: ORGANIZATION_LOGO_PATH })
|
||||
const logo = await cloudOrganizationLogo(on)
|
||||
expect(logo?.type).toBe("image/png")
|
||||
expect(seen.at(-1)).toEqual({
|
||||
url: "https://platform.example.test/blob/org/7/logo.png",
|
||||
authorization: "Bearer dt",
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps the app's own when Cloud has none, and refuses what is not a logo", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string | URL) =>
|
||||
String(url).endsWith("/branding")
|
||||
? Response.json(
|
||||
{ ...branding('"x"'), logo_url: "https://cdn.example.test/x.png" },
|
||||
{ headers: { etag: '"x"' } },
|
||||
)
|
||||
: new Response("<html>", { headers: { "content-type": "image/png" } }),
|
||||
)
|
||||
const deployment = { ...on, CONUSAI_ORG_SLUG: "nologo" }
|
||||
expect(await organizationIdentity(deployment)).toEqual({ logo: ORGANIZATION_LOGO_PATH })
|
||||
expect(await cloudOrganizationLogo(deployment)).toBeNull()
|
||||
expect(await organizationIdentity({ ...on, CONUSAI_ORG_SLUG: "" })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("the app's code and its deployment contract", () => {
|
||||
it("names no hex colour and no colour function in the app's pages, layout, stylesheet or server code", () => {
|
||||
const files = listFiles(path.join(root, "apps/web/src"), /\.(?:tsx?|css)$/)
|
||||
expect(files.some((file) => file.endsWith("src/routes/__root.tsx"))).toBe(true)
|
||||
expect(files.some((file) => file.endsWith("src/ui/globals.css"))).toBe(true)
|
||||
for (const file of files) {
|
||||
const code = readFileSync(file, "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "")
|
||||
.replace(/(?<=\s)\/\/ .*$/gm, "")
|
||||
const relative = path.relative(root, file)
|
||||
expect(code, relative).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
|
||||
expect(code, relative).not.toMatch(/\b(?:rgba?|hsla?|hwb|lab|lch|oklch)\(/)
|
||||
}
|
||||
})
|
||||
|
||||
it("reads only the font, type scale and radius brand tokens in its stylesheet, never a colour token (BK3)", () => {
|
||||
const css = readFileSync(path.join(root, "apps/web/src/ui/globals.css"), "utf8")
|
||||
const reads = css.match(/var\(--brand-[a-z0-9-]+/g) ?? []
|
||||
expect(reads.length).toBeGreaterThan(0)
|
||||
for (const read of reads) expect(read, read).toMatch(/--brand-(?:font|radius|text|leading|app-font-size)-/)
|
||||
})
|
||||
|
||||
it("draws the product UI at the app's 16px root and its one type scale, not the site's 18px root", () => {
|
||||
const css = readFileSync(path.join(root, "apps/web/src/ui/globals.css"), "utf8")
|
||||
expect(css).toMatch(/html:root \{\s*font-size: var\(--brand-app-font-size-root\);/)
|
||||
expect(css).not.toContain("--brand-font-size-root")
|
||||
expect(css).not.toContain("--brand-font-display")
|
||||
})
|
||||
|
||||
it("asks the platform for branding:read, and only that new scope", () => {
|
||||
const contract = JSON.parse(readFileSync(path.join(root, "conusai.json"), "utf8")) as {
|
||||
token: { permissions: string[] }
|
||||
}
|
||||
expect(contract.token.permissions).toContain("branding:read")
|
||||
expect(contract.token.permissions).toEqual(expect.arrayContaining(["blob:read", "blob:write"]))
|
||||
})
|
||||
|
||||
it("lists the files it adds as the template's own", () => {
|
||||
const list = readFileSync(path.join(root, "TEMPLATE-FILES"), "utf8").split("\n")
|
||||
expect(list).toContain("apps/web/src/server/platform-theme.ts")
|
||||
expect(list).toContain("test/platform-theme.test.ts")
|
||||
})
|
||||
})
|
||||
|
||||
function listFiles(directory: string, pattern: RegExp): string[] {
|
||||
return readdirSync(directory).flatMap((name) => {
|
||||
const full = path.join(directory, name)
|
||||
return statSync(full).isDirectory() ? listFiles(full, pattern) : pattern.test(name) ? [full] : []
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The platform's services are really bound (plan item AT4.1, spec §9): the AI gateway, OCR, Blob, the
|
||||
// audit log and usage metering, built from the deployment contract by `platformClients`, bound to the
|
||||
// tokens modules resolve them by in BOTH composition roots (apps/web/src/server/ports.ts and
|
||||
// apps/worker/src/ports.ts), and each one reaching the platform over HTTP. The platform here is the
|
||||
// fake of @conusai-kit/testing; nothing between a token and its HTTP request is stubbed.
|
||||
|
||||
import { readFileSync } from "node:fs" // kit:documents
|
||||
import { loadAppEnv, PlatformEnvError, platformClients, probePlatform, publicUrlOf } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import { DOCUMENT_MODEL, DOCUMENT_OCR, MODEL_GATEWAY } from "@app/modules/documents" // kit:documents
|
||||
import { buildRegistry, Container } from "@conusai-kit/core"
|
||||
import { OBJECT_STORE } from "@conusai-kit/data"
|
||||
import { FILES_AUDIT, FILES_REGISTRY, FILES_USAGE } from "@conusai-kit/files"
|
||||
import { PROPOSALS_AUDIT, PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
|
||||
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { bindWebPorts } from "../apps/web/src/server/ports.js"
|
||||
import { bindModulePorts } from "../apps/worker/src/ports.js"
|
||||
|
||||
// kit:documents:begin
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL("../modules/documents/__tests__/fixtures/ocr-document.json", import.meta.url), "utf8"),
|
||||
) as Record<string, unknown>
|
||||
// kit:documents:end
|
||||
|
||||
type Platform = Awaited<ReturnType<typeof startFakePlatform>>
|
||||
|
||||
let platform: Platform
|
||||
beforeEach(async () => {
|
||||
platform = await startFakePlatform()
|
||||
})
|
||||
afterEach(async () => {
|
||||
await platform.close()
|
||||
})
|
||||
|
||||
const environment = (extra: Record<string, string> = {}): Record<string, string | undefined> => ({
|
||||
NODE_ENV: "test",
|
||||
CONUSAI_FAKE_PLATFORM: "1",
|
||||
CONUSAI_API_URL: `${platform.url}/api`,
|
||||
CONUSAI_API_TOKEN: "test-token",
|
||||
CONUSAI_PROJECT_ID: "42",
|
||||
CONUSAI_ORG_ID: "42",
|
||||
CONUSAI_ORG_SLUG: "owner-org",
|
||||
POSTGRES_URL: "postgresql://app_app:secret@db:5432/app",
|
||||
POSTGRES_READER_URL: "postgresql://app_reader:secret@db:5432/app",
|
||||
...extra,
|
||||
})
|
||||
|
||||
const setup = (service: "web" | "worker") => {
|
||||
const env = loadAppEnv(service, environment())
|
||||
const registry = buildRegistry([...appModules])
|
||||
const clients = platformClients(env, registry, environment())
|
||||
return { env, registry, clients }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the fake platform recorded for one organization of the platform. It keeps every tenant apart,
|
||||
* as the real one does: usage arrives under the organization in its URL, everything else under the
|
||||
* default one.
|
||||
*/
|
||||
const stateOf = async (organization?: string) =>
|
||||
(await (
|
||||
await fetch(
|
||||
`${platform.url}/__e2e/state`,
|
||||
organization ? { headers: { "x-conusai-organization": organization } } : {},
|
||||
)
|
||||
).json()) as {
|
||||
audit: Array<{ action: string; resource_id: string | null }>
|
||||
usage: Array<Record<string, unknown>>
|
||||
blobs: Array<{ pathname: string; size: number }>
|
||||
ocr: unknown[]
|
||||
ai: Array<{ request?: { model?: string } }>
|
||||
}
|
||||
|
||||
describe("platformClients", () => {
|
||||
it("writes an audit event to the platform's audit log", async () => {
|
||||
const { clients } = setup("web")
|
||||
await clients.audit.log("tenancy.organization_created", {
|
||||
actor: { id: "user-1", kind: "human" },
|
||||
target: { id: "acme", type: "organization" },
|
||||
outcome: "success",
|
||||
})
|
||||
await clients.audit.flush()
|
||||
expect((await stateOf()).audit.map((event) => event.action)).toContain("tenancy.organization_created")
|
||||
})
|
||||
|
||||
it("meters usage against the ConusAI organization that owns the deployment, not against a customer", async () => {
|
||||
const { clients } = setup("web")
|
||||
clients.usage.track({
|
||||
customer: { externalId: "acme" },
|
||||
meter: "notes_created",
|
||||
quantity: 1,
|
||||
idempotencyKey: "n-1",
|
||||
})
|
||||
await clients.usage.flush()
|
||||
// The fake separates numeric ID from slug, matching the production billing route contract.
|
||||
const [fact] = (await stateOf("owner-org")).usage
|
||||
expect((await stateOf()).usage).toEqual([])
|
||||
expect(fact).toMatchObject({ meter: "notes_created", quantity: "1", idempotency_key: "n-1" })
|
||||
})
|
||||
|
||||
it("stores an object in Blob and reads its bytes back", async () => {
|
||||
const { clients } = setup("worker")
|
||||
const stored = await clients.objectStore.put("files/a.txt", "hello", { contentType: "text/plain" })
|
||||
expect((await stateOf()).blobs.map((blob) => blob.pathname)).toContain("files/a.txt")
|
||||
expect(await (await clients.objectStore.download(stored.url)).text()).toBe("hello")
|
||||
})
|
||||
|
||||
it("asks the AI gateway for a validated object, naming the model the app is configured with", async () => {
|
||||
platform.ai.respond(() => ({ content: JSON.stringify({ answer: "four" }) }))
|
||||
const { clients } = setup("worker")
|
||||
const reply = await clients.models.generateObject(z.object({ answer: z.string() }), [
|
||||
{ role: "user", content: "2 + 2?" },
|
||||
])
|
||||
expect(reply.value).toEqual({ answer: "four" })
|
||||
const [call] = (await stateOf()).ai
|
||||
expect(call?.request?.model).toBe(clients.model)
|
||||
})
|
||||
|
||||
// kit:documents:begin
|
||||
it("reads a document through the OCR service", async () => {
|
||||
platform.ocr.setDocument(fixture)
|
||||
const { clients } = setup("worker")
|
||||
const job = await clients.documents.submit(new TextEncoder().encode("%PDF-1.4"), {
|
||||
filename: "a.pdf",
|
||||
idempotencyKey: "k-1",
|
||||
})
|
||||
const read = await clients.documents.wait(job, { intervalMs: 10 })
|
||||
expect(read.pages).toHaveLength((fixture as { pages: unknown[] }).pages.length)
|
||||
expect((await stateOf()).ocr).toHaveLength(1)
|
||||
})
|
||||
// kit:documents:end
|
||||
})
|
||||
|
||||
describe("what each process binds", () => {
|
||||
const resolvable = (container: Container, tokens: Array<Parameters<Container["resolve"]>[0]>): boolean[] =>
|
||||
tokens.map((token) => {
|
||||
try {
|
||||
container.resolve(token)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
it("binds Blob, the AI gateway, OCR and the registries in the web process", () => {
|
||||
const { registry, clients } = setup("web")
|
||||
const container = new Container()
|
||||
bindWebPorts(container, clients, registry)
|
||||
expect(
|
||||
resolvable(container, [
|
||||
OBJECT_STORE,
|
||||
MODEL_GATEWAY, // kit:documents
|
||||
DOCUMENT_MODEL, // kit:documents
|
||||
DOCUMENT_OCR, // kit:documents
|
||||
FILES_REGISTRY,
|
||||
PROPOSALS_REGISTRY,
|
||||
]),
|
||||
).not.toContain(false)
|
||||
// The sinks that belong to the outbox are the worker's alone.
|
||||
expect(resolvable(container, [FILES_AUDIT, FILES_USAGE, PROPOSALS_AUDIT])).toEqual([false, false, false])
|
||||
})
|
||||
|
||||
it("binds the same five and the audit and usage sinks in the worker process", () => {
|
||||
const { registry, clients } = setup("worker")
|
||||
const container = new Container()
|
||||
bindModulePorts(container, clients, registry)
|
||||
expect(
|
||||
resolvable(container, [
|
||||
OBJECT_STORE,
|
||||
MODEL_GATEWAY, // kit:documents
|
||||
DOCUMENT_MODEL, // kit:documents
|
||||
DOCUMENT_OCR, // kit:documents
|
||||
FILES_REGISTRY,
|
||||
PROPOSALS_REGISTRY,
|
||||
FILES_AUDIT,
|
||||
FILES_USAGE,
|
||||
PROPOSALS_AUDIT,
|
||||
]),
|
||||
).not.toContain(false)
|
||||
})
|
||||
|
||||
it("sends the worker's usage and audit sinks to the platform, through the bound tokens", async () => {
|
||||
const { registry, clients } = setup("worker")
|
||||
const container = new Container()
|
||||
bindModulePorts(container, clients, registry)
|
||||
const usage = container.resolve(FILES_USAGE)
|
||||
await usage.track({
|
||||
customer: { externalId: "acme" },
|
||||
meter: "files_bytes_stored",
|
||||
quantity: 12,
|
||||
idempotencyKey: "f-1",
|
||||
})
|
||||
await usage.flush()
|
||||
await container.resolve(FILES_AUDIT).log("files.file_uploaded", {
|
||||
actor: { id: "user-1", kind: "human" },
|
||||
target: { id: "acme/file-1", type: "file" },
|
||||
})
|
||||
await clients.audit.flush()
|
||||
const state = await stateOf()
|
||||
expect((await stateOf("owner-org")).usage).toEqual([
|
||||
expect.objectContaining({ meter: "files_bytes_stored", quantity: "12" }),
|
||||
])
|
||||
expect(state.audit.map((event) => event.action)).toContain("files.file_uploaded")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the platform contract at start-up", () => {
|
||||
const fake = (): Record<string, string | undefined> => environment()
|
||||
|
||||
it("refuses to start in production without the platform URL, naming the variable", () => {
|
||||
const { CONUSAI_API_URL: _url, CONUSAI_FAKE_PLATFORM: _fake, ...rest } = fake()
|
||||
const error = (() => {
|
||||
try {
|
||||
loadAppEnv("web", { ...rest, NODE_ENV: "production" })
|
||||
} catch (thrown) {
|
||||
return thrown
|
||||
}
|
||||
})()
|
||||
expect(error).toBeInstanceOf(PlatformEnvError)
|
||||
expect(error).toMatchObject({ variable: "CONUSAI_API_URL" })
|
||||
expect((error as Error).message).toMatch(/CONUSAI_API_URL is not set: link this project to the platform/)
|
||||
})
|
||||
|
||||
it("refuses to start without the platform token, whichever mode, naming the variable", () => {
|
||||
for (const NODE_ENV of ["production", "development", undefined]) {
|
||||
const { CONUSAI_API_TOKEN: _token, ...rest } = fake()
|
||||
const run = () =>
|
||||
loadAppEnv("worker", { ...rest, ...(NODE_ENV ? { NODE_ENV } : {}), CONUSAI_FAKE_PLATFORM: undefined })
|
||||
expect(run, String(NODE_ENV)).toThrow(PlatformEnvError)
|
||||
expect(run, String(NODE_ENV)).toThrow(/CONUSAI_API_TOKEN is not set/)
|
||||
}
|
||||
})
|
||||
|
||||
it("treats a run with no NODE_ENV as production, so the fake platform must be asked for in a development run", () => {
|
||||
const { NODE_ENV: _mode, ...rest } = fake()
|
||||
expect(() => loadAppEnv("web", rest)).toThrow(/CONUSAI_FAKE_PLATFORM is set in a production run/)
|
||||
})
|
||||
|
||||
it("points a development run that has no platform at `pnpm dev` and the explicit switch", () => {
|
||||
const { CONUSAI_API_URL: _url, CONUSAI_FAKE_PLATFORM: _fake, ...rest } = fake()
|
||||
expect(() => loadAppEnv("web", { ...rest, NODE_ENV: "development" })).toThrow(/pnpm dev.*CONUSAI_FAKE_PLATFORM=1/)
|
||||
})
|
||||
|
||||
it("accepts the fake platform in development and in a test, and says which it is using", () => {
|
||||
for (const NODE_ENV of ["development", "test"]) {
|
||||
expect(loadAppEnv("web", { ...fake(), NODE_ENV })).toMatchObject({ platform: "fake", production: false })
|
||||
}
|
||||
const { CONUSAI_FAKE_PLATFORM: _fake, ...real } = fake()
|
||||
expect(loadAppEnv("web", { ...real, NODE_ENV: "development" }).platform).toBe("platform")
|
||||
})
|
||||
|
||||
it("refuses the fake platform in a production run even when everything else is set", () => {
|
||||
expect(() => loadAppEnv("web", { ...fake(), NODE_ENV: "production" })).toThrow(PlatformEnvError)
|
||||
expect(() => loadAppEnv("web", { ...fake(), NODE_ENV: "production" })).toThrow(/CONUSAI_FAKE_PLATFORM/)
|
||||
})
|
||||
|
||||
it("refuses the fake switch when the platform URL is somebody's public address", () => {
|
||||
expect(() => loadAppEnv("web", { ...fake(), CONUSAI_API_URL: "https://api.conusai.example/api" })).toThrow(
|
||||
/CONUSAI_API_URL names api\.conusai\.example/,
|
||||
)
|
||||
// ... while a compose service name and loopback are what the fake platform runs at.
|
||||
expect(() => loadAppEnv("web", { ...fake(), CONUSAI_API_URL: "http://platform:4010/api" })).not.toThrow()
|
||||
expect(() => loadAppEnv("web", { ...fake(), CONUSAI_API_URL: "http://127.0.0.1:4010/api" })).not.toThrow()
|
||||
})
|
||||
|
||||
it("requires the owning organization in production, where usage would otherwise be metered against nothing", () => {
|
||||
const { CONUSAI_FAKE_PLATFORM: _fake, CONUSAI_ORG_ID: _org, ...rest } = fake()
|
||||
const production = { ...rest, NODE_ENV: "production" }
|
||||
expect(() => loadAppEnv("web", production)).toThrow(/CONUSAI_ORG_ID is not set/)
|
||||
expect(() => loadAppEnv("worker", production)).toThrow(/CONUSAI_ORG_ID is not set/)
|
||||
// The migrate job meters nothing.
|
||||
expect(() =>
|
||||
loadAppEnv("migrate", { ...production, POSTGRES_MIGRATE_URL: "postgresql://app_owner:secret@db:5432/app" }),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("probePlatform", () => {
|
||||
it("uses public readiness when the platform descriptor is protected", async () => {
|
||||
const authorization: Array<string | null> = []
|
||||
const answered = await probePlatform(
|
||||
{ CONUSAI_API_URL: "https://platform.example.test/api" },
|
||||
{
|
||||
fetch: async (input, init) => {
|
||||
authorization.push(new Headers(init?.headers).get("authorization"))
|
||||
const path = new URL(String(input)).pathname
|
||||
// The real platform mounts its descriptor under authenticated /api;
|
||||
// /readyz is its public readiness contract.
|
||||
if (path === "/readyz") return new Response("ready")
|
||||
return new Response("not public", { status: path.startsWith("/api/") ? 401 : 404 })
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(answered).toBe(true)
|
||||
expect(authorization).toEqual([null])
|
||||
})
|
||||
|
||||
it("is true when the platform answers its public readiness probe, and sends no credential", async () => {
|
||||
const seen: Array<string | null> = []
|
||||
const answered = await probePlatform(
|
||||
{ CONUSAI_API_URL: `${platform.url}/api` },
|
||||
{
|
||||
fetch: async (input, init) => {
|
||||
seen.push(new Headers(init?.headers).get("authorization"))
|
||||
return fetch(input, init)
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(answered).toBe(true)
|
||||
expect(seen).toEqual([null])
|
||||
})
|
||||
|
||||
it("is false when nothing answers, without throwing", async () => {
|
||||
expect(await probePlatform({ CONUSAI_API_URL: "http://127.0.0.1:9/api" }, { timeoutMs: 500 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the public address", () => {
|
||||
it("is the one the deployment states", () => {
|
||||
expect(publicUrlOf({ NODE_ENV: "production", CONUSAI_PUBLIC_URL: "https://app.example.test" })).toBe(
|
||||
"https://app.example.test",
|
||||
)
|
||||
})
|
||||
|
||||
it("is refused by name in a production run that has none, and never guessed", () => {
|
||||
for (const env of [{ NODE_ENV: "production" }, {}]) {
|
||||
expect(() => publicUrlOf(env)).toThrow(PlatformEnvError)
|
||||
try {
|
||||
publicUrlOf(env)
|
||||
} catch (error) {
|
||||
expect((error as PlatformEnvError).variable).toBe("CONUSAI_PUBLIC_URL")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to the development address only in development and tests", () => {
|
||||
expect(publicUrlOf({ NODE_ENV: "development" })).toBe("http://localhost:3000")
|
||||
expect(publicUrlOf({ NODE_ENV: "test" })).toBe("http://localhost:3000")
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
|
||||
...(await import("./support/native-readiness.js")).selectedModules(["documents"]),
|
||||
}))
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The language and the theme a person chooses (spec §10.4): precedence, the cookies that carry a
|
||||
// visitor's choice, and the roles and invitation states the shell words from the catalogues.
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { cookieValue, localeOf, panelWidthOf, sidebarOpenOf, themeOf } from "../apps/web/src/server/preferences.js"
|
||||
import { inviteErrorKey } from "../apps/web/src/ui/invite-accept.js"
|
||||
import {
|
||||
LOCALE_COOKIE,
|
||||
PREFERENCE_SECONDS,
|
||||
preferenceCookie,
|
||||
THEME_COOKIE,
|
||||
} from "../apps/web/src/ui/preference-cookies.js"
|
||||
|
||||
describe("the language", () => {
|
||||
it("is the signed-in person's own setting before anything else", () => {
|
||||
expect(
|
||||
localeOf({ appLocale: "en", userLocale: "lt", cookieHeader: `${LOCALE_COOKIE}=en`, acceptLanguage: "en" }),
|
||||
).toBe("lt")
|
||||
})
|
||||
|
||||
it("is a visitor's cookie when nobody is signed in", () => {
|
||||
expect(localeOf({ appLocale: "en", cookieHeader: `a=1; ${LOCALE_COOKIE}=lt; b=2` })).toBe("lt")
|
||||
})
|
||||
|
||||
it("falls to the browser's language when it names one the app serves, then to the app's default", () => {
|
||||
// A Lithuanian app and an English browser: English, and the other way round.
|
||||
expect(localeOf({ appLocale: "lt", acceptLanguage: "en-GB,en;q=0.9" })).toBe("en")
|
||||
expect(localeOf({ appLocale: "en", acceptLanguage: "lt-LT,en;q=0.8" })).toBe("lt")
|
||||
// The browser's own order, by weight, among the languages the app serves.
|
||||
expect(localeOf({ appLocale: "en", acceptLanguage: "en;q=0.4,lt;q=0.9" })).toBe("lt")
|
||||
expect(localeOf({ appLocale: "lt", acceptLanguage: "fr-FR,de;q=0.9,en;q=0.1" })).toBe("en")
|
||||
// A browser that names none of them, or none at all: the app's default.
|
||||
expect(localeOf({ appLocale: "lt", acceptLanguage: "fr-FR,de;q=0.9" })).toBe("lt")
|
||||
expect(localeOf({ appLocale: "lt" })).toBe("lt")
|
||||
expect(localeOf({ appLocale: "xx", acceptLanguage: "lt-LT,en;q=0.8" })).toBe("lt")
|
||||
expect(localeOf({ appLocale: "en", userLocale: "fr", cookieHeader: `${LOCALE_COOKIE}=de` })).toBe("en")
|
||||
})
|
||||
|
||||
it("keeps a person's or a visitor's own choice before the browser's", () => {
|
||||
expect(localeOf({ appLocale: "lt", userLocale: "lt", acceptLanguage: "en-GB" })).toBe("lt")
|
||||
expect(localeOf({ appLocale: "lt", cookieHeader: `${LOCALE_COOKIE}=lt`, acceptLanguage: "en-GB" })).toBe("lt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the theme", () => {
|
||||
it("is dark unless the cookie says light: the brand is dark-first", () => {
|
||||
expect(themeOf(null)).toBe("dark")
|
||||
expect(themeOf(`${THEME_COOKIE}=light`)).toBe("light")
|
||||
expect(themeOf(`${THEME_COOKIE}=dark`)).toBe("dark")
|
||||
expect(themeOf(`${THEME_COOKIE}=system`)).toBe("system")
|
||||
expect(themeOf(`${THEME_COOKIE}=neon`)).toBe("dark")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the cookies", () => {
|
||||
it("are written for a year, to every path, and never to another site", () => {
|
||||
expect(preferenceCookie(LOCALE_COOKIE, "lt")).toBe(
|
||||
`app_locale=lt; Path=/; Max-Age=${PREFERENCE_SECONDS}; SameSite=Lax`,
|
||||
)
|
||||
expect(PREFERENCE_SECONDS).toBe(31_536_000)
|
||||
})
|
||||
|
||||
it("read back what was written, and survive a value that is not valid percent-encoding", () => {
|
||||
expect(cookieValue(`x=1; ${preferenceCookie(THEME_COOKIE, "light").split(";")[0]}`, THEME_COOKIE)).toBe("light")
|
||||
expect(cookieValue("app_locale=%E0%A4%A", LOCALE_COOKIE)).toBeUndefined()
|
||||
expect(cookieValue(undefined, LOCALE_COOKIE)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("an invitation's states", () => {
|
||||
it.each([
|
||||
["not_found", "invalid"],
|
||||
["invitation_used", "used"],
|
||||
["invitation_revoked", "revoked"],
|
||||
["invitation_expired", "expired"],
|
||||
["already_member", "member"],
|
||||
["conflict", "failed"],
|
||||
["rate_limited", "failed"],
|
||||
["", "failed"],
|
||||
] as const)("words the code %j as %s", (code, key) => {
|
||||
expect(inviteErrorKey(code)).toBe(key)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the frame's layout", () => {
|
||||
it("opens the sidebar unless the person closed it", () => {
|
||||
expect(sidebarOpenOf(null)).toBe(true)
|
||||
expect(sidebarOpenOf("app_sidebar=open")).toBe(true)
|
||||
expect(sidebarOpenOf("app_sidebar=closed")).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps the panel's width only inside what the panel can take", () => {
|
||||
expect(panelWidthOf(null)).toBeUndefined()
|
||||
expect(panelWidthOf("app_panel_width=480")).toBe(480)
|
||||
expect(panelWidthOf("app_panel_width=9000")).toBeUndefined()
|
||||
expect(panelWidthOf("app_panel_width=wide")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Exercise the real server entry before the Start handler or any store is reached.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
profile: "mariadb",
|
||||
registryError: undefined as Error | undefined,
|
||||
envError: undefined as Error | undefined,
|
||||
handler: vi.fn(async () => new Response("supported handler")),
|
||||
}))
|
||||
vi.mock("@app/config", async (original) => {
|
||||
const config = await original<typeof import("@app/config")>()
|
||||
return {
|
||||
...config,
|
||||
get PROFILE() {
|
||||
return state.profile
|
||||
},
|
||||
loadAppEnv: () => {
|
||||
if (state.envError) throw state.envError
|
||||
return {}
|
||||
},
|
||||
profileRegistry: (...args: Parameters<typeof config.profileRegistry>) => {
|
||||
if (state.registryError) throw state.registryError
|
||||
return config.profileRegistry(...args)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock("@conusai-kit/auth", async (original) => ({
|
||||
...(await original<typeof import("@conusai-kit/auth")>()),
|
||||
loadAuthConfig: () => ({}),
|
||||
}))
|
||||
vi.mock("@tanstack/react-start/server-entry", () => ({
|
||||
default: { fetch: state.handler },
|
||||
createServerEntry: (entry: unknown) => entry,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/guard", () => ({ PAGE_STATUS_HEADER: "x-conusai-page-status" }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
state.profile = "mariadb"
|
||||
state.registryError = undefined
|
||||
state.envError = undefined
|
||||
state.handler.mockClear()
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe("the actual web entry's startup composition", () => {
|
||||
// kit:profile-dialect:begin
|
||||
it.each(["mariadb", "mongodb"])("classifies unsupported adopted %s modules before dispatch", async (profile) => {
|
||||
state.profile = profile
|
||||
const { default: entry } = await import("../apps/web/src/server")
|
||||
const request = new Request("https://fixture.example.test/api/ready")
|
||||
const response = await entry.fetch(request)
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get("cache-control")).toBe("no-store")
|
||||
expect(response.headers.get("retry-after")).toBe("30")
|
||||
expect(await response.json()).toMatchObject({
|
||||
ready: false,
|
||||
checks: { contract: false },
|
||||
reason: expect.stringContaining("agent, assistant"),
|
||||
})
|
||||
expect(state.handler).not.toHaveBeenCalled()
|
||||
const health = await entry.fetch(new Request("https://fixture.example.test/api/health"))
|
||||
expect(health.status).toBe(503)
|
||||
expect(await health.json()).toEqual({ status: 503, code: "not_configured" })
|
||||
})
|
||||
|
||||
// kit:profile-dialect:end
|
||||
|
||||
it("keeps the supported Postgres entry on the actual handler", async () => {
|
||||
state.profile = "postgres"
|
||||
const { default: entry } = await import("../apps/web/src/server")
|
||||
const response = await entry.fetch(new Request("https://fixture.example.test/api/health"))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe("supported handler")
|
||||
expect(state.handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("preserves existing environment refusal without exposing a quoted value", async () => {
|
||||
state.envError = new Error('CONUSAI_API_URL is "https://synthetic-secret.invalid"')
|
||||
const { default: entry } = await import("../apps/web/src/server")
|
||||
const response = await entry.fetch(new Request("https://fixture.example.test/api/ready"))
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.text()).not.toContain("synthetic-secret")
|
||||
expect(state.handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// kit:profile-dialect:begin
|
||||
it("does not classify an unexpected registry failure as a configuration refusal", async () => {
|
||||
const unexpected = new Error("synthetic unexpected registry failure")
|
||||
state.registryError = unexpected
|
||||
const { default: entry } = await import("../apps/web/src/server")
|
||||
await expect(entry.fetch(new Request("https://fixture.example.test/api/ready"))).rejects.toBe(unexpected)
|
||||
expect(state.handler).not.toHaveBeenCalled()
|
||||
})
|
||||
// kit:profile-dialect:end
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// `/api/ready` (spec §13): the migration level it compares against, read from this repository's
|
||||
// folders. The verdict is `@conusai-kit/host/http`'s `checkReadiness` and its tests.
|
||||
|
||||
import { readdirSync } from "node:fs"
|
||||
import { appRoot, expectedMigrations, GRANT_MIGRATIONS, missingMigrations } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("the migration level", () => {
|
||||
it("lists the app's files, then each module's, then the grants the migrate job adds", async () => {
|
||||
const expected = await expectedMigrations(buildRegistry([...appModules]))
|
||||
const files = readdirSync(`${appRoot()}/migrations/postgres`)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.map((name) => name.replace(/\.sql$/, ""))
|
||||
.sort()
|
||||
expect(expected.slice(0, files.length)).toEqual(files)
|
||||
expect(expected).toContain("notes/0001_notes") // kit:notes
|
||||
expect(expected.slice(-GRANT_MIGRATIONS.length)).toEqual([...GRANT_MIGRATIONS])
|
||||
})
|
||||
|
||||
it("names what a database lacks", () => {
|
||||
expect(missingMigrations(["a", "b", "c"], new Set(["a", "c", "z"]))).toEqual(["b"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The registry this app boots with (spec §4.1): built from the manifests in modules/index.ts and
|
||||
// from nothing else, so what the router, the navigation and the permissions hold is what the
|
||||
// modules declared.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules, domainModules, kernelModules } from "../modules/index.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
|
||||
describe("the app registry", () => {
|
||||
it("boots with the kernel modules a tenant app needs", () => {
|
||||
const kernel = ["tenancy", "auth", "files", "proposals", "notifications"]
|
||||
expect(kernelModules.map((module) => module.id)).toEqual(kernel)
|
||||
expect(appModules.length).toBe(kernelModules.length + domainModules.length)
|
||||
})
|
||||
|
||||
it("registers every tenancy and auth procedure", () => {
|
||||
const names = [...registry.procedures.keys()]
|
||||
expect(names).toContain("tenancy.org.create")
|
||||
expect(names).toContain("tenancy.member.invite")
|
||||
expect(names).toContain("auth.apiKey.create")
|
||||
expect(registry.procedures.size).toBeGreaterThanOrEqual(15)
|
||||
})
|
||||
|
||||
it("declares a permission for every tenant-scoped procedure", () => {
|
||||
for (const [name, { def }] of registry.procedures) {
|
||||
if (def.scope === "public" || def.scope === "user") continue
|
||||
expect(registry.permissions[def.permission], `${name} declares ${def.permission}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it("lists every tenant-scoped procedure as needing an isolation fixture", () => {
|
||||
const tenantScoped = [...registry.procedures.values()].filter(
|
||||
({ def }) => def.scope !== "public" && def.scope !== "user",
|
||||
)
|
||||
expect(registry.isolationFixtures.length).toBe(tenantScoped.length)
|
||||
})
|
||||
|
||||
it("has a table for every entity the app's schema must hold", () => {
|
||||
const tables = [...entitiesOf(registry, "postgres"), ...KIT_JOBS_ENTITIES].map((entity) => entity.table)
|
||||
expect(tables).toContain("organizations")
|
||||
expect(tables).toContain("auth_sessions")
|
||||
expect(tables).toContain("kit_processed_events")
|
||||
expect(new Set(tables).size).toBe(tables.length)
|
||||
})
|
||||
|
||||
it("gives the web shell a navigation entry per kernel area", () => {
|
||||
expect(registry.nav.map((item) => item.href)).toEqual(expect.arrayContaining(["members", "settings", "api-keys"]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,509 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Every subscriber accepts the delivery the worker's relay makes. The relay (`createOutboxRelay` of
|
||||
// @conusai-kit/jobs-pgboss) runs each (record, subscriber) pair in the record's organization's scope with a system
|
||||
// actor, and the worker binds each module's scope token to that delivery scope (`bindModulePorts`,
|
||||
// apps/worker/src/ports.ts). A subscriber that works in a project builds the project from the record, comparing
|
||||
// the record's organization with the delivery's.
|
||||
//
|
||||
// What runs here is the worker as it is deployed, on a disposable PostgreSQL with the three roles: the migrate job
|
||||
// (`runAppMigrations`), the worker's runtime (`buildPostgresWorkerRuntime`: its data source, its pg-boss queue in
|
||||
// the delivery's transaction), the worker's own port binding and pg-boss's worker (`createWorker`, its queues and
|
||||
// its relay, driven a pass at a time), with the optional modules these events reach turned on the way an app
|
||||
// turns them on. The platform is the fake one. A delivery that fails is
|
||||
// a failure of this test, never a retry. Needs Docker.
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { loadAppEnv } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import * as knowledge from "@app/modules/knowledge" // kit:knowledge
|
||||
import { type Actor, createCaller, type ModuleManifest, newId, type TenantScope } from "@conusai-kit/core"
|
||||
import {
|
||||
beginDelete, // kit:knowledge
|
||||
fileDeleted,
|
||||
fileStored, // kit:knowledge
|
||||
files, // kit:knowledge
|
||||
filesDeps, // kit:knowledge
|
||||
finishDeleteRecord, // kit:knowledge
|
||||
platformBlobPathname, // kit:knowledge
|
||||
} from "@conusai-kit/files"
|
||||
import { runAppMigrations } from "@conusai-kit/host/migrate"
|
||||
import { loadPostgresEnv, startObservability } from "@conusai-kit/host/postgres/config"
|
||||
import { buildPostgresWorkerRuntime, type WorkerRuntime } from "@conusai-kit/host/worker"
|
||||
import { createWorker, type Relay, type Worker, type WorkerLogEvent } from "@conusai-kit/jobs-pgboss"
|
||||
import { createKnowledge } from "@conusai-kit/platform" // kit:knowledge
|
||||
import { accountDataOf, createTenancyHooks, TENANCY, users } from "@conusai-kit/tenancy" // kit:knowledge
|
||||
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
||||
import { Pool } from "pg"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { bindModulePorts } from "../apps/worker/src/ports.js"
|
||||
import { appSchedules } from "../apps/worker/src/schedules.js"
|
||||
|
||||
const APP_ROOT = new URL("..", import.meta.url).pathname
|
||||
const docker = (...args: string[]): string =>
|
||||
execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
||||
|
||||
/** The optional modules these deliveries reach, turned on the way an app turns them on. */
|
||||
const TURNED_ON: readonly ModuleManifest[] = [
|
||||
knowledge.knowledgeModule, // kit:knowledge
|
||||
]
|
||||
|
||||
/** The app's modules with those turned on. */
|
||||
function selectedModules(): readonly ModuleManifest[] {
|
||||
const on = new Set(TURNED_ON.map((module) => module.id))
|
||||
return [...appModules.filter((module) => !on.has(module.id)), ...TURNED_ON]
|
||||
}
|
||||
|
||||
let container = ""
|
||||
let owner: Pool
|
||||
let platform: Awaited<ReturnType<typeof startFakePlatform>>
|
||||
let runtime: WorkerRuntime
|
||||
let worker: Worker
|
||||
let relay: Relay
|
||||
/** Each failed delivery, as the relay reports it: the module and why. */
|
||||
const failures: string[] = []
|
||||
let project: TenantScope & { level: "project"; workspaceId: string; projectId: string; actor: Actor }
|
||||
/** The project owner's caller, for what a person does in a test. */
|
||||
let call: ReturnType<typeof createCaller>
|
||||
|
||||
beforeAll(async () => {
|
||||
const name = `app-relay-scope-${crypto.randomUUID()}`
|
||||
// The three roles a linked managed database provides, made by the same script the dev stack and e2e run.
|
||||
container = docker(
|
||||
"run",
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"--label",
|
||||
"conusai.test=app-relay-scope",
|
||||
"--publish",
|
||||
"127.0.0.1::5432",
|
||||
"--env",
|
||||
"POSTGRES_PASSWORD=postgres",
|
||||
"--env",
|
||||
"POSTGRES_DB=appdev",
|
||||
"--volume",
|
||||
`${APP_ROOT}dev:/conusai-dev:ro`,
|
||||
"--entrypoint",
|
||||
"sh",
|
||||
"postgres:18",
|
||||
"/conusai-dev/postgres-entrypoint.sh",
|
||||
"postgres",
|
||||
)
|
||||
let port = ""
|
||||
for (const deadline = Date.now() + 10_000; !/^[1-9][0-9]*$/.test(port); ) {
|
||||
port = docker("port", container, "5432/tcp").split(":").at(-1) ?? ""
|
||||
if (Date.now() > deadline) throw new Error("the test database's port was not published")
|
||||
if (!/^[1-9][0-9]*$/.test(port)) await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
const url = (role: string): string => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${port}/appdev`
|
||||
const ownerUrl = url("owner")
|
||||
// The init script creates the roles after the server first starts, then it restarts: ready is the owner's login.
|
||||
owner = new Pool({ connectionString: ownerUrl, max: 1, connectionTimeoutMillis: 1_000 })
|
||||
for (const deadline = Date.now() + 90_000; ; ) {
|
||||
try {
|
||||
await owner.query("select 1")
|
||||
break
|
||||
} catch (error) {
|
||||
if (Date.now() > deadline) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
|
||||
platform = await startFakePlatform()
|
||||
const source = {
|
||||
NODE_ENV: "test",
|
||||
CONUSAI_FAKE_PLATFORM: "1",
|
||||
CONUSAI_API_URL: `${platform.url}/api`,
|
||||
CONUSAI_API_TOKEN: "test-token",
|
||||
CONUSAI_PROJECT_ID: "42",
|
||||
CONUSAI_ORG_ID: "42",
|
||||
CONUSAI_ORG_SLUG: "owner-org",
|
||||
// The deployment's workspace, project and environment, which address the platform's knowledge service.
|
||||
CONUSAI_WORKSPACE_SLUG: "general",
|
||||
CONUSAI_PROJECT_SLUG: "app",
|
||||
CONUSAI_ENVIRONMENT: "test",
|
||||
POSTGRES_URL: url("app"),
|
||||
POSTGRES_READER_URL: url("reader"),
|
||||
}
|
||||
const modules = selectedModules()
|
||||
// The migrate job, as compose.yaml runs it: it reads the two logins it grants to from the environment.
|
||||
const saved = { POSTGRES_URL: process.env.POSTGRES_URL, POSTGRES_READER_URL: process.env.POSTGRES_READER_URL }
|
||||
Object.assign(process.env, { POSTGRES_URL: source.POSTGRES_URL, POSTGRES_READER_URL: source.POSTGRES_READER_URL })
|
||||
try {
|
||||
await runAppMigrations({ ownerUrl, modules, root: APP_ROOT })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// The worker, as createPostgresWorker composes it for apps/worker/src/main.ts: its runtime, the app's port
|
||||
// binding and pg-boss's worker, which creates the queues and serves them. Only the relay's timer is off: the
|
||||
// test drives the worker's own relay a pass at a time.
|
||||
loadAppEnv("worker", source)
|
||||
const env = loadPostgresEnv("worker", source)
|
||||
runtime = buildPostgresWorkerRuntime({ modules, env, observe: startObservability(env, { env: source }), source })
|
||||
bindModulePorts(runtime.container, runtime.platform, runtime.registry)
|
||||
// kit:knowledge:begin -- the knowledge port, bound as bindModulePorts binds it once an app has adopted knowledge
|
||||
// (apps/worker/src/ports.ts `bindKnowledgePorts`). The shipped inclusion record has not adopted it; this test
|
||||
// turns knowledge on by its manifest, so it binds the port the same way.
|
||||
runtime.container.register(knowledge.KNOWLEDGE_SEARCH, (c) =>
|
||||
knowledge.platformKnowledgeSearch(createKnowledge(runtime.platform.deployment), filesDeps(c), {
|
||||
environment: runtime.platform.deployment.CONUSAI_ENVIRONMENT,
|
||||
}),
|
||||
)
|
||||
// kit:knowledge:end
|
||||
worker = createWorker({
|
||||
registry: runtime.registry,
|
||||
boss: runtime.boss,
|
||||
claimGuard: runtime.claimGuard,
|
||||
dataSource: runtime.data,
|
||||
container: runtime.container,
|
||||
schedules: appSchedules(),
|
||||
relay: { enabled: false },
|
||||
log: (event: WorkerLogEvent) => {
|
||||
if (event.kind === "relay.failed") failures.push(`${event.event} -> ${event.subscriber}: ${String(event.error)}`)
|
||||
},
|
||||
})
|
||||
await worker.start()
|
||||
relay = worker.relay
|
||||
|
||||
// One organization with a project, made through the tenancy procedures as a person makes it.
|
||||
const { data, registry } = runtime
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: "ada@example.test",
|
||||
email: "ada@example.test",
|
||||
displayName: "Ada",
|
||||
locale: "en",
|
||||
})
|
||||
const actor: Actor = { userId: user.id }
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const requests = runtime.container.child()
|
||||
requests.value(TENANCY, tenancy)
|
||||
call = createCaller({ registry, container: requests, tenancy }, { actor })
|
||||
const { orgId } = (await call.callByName("tenancy.org.create", { slug: "acme", name: "Acme" })) as { orgId: string }
|
||||
const { workspaceId } = (await call.callByName("tenancy.workspace.create", {
|
||||
org: "acme",
|
||||
slug: "ops",
|
||||
name: "Ops",
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string }
|
||||
const { projectId } = (await call.callByName("tenancy.project.create", {
|
||||
org: "acme",
|
||||
workspace: "ops",
|
||||
slug: "site",
|
||||
name: "Site",
|
||||
moduleKey: "notes",
|
||||
})) as { projectId: string }
|
||||
project = { level: "project", actor, orgId, workspaceId, projectId }
|
||||
// What the setup published is delivered first, so each test drains only its own records.
|
||||
await relay.drain()
|
||||
expect(failures).toEqual([])
|
||||
}, 300_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await worker?.stop()
|
||||
await runtime?.close()
|
||||
await owner?.end()
|
||||
await platform?.close()
|
||||
if (container) docker("rm", "--force", "--volumes", container)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
failures.length = 0
|
||||
})
|
||||
|
||||
/** The jobs pg-boss holds for one queue, with the tenant ids each was enqueued in. */
|
||||
async function queued(job: string): Promise<Array<{ scope: unknown; payload: unknown }>> {
|
||||
const { rows } = await owner.query<{ data: { scope: unknown; payload: unknown } }>(
|
||||
"select data from pgboss.job where name = $1 order by created_on",
|
||||
[job],
|
||||
)
|
||||
return rows.map((row) => ({ scope: row.data.scope, payload: row.data.payload }))
|
||||
}
|
||||
|
||||
describe("the worker's relay delivers to every subscriber in the organization's scope", () => {
|
||||
it("delivers a file deletion to every module that subscribes to it", async () => {
|
||||
const fileId = newId()
|
||||
// kit:knowledge:begin -- the deleted file was indexed into a collection
|
||||
await runtime.data.forScope(project).of(knowledge.documents).insert({
|
||||
collectionName: "contracts",
|
||||
fileId,
|
||||
filename: "contract.pdf",
|
||||
status: "indexed",
|
||||
error: null,
|
||||
indexedAt: "2026-11-09T00:00:00.000Z",
|
||||
requestedBy: project.actor.userId,
|
||||
})
|
||||
// kit:knowledge:end
|
||||
await runtime.data.unitOfWork.run(project, (tx) =>
|
||||
tx.publish(fileDeleted, {
|
||||
orgId: project.orgId,
|
||||
workspaceId: project.workspaceId,
|
||||
projectId: project.projectId,
|
||||
fileId,
|
||||
storageKey: `files/${fileId}`,
|
||||
size: 0,
|
||||
}),
|
||||
)
|
||||
const pass = await relay.drain()
|
||||
expect(failures).toEqual([])
|
||||
expect(pass.delivered).toBeGreaterThan(0)
|
||||
// kit:knowledge:begin -- knowledge cancelled the document and asked for its removal, which the relay delivers
|
||||
const [document] = (await runtime.data.forScope(project).of(knowledge.documents).list({})).items
|
||||
expect(document?.status).toBe("cancelled")
|
||||
await relay.drain()
|
||||
expect(failures).toEqual([])
|
||||
expect(await queued(knowledge.REMOVE_JOB)).toMatchObject([
|
||||
{
|
||||
scope: {
|
||||
level: "project",
|
||||
orgId: project.orgId,
|
||||
workspaceId: project.workspaceId,
|
||||
projectId: project.projectId,
|
||||
},
|
||||
payload: { fileId, collectionName: "contracts" },
|
||||
},
|
||||
])
|
||||
// kit:knowledge:end
|
||||
})
|
||||
|
||||
// kit:knowledge:begin
|
||||
it("delivers knowledge's own index request, which enqueues the job in the record's project", async () => {
|
||||
const documentId = newId()
|
||||
await runtime.data.unitOfWork.run(project, (tx) =>
|
||||
tx.publish(knowledge.documentIndexRequested, {
|
||||
orgId: project.orgId,
|
||||
workspaceId: project.workspaceId,
|
||||
projectId: project.projectId,
|
||||
documentId,
|
||||
collectionName: "contracts",
|
||||
fileId: newId(),
|
||||
}),
|
||||
)
|
||||
await relay.drain()
|
||||
expect(failures).toEqual([])
|
||||
expect(await queued(knowledge.INDEX_JOB)).toMatchObject([
|
||||
{
|
||||
scope: {
|
||||
level: "project",
|
||||
orgId: project.orgId,
|
||||
workspaceId: project.workspaceId,
|
||||
projectId: project.projectId,
|
||||
},
|
||||
payload: { documentId },
|
||||
},
|
||||
])
|
||||
})
|
||||
// kit:knowledge:end
|
||||
|
||||
// kit:knowledge:begin
|
||||
/** A stored file of the project, indexed into a collection: what an upload and an index leave behind. */
|
||||
async function indexedFile(): Promise<{ fileId: string; storageKey: string }> {
|
||||
const storageKey = `files/${newId()}`
|
||||
const stored = await runtime.data
|
||||
.forScope(project)
|
||||
.of(files)
|
||||
.insert({
|
||||
status: "stored",
|
||||
storageKey,
|
||||
filename: "contract.pdf",
|
||||
contentType: "application/pdf",
|
||||
size: 1024,
|
||||
// A stored row always has its size, digest and time (kit-files `viewOf` refuses one without them).
|
||||
sha256: "0".repeat(64),
|
||||
uploadedBy: project.actor.userId,
|
||||
storedAt: "2026-11-09T00:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: 1024,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
const fileId = stored.id
|
||||
await runtime.data.forScope(project).of(knowledge.documents).insert({
|
||||
collectionName: "contracts",
|
||||
fileId,
|
||||
filename: "contract.pdf",
|
||||
status: "indexed",
|
||||
error: null,
|
||||
indexedAt: "2026-11-09T00:00:00.000Z",
|
||||
requestedBy: project.actor.userId,
|
||||
})
|
||||
return { fileId, storageKey }
|
||||
}
|
||||
|
||||
/** The remove job's rows for one file, as pg-boss holds them. */
|
||||
async function removeJob(fileId: string) {
|
||||
const { rows } = await owner.query<{ state: string; retry_count: number }>(
|
||||
"select state, retry_count from pgboss.job where name = $1 and data->'payload'->>'fileId' = $2",
|
||||
[knowledge.REMOVE_JOB, fileId],
|
||||
)
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The platform knowledge service's `remove` calls for one stored object. */
|
||||
async function removalsOf(storageKey: string) {
|
||||
const state = (await (await fetch(`${platform.url}/__e2e/state`)).json()) as {
|
||||
knowledge: Array<{ route: string; documentRef?: string }>
|
||||
}
|
||||
return state.knowledge.filter((call) => call.route === "remove" && call.documentRef?.endsWith(storageKey))
|
||||
}
|
||||
|
||||
it("removes a deleted file's indexed text from the knowledge service once, with the ref the file had (F407)", async () => {
|
||||
const { fileId, storageKey } = await indexedFile()
|
||||
// The files module's own deletion: the row is marked, then removed, and `FileDeleted` is published with it.
|
||||
await beginDelete(filesDeps(runtime.container), project, fileId, project.actor.userId)
|
||||
expect(await finishDeleteRecord(runtime.data, project, fileId)).toBe(true)
|
||||
await relay.drain() // FileDeleted -> knowledge cancels the document and asks for its removal
|
||||
await relay.drain() // documentRemoveRequested -> the remove job, which pg-boss's worker runs
|
||||
expect(failures).toEqual([])
|
||||
|
||||
const removeJob = async () => {
|
||||
const { rows } = await owner.query<{ state: string; retry_count: number }>(
|
||||
"select state, retry_count from pgboss.job where name = $1 and data->'payload'->>'fileId' = $2",
|
||||
[knowledge.REMOVE_JOB, fileId],
|
||||
)
|
||||
return rows
|
||||
}
|
||||
await expect.poll(async () => (await removeJob())[0]?.state, { timeout: 30_000 }).toBe("completed")
|
||||
expect(await removeJob()).toEqual([{ state: "completed", retry_count: 0 }])
|
||||
const state = (await (await fetch(`${platform.url}/__e2e/state`)).json()) as {
|
||||
knowledge: Array<{ route: string; documentRef?: string }>
|
||||
}
|
||||
const removals = state.knowledge.filter((call) => call.route === "remove")
|
||||
expect(removals.filter((call) => call.documentRef?.endsWith(storageKey))).toEqual([
|
||||
expect.objectContaining({ documentRef: platformBlobPathname(project, storageKey) }),
|
||||
])
|
||||
expect(
|
||||
(await runtime.data.forScope(project).of(knowledge.documents).list({})).items.filter(
|
||||
(row) => row.fileId === fileId,
|
||||
),
|
||||
).toEqual([])
|
||||
|
||||
// A second run of the same job, as a redelivery makes it, finds nothing left to remove and does nothing.
|
||||
const { rows: delivered } = await owner.query<{ payload: unknown }>(
|
||||
"select data->'payload' as payload from pgboss.job where name = $1 and data->'payload'->>'fileId' = $2",
|
||||
[knowledge.REMOVE_JOB, fileId],
|
||||
)
|
||||
const inProject = runtime.container.child()
|
||||
inProject.value(knowledge.KNOWLEDGE_SCOPE, project)
|
||||
await runtime.registry.jobs[knowledge.REMOVE_JOB]?.def.handler(
|
||||
delivered[0]?.payload as never,
|
||||
{
|
||||
container: inProject,
|
||||
} as never,
|
||||
)
|
||||
const again = (await (await fetch(`${platform.url}/__e2e/state`)).json()) as typeof state
|
||||
expect(
|
||||
again.knowledge.filter((call) => call.route === "remove" && call.documentRef?.endsWith(storageKey)),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("removes it once too when a person's removal is still waiting as the file is deleted (F407, review R1)", async () => {
|
||||
const { fileId, storageKey } = await indexedFile()
|
||||
// A member removes the document: it is cancelled and its removal is published...
|
||||
await call.callByName("knowledge.document.remove", {
|
||||
org: "acme",
|
||||
workspace: "ops",
|
||||
project: "site",
|
||||
collectionName: "contracts",
|
||||
fileId,
|
||||
})
|
||||
// ...and the file is deleted before the relay has delivered that removal, so its row is gone when the job runs.
|
||||
await beginDelete(filesDeps(runtime.container), project, fileId, project.actor.userId)
|
||||
expect(await finishDeleteRecord(runtime.data, project, fileId)).toBe(true)
|
||||
await relay.drain()
|
||||
await relay.drain()
|
||||
expect(failures).toEqual([])
|
||||
await expect.poll(async () => (await removeJob(fileId))[0]?.state, { timeout: 30_000 }).toBe("completed")
|
||||
expect(await removeJob(fileId)).toEqual([{ state: "completed", retry_count: 0 }])
|
||||
expect(await removalsOf(storageKey)).toEqual([
|
||||
expect.objectContaining({ documentRef: platformBlobPathname(project, storageKey) }),
|
||||
])
|
||||
expect(
|
||||
(await runtime.data.forScope(project).of(knowledge.documents).list({})).items.filter(
|
||||
(row) => row.fileId === fileId,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("delivers a stored PDF to knowledge's files processor, which enqueues the automatic intake in the upload's project", async () => {
|
||||
const fileId = newId()
|
||||
const sha256 = "a".repeat(64)
|
||||
await runtime.data.unitOfWork.run(project, (tx) =>
|
||||
tx.publish(fileStored, {
|
||||
orgId: project.orgId,
|
||||
workspaceId: project.workspaceId,
|
||||
projectId: project.projectId,
|
||||
fileId,
|
||||
storageKey: `files/${project.workspaceId}/${project.projectId}/${fileId}`,
|
||||
contentType: "application/pdf",
|
||||
size: 10,
|
||||
sha256,
|
||||
uploadedBy: project.actor.userId,
|
||||
recovered: false,
|
||||
}),
|
||||
)
|
||||
await relay.drain()
|
||||
expect(failures).toEqual([])
|
||||
expect(await queued(knowledge.AUTO_INDEX_JOB)).toMatchObject([
|
||||
{
|
||||
scope: {
|
||||
level: "project",
|
||||
orgId: project.orgId,
|
||||
workspaceId: project.workspaceId,
|
||||
projectId: project.projectId,
|
||||
},
|
||||
payload: { fileId, contentType: "application/pdf", sha256 },
|
||||
},
|
||||
])
|
||||
})
|
||||
it("exports and erases intake attribution through the registered account share under the runtime role", async () => {
|
||||
const share = accountDataOf(runtime.registry).find((entry) => entry.key === "knowledge")
|
||||
if (!share?.export || !share.erase) throw new Error("Knowledge account share is missing")
|
||||
const scopes: Array<typeof project> = []
|
||||
for (const slug of ["account-own", "account-other"]) {
|
||||
const { projectId } = (await call.callByName("tenancy.project.create", {
|
||||
org: "acme",
|
||||
workspace: "ops",
|
||||
slug,
|
||||
name: slug,
|
||||
moduleKey: "notes",
|
||||
})) as { projectId: string }
|
||||
scopes.push({ ...project, projectId })
|
||||
}
|
||||
const own = scopes[0]
|
||||
const other = scopes[1]
|
||||
if (!own || !other) throw new Error("Missing account project fixtures")
|
||||
const { autoIndex } = knowledge
|
||||
for (const [scope, enabledBy] of [
|
||||
[own, project.actor.userId],
|
||||
[other, "another-person"],
|
||||
] as const) {
|
||||
await runtime.data.forScope(scope).of(autoIndex).insert({
|
||||
collectionName: "contracts",
|
||||
enabledBy,
|
||||
enabledAt: "2026-11-11T00:00:00.000Z",
|
||||
})
|
||||
}
|
||||
const context = { subject: project.actor.userId, container: runtime.container }
|
||||
const exported = await share.export(context)
|
||||
if (!exported) throw new Error("Knowledge account export is missing")
|
||||
expect(exported.automaticIndexing).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ orgId: own.orgId, projectId: own.projectId })]),
|
||||
)
|
||||
expect(exported.automaticIndexing).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ projectId: other.projectId })]),
|
||||
)
|
||||
await share.erase(context)
|
||||
await share.erase(context)
|
||||
expect((await runtime.data.forScope(own).of(autoIndex).list()).items).toEqual([])
|
||||
expect((await runtime.data.forScope(other).of(autoIndex).list()).items).toMatchObject([
|
||||
{ enabledBy: "another-person" },
|
||||
])
|
||||
})
|
||||
// kit:knowledge:end
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
// The first path segment is an organization's slug (`src/routes/$org*`), so every other top-level route
|
||||
// the web shell mounts must be a slug no organization can take: kit-tenancy refuses the slugs in
|
||||
// RESERVED_SLUGS when an organization is created. A new top-level route that is not reserved would
|
||||
// let an organization of that name shadow it, or the route shadow the organization (R8: `/members`).
|
||||
// `server.mjs` serves the build's static files before any route, so a folder of those counts as well.
|
||||
// The same holds one level down: the third segment is a project's slug, so a static route beside
|
||||
// `$org.$workspace.$project` (`modules`, F10's pages above a project) is a slug no project can take.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { RESERVED_PROJECT_SLUGS, RESERVED_SLUGS } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const routesDir = new URL("../apps/web/src/routes/", import.meta.url)
|
||||
const publicDir = new URL("../apps/web/public/", import.meta.url)
|
||||
|
||||
/** A route file's first URL segment: `_layout.` and `(group).` add none, so what follows them is the route. */
|
||||
const firstSegment = (file: string): string | undefined =>
|
||||
file
|
||||
.replace(/\.tsx?$/, "")
|
||||
.split(".")
|
||||
.find((part) => !/^[_(]/.test(part))
|
||||
|
||||
/** The routes' top-level segments: not `$param`, `__root` or `index`. */
|
||||
const startSegments = readdirSync(routesDir)
|
||||
.map(firstSegment)
|
||||
.filter((segment): segment is string => segment !== undefined && !segment.startsWith("$") && segment !== "index")
|
||||
|
||||
/** Folders `dist/client` holds: Vite's own (its default `assetsDir`) and whatever `public/` brings. */
|
||||
const staticSegments = [
|
||||
"assets",
|
||||
...(existsSync(publicDir) ? readdirSync(publicDir, { withFileTypes: true }) : [])
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name),
|
||||
]
|
||||
|
||||
const routeSegments = [...new Set([...startSegments, ...staticSegments])]
|
||||
|
||||
/** The static segments beside a project: `$org.$workspace.<segment>...`, but not the workspace's own `index`. */
|
||||
const workspaceSegments = [
|
||||
...new Set(
|
||||
readdirSync(routesDir)
|
||||
.map((file) => /^\$org\.\$workspace\.([^$.][^.]*)\./.exec(file)?.[1])
|
||||
.filter((segment): segment is string => segment !== undefined && segment !== "index"),
|
||||
),
|
||||
]
|
||||
|
||||
describe("top-level routes", () => {
|
||||
it("finds the routes the shell mounts, or this suite proves nothing", () => {
|
||||
// Each is a route R8's rule applies to; the list is what apps/web/ROUTES.md names at the top level.
|
||||
expect(startSegments).toEqual(
|
||||
expect.arrayContaining(["api", "auth", "invitations", "members", "onboarding", "settings"]),
|
||||
)
|
||||
})
|
||||
|
||||
it("reads a route behind a pathless layout as the route it is", () => {
|
||||
expect(firstSegment("_public.pricing.tsx")).toBe("pricing")
|
||||
expect(firstSegment("(marketing).pricing.tsx")).toBe("pricing")
|
||||
expect(firstSegment("__root.tsx")).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(routeSegments)("reserves /%s from organization slugs", (segment) => {
|
||||
expect(RESERVED_SLUGS).toContain(segment)
|
||||
})
|
||||
})
|
||||
|
||||
describe("routes beside a project", () => {
|
||||
it("finds the route of a module's pages above a project, or this suite proves nothing", () => {
|
||||
expect(workspaceSegments).toContain("modules")
|
||||
})
|
||||
|
||||
it.each(workspaceSegments)("reserves /<org>/<workspace>/%s from project slugs", (segment) => {
|
||||
expect(RESERVED_PROJECT_SLUGS).toContain(segment)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What this app hands the mount (apps/web/src/server/router.ts): its readiness per profile, its title
|
||||
// and its live stream. The mount itself is `@conusai-kit/host/http` and its tests.
|
||||
|
||||
import { appBrand, PROFILE } from "@app/config"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const app = vi.hoisted(() => ({
|
||||
runtime: {} as Record<string, unknown>,
|
||||
ready: { ready: false, checks: { store: false, reader: false, migrations: false, platform: false } },
|
||||
}))
|
||||
|
||||
vi.mock("../apps/web/src/server/app", () => ({
|
||||
convexCapabilityStatus: async () => ({ ready: true }),
|
||||
postgresHost: () => ({ readiness: async () => app.ready }),
|
||||
webRuntime: () => app.runtime,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/request", () => ({
|
||||
requestContext: async () => ({ principal: null, caller: {}, correlationId: "correlation" }),
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/live", () => ({ serveLive: async () => new Response("the live stream") }))
|
||||
|
||||
const handled = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||
const { handle } = await import("../apps/web/src/server/router")
|
||||
return handle(new Request(`https://app.example.test${path}`, init))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
app.runtime = {
|
||||
env: { deployment: { CONUSAI_API_URL: "http://127.0.0.1:9/api" } },
|
||||
observe: { log: { warn: () => undefined } },
|
||||
registry: { kit: "0.1.0", procedures: new Map() },
|
||||
}
|
||||
})
|
||||
|
||||
describe("this app's mount", () => {
|
||||
it("serves its OpenAPI document under the app brand's name", async () => {
|
||||
const document = (await (await handled("/api/openapi.json")).json()) as { info: { title: string } }
|
||||
expect(document.info.title).toBe(`${appBrand.name} API`)
|
||||
})
|
||||
|
||||
it("hands the live stream to the app's own server", async () => {
|
||||
const answer = await handled("/api/v1/orgs/acme/live", { method: "POST" })
|
||||
expect(await answer.text()).toBe("the live stream")
|
||||
})
|
||||
|
||||
it.runIf(PROFILE === "postgres")("answers /api/ready from the host, 503 while it is not ready", async () => {
|
||||
const answer = await handled("/api/ready")
|
||||
expect(answer.status).toBe(503)
|
||||
expect(await answer.json()).toEqual(app.ready)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
//
|
||||
// The software bill of materials of the built image and the THIRD-PARTY-NOTICES generated from it
|
||||
// (plan item AT5.1): the SBOM reads what is installed in a pnpm store (a runtime dependency and not a
|
||||
// link to one, once per version), and the notices list every component of it, with each licence text
|
||||
// printed once however many packages share it. The committed THIRD-PARTY-NOTICES is checked against
|
||||
// the dependencies this template declares.
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { dpkgComponents, npmComponents, sbom } from "../scripts/sbom.mjs"
|
||||
import { notices, unlisted } from "../scripts/third-party-notices.mjs"
|
||||
|
||||
const APP_ROOT = new URL("..", import.meta.url).pathname
|
||||
|
||||
const made: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const MIT = "MIT License\n\nCopyright (c) 2020 Someone\n\nPermission is hereby granted, free of charge, ..."
|
||||
|
||||
/** A pnpm virtual store: each entry holds its own package and links to the packages it depends on. */
|
||||
function image(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "sbom-"))
|
||||
made.push(root)
|
||||
const put = (path: string, text: string) => {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true })
|
||||
writeFileSync(join(root, path), text)
|
||||
}
|
||||
const store = "node_modules/.pnpm"
|
||||
put("package.json", JSON.stringify({ name: "demo-app", version: "1.2.3" }))
|
||||
put(
|
||||
`${store}/alpha@1.0.0/node_modules/alpha/package.json`,
|
||||
JSON.stringify({ name: "alpha", version: "1.0.0", license: "MIT" }),
|
||||
)
|
||||
put(`${store}/alpha@1.0.0/node_modules/alpha/LICENSE`, MIT)
|
||||
put(
|
||||
`${store}/beta@2.0.0/node_modules/beta/package.json`,
|
||||
JSON.stringify({ name: "beta", version: "2.0.0", license: "MIT" }),
|
||||
)
|
||||
put(`${store}/beta@2.0.0/node_modules/beta/LICENSE.md`, MIT)
|
||||
// the same version under two peer sets is one component
|
||||
put(
|
||||
`${store}/beta@2.0.0_react@19/node_modules/beta/package.json`,
|
||||
JSON.stringify({ name: "beta", version: "2.0.0", license: "MIT" }),
|
||||
)
|
||||
put(
|
||||
`${store}/@scope+gamma@3.0.0/node_modules/@scope/gamma/package.json`,
|
||||
JSON.stringify({
|
||||
name: "@scope/gamma",
|
||||
version: "3.0.0",
|
||||
license: { type: "ISC" },
|
||||
repository: { url: "git+https://example.test/gamma.git" },
|
||||
}),
|
||||
)
|
||||
put(
|
||||
`${store}/@conusai-kit+core@0.1.0/node_modules/@conusai-kit/core/package.json`,
|
||||
JSON.stringify({ name: "@conusai-kit/core", version: "0.1.0", license: "LicenseRef-ConusAI-Template-1.0" }),
|
||||
)
|
||||
put(
|
||||
`${store}/delta@4.0.0/node_modules/delta/package.json`,
|
||||
JSON.stringify({ name: "delta", version: "4.0.0", license: "(MIT OR Apache-2.0)" }),
|
||||
)
|
||||
// alpha depends on beta: a link inside alpha's entry, which is not a second copy of beta
|
||||
symlinkSync(join(root, `${store}/beta@2.0.0/node_modules/beta`), join(root, `${store}/alpha@1.0.0/node_modules/beta`))
|
||||
return root
|
||||
}
|
||||
|
||||
describe("the SBOM of an image", () => {
|
||||
it("holds each installed package once, with its licence and the text of its licence file", () => {
|
||||
const components = npmComponents(image())
|
||||
expect(components.map((component) => component["bom-ref"])).toEqual([
|
||||
"pkg:npm/%40conusai-kit/core@0.1.0",
|
||||
"pkg:npm/%40scope/gamma@3.0.0",
|
||||
"pkg:npm/alpha@1.0.0",
|
||||
"pkg:npm/beta@2.0.0",
|
||||
"pkg:npm/delta@4.0.0",
|
||||
])
|
||||
const alpha = components.find((component) => component.name === "alpha")
|
||||
expect(alpha?.licenses[0]?.license).toMatchObject({ id: "MIT", text: { content: MIT } })
|
||||
expect(components.find((component) => component.name === "gamma")).toMatchObject({
|
||||
group: "@scope",
|
||||
licenses: [{ license: { id: "ISC" } }],
|
||||
externalReferences: [{ type: "vcs", url: "https://example.test/gamma" }],
|
||||
})
|
||||
expect(components.find((component) => component.name === "delta")?.licenses).toEqual([
|
||||
{ expression: "(MIT OR Apache-2.0)" },
|
||||
])
|
||||
})
|
||||
|
||||
it("marks the ConusAI packages, whose licence is LICENSE-TEMPLATE, and names their licence as a reference", () => {
|
||||
const core = npmComponents(image()).find((component) => component.name === "core")
|
||||
expect(core?.properties).toContainEqual({ name: "conusai:component", value: "true" })
|
||||
expect(core?.licenses[0]?.license).toEqual({ name: "LicenseRef-ConusAI-Template-1.0" })
|
||||
})
|
||||
|
||||
it("is a CycloneDX 1.5 document with the application, the Node.js runtime and the packages", () => {
|
||||
const document = sbom({ root: image(), dpkg: "/nonexistent", nodeVersion: "v24.1.0" })
|
||||
expect(document).toMatchObject({
|
||||
bomFormat: "CycloneDX",
|
||||
specVersion: "1.5",
|
||||
metadata: { component: { type: "application", name: "app", version: "0.0.0" } },
|
||||
})
|
||||
expect(document.components[0]).toMatchObject({ type: "platform", name: "node", version: "24.1.0" })
|
||||
expect(document.components).toHaveLength(6)
|
||||
})
|
||||
|
||||
it("reads the operating system packages that are installed, and no others", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "dpkg-"))
|
||||
made.push(dir)
|
||||
const status = join(dir, "status")
|
||||
writeFileSync(
|
||||
status,
|
||||
[
|
||||
"Package: libc6\nStatus: install ok installed\nVersion: 2.41-1\nArchitecture: amd64",
|
||||
"Package: gone\nStatus: deinstall ok config-files\nVersion: 1.0",
|
||||
"Package: zlib1g\nStatus: install ok installed\nVersion: 1:1.3.dfsg-1",
|
||||
"",
|
||||
].join("\n\n"),
|
||||
)
|
||||
const components = dpkgComponents(status, dir)
|
||||
expect(components.map((component) => `${component.name} ${component.version}`)).toEqual([
|
||||
"libc6 2.41-1",
|
||||
"zlib1g 1:1.3.dfsg-1",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("THIRD-PARTY-NOTICES generated from an SBOM", () => {
|
||||
const document = () =>
|
||||
sbom({ root: image(), dpkg: "/nonexistent", name: "demo-app", version: "1.2.3", nodeVersion: "v24.1.0" })
|
||||
|
||||
it("lists every component, and prints a licence text shared by several packages once", () => {
|
||||
const text = notices(document())
|
||||
expect(unlisted(document(), text)).toEqual([])
|
||||
expect(text).toContain("@conusai-kit/core@0.1.0")
|
||||
expect(text).toContain("node@24.1.0")
|
||||
expect(text.split("Permission is hereby granted").length - 1).toBe(1)
|
||||
expect(text).toContain("alpha@1.0.0")
|
||||
expect(text).toContain("beta@2.0.0")
|
||||
expect(text).toContain("these packages ship no licence file; they declare ISC")
|
||||
})
|
||||
|
||||
it("is the same text every time it is generated from the same SBOM", () => {
|
||||
expect(notices(document())).toBe(notices(document()))
|
||||
})
|
||||
|
||||
it("is checked against the SBOM: a component it does not list is named", () => {
|
||||
const text = notices(document())
|
||||
const more = document()
|
||||
more.components.push({
|
||||
type: "library",
|
||||
"bom-ref": "pkg:npm/epsilon@5.0.0",
|
||||
name: "epsilon",
|
||||
version: "5.0.0",
|
||||
purl: "pkg:npm/epsilon@5.0.0",
|
||||
licenses: [],
|
||||
})
|
||||
expect(unlisted(more, text)).toEqual(["epsilon@5.0.0"])
|
||||
// a version that moved is drift by name, not a missing component
|
||||
const moved = document()
|
||||
moved.components = moved.components.map((component) =>
|
||||
component.name === "alpha" ? { ...component, version: "1.0.1" } : component,
|
||||
)
|
||||
expect(unlisted(moved, text)).toEqual(["alpha@1.0.1"])
|
||||
expect(unlisted(moved, text, { byName: true })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the committed THIRD-PARTY-NOTICES", () => {
|
||||
const text = readFileSync(join(APP_ROOT, "THIRD-PARTY-NOTICES"), "utf8")
|
||||
|
||||
it("says what it is generated from, and that it is not edited by hand", () => {
|
||||
expect(text).toContain("software bill of materials of the built image")
|
||||
expect(text).toContain("do not edit it by hand")
|
||||
})
|
||||
|
||||
it("lists every runtime dependency this template declares, by name", () => {
|
||||
const declared = new Set<string>()
|
||||
for (const path of [
|
||||
"package.json",
|
||||
"apps/web/package.json",
|
||||
"apps/worker/package.json",
|
||||
"config/package.json",
|
||||
"modules/package.json",
|
||||
]) {
|
||||
const manifest = JSON.parse(readFileSync(join(APP_ROOT, path), "utf8")) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
for (const name of Object.keys(manifest.dependencies ?? {})) if (!name.startsWith("@app/")) declared.add(name)
|
||||
}
|
||||
expect(declared.size).toBeGreaterThan(15)
|
||||
const missing = [...declared].filter((name) => !text.includes(` ${name}@`))
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The demo data of a development run (apps/worker/src/seed.ts): two organizations, one with a tenant
|
||||
// brand, written with the kit's own use cases as the people who may write them, and written once --
|
||||
// a second run changes nothing. The data source is the in-memory one, which the kit proves to behave
|
||||
// as Postgres does (the adapter contract suite); the procedures, the permission checks and the
|
||||
// invitation flow are the real ones.
|
||||
|
||||
import { DEV_LOGIN_ISSUER } from "@conusai-kit/auth"
|
||||
import { users } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { DEMO_ORGANIZATIONS, DEMO_PEOPLE, seedDemo } from "../apps/worker/src/seed.js"
|
||||
import { makeNotesApp } from "./support/notes-app.js"
|
||||
|
||||
const seeded = async () => {
|
||||
const app = makeNotesApp()
|
||||
const deps = { data: app.data, registry: app.registry, container: app.root }
|
||||
return { app, deps, summary: await seedDemo(deps) }
|
||||
}
|
||||
|
||||
describe("the demo data", () => {
|
||||
it("makes two organizations, and the people in them with the roles the demo describes", async () => {
|
||||
const { app, summary } = await seeded()
|
||||
expect(summary.organizations.map((org) => org.slug)).toEqual(["acme-review", "northwind"])
|
||||
|
||||
const rolesOf = async (email: string) => {
|
||||
const rows = await app.data.global(users).list({ where: [{ field: "email", op: "eq", value: email }], limit: 1 })
|
||||
const me = (await app.caller({ userId: rows.items[0]?.id ?? "" }).callByName("tenancy.me.get", undefined)) as {
|
||||
organizations: Array<{ slug: string; role: string }>
|
||||
}
|
||||
return Object.fromEntries(me.organizations.map((org) => [org.slug, org.role]))
|
||||
}
|
||||
expect(await rolesOf("ada@example.test")).toEqual({ "acme-review": "owner", northwind: "admin" })
|
||||
expect(await rolesOf("grace@example.test")).toEqual({ "acme-review": "member", northwind: "owner" })
|
||||
expect(await rolesOf("linus@example.test")).toEqual({ "acme-review": "viewer" })
|
||||
})
|
||||
|
||||
it("gives exactly one of the organizations a tenant brand", async () => {
|
||||
const { app } = await seeded()
|
||||
const grace = (
|
||||
await app.data
|
||||
.global(users)
|
||||
.list({ where: [{ field: "email", op: "eq", value: "grace@example.test" }], limit: 1 })
|
||||
).items[0]
|
||||
const call = app.caller({ userId: grace?.id ?? "" })
|
||||
expect(await call.callByName("branding.brand.get", { org: "northwind" })).toMatchObject({
|
||||
displayName: "Northwind",
|
||||
primary: "#1a1a8c",
|
||||
accent: "#f4c430",
|
||||
})
|
||||
expect(await call.callByName("branding.brand.get", { org: "acme-review" })).toMatchObject({
|
||||
displayName: null,
|
||||
primary: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("puts a project and two notes where a module's pages can show them", async () => {
|
||||
const { app } = await seeded()
|
||||
const ada = (
|
||||
await app.data.global(users).list({ where: [{ field: "email", op: "eq", value: "ada@example.test" }], limit: 1 })
|
||||
).items[0]
|
||||
const call = app.caller({ userId: ada?.id ?? "" })
|
||||
const listed = (await call.callByName("notes.note.list", {
|
||||
org: "acme-review",
|
||||
workspace: "general",
|
||||
project: "notes",
|
||||
})) as { notes: Array<{ title: string }> }
|
||||
expect(listed.notes.map((note) => note.title).sort()).toEqual(["Roles", "Welcome"])
|
||||
})
|
||||
|
||||
it("creates the people as a sign-in does, under the dev-login issuer, so the dev sign-in lets them in", async () => {
|
||||
const { app } = await seeded()
|
||||
const rows = await app.data.global(users).list({ limit: 10 })
|
||||
expect(rows.items.map((user) => [user.issuer, user.subject, user.email]).sort()).toEqual(
|
||||
DEMO_PEOPLE.map((person) => [DEV_LOGIN_ISSUER, person.email, person.email]).sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it("changes nothing when it runs again", async () => {
|
||||
const { app, deps, summary } = await seeded()
|
||||
expect(summary.created.length).toBeGreaterThan(5)
|
||||
const before = JSON.stringify({
|
||||
users: (await app.data.global(users).list({ limit: 50 })).items.map((user) => [user.email, user.version]),
|
||||
outbox: (await app.data.outbox.pending(500)).length,
|
||||
})
|
||||
|
||||
const again = await seedDemo(deps)
|
||||
|
||||
expect(again.created).toEqual([])
|
||||
expect(
|
||||
JSON.stringify({
|
||||
users: (await app.data.global(users).list({ limit: 50 })).items.map((user) => [user.email, user.version]),
|
||||
outbox: (await app.data.outbox.pending(500)).length,
|
||||
}),
|
||||
).toBe(before)
|
||||
})
|
||||
|
||||
it("names the organizations the way the README and the sign-in help do", () => {
|
||||
expect(DEMO_ORGANIZATIONS.acme.slug).toBe("acme-review")
|
||||
expect(DEMO_ORGANIZATIONS.northwind.brand.displayName).toBe("Northwind")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The brand as a share image and an email show it (spec §10.2, §10.3): the app brand's name and logo, or an
|
||||
// organization's, drawn through the kit's logo checks. Every logo here goes the way it goes in production: an
|
||||
// organization's through the caller (so a stored, normalised file), the app's from a file of a public folder.
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
type BrandView,
|
||||
type ComposedEmail,
|
||||
composeEmail,
|
||||
type MailTransport,
|
||||
renderShareImage,
|
||||
type ShareBrand,
|
||||
sendEmail,
|
||||
shareBrand,
|
||||
} from "@app/modules/branding"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { appLogoSource, readersFor } from "../apps/web/src/server/brand-assets"
|
||||
import { closeApps, makeDocumentsApp, type SeededProject } from "./support/documents-app.js"
|
||||
import { dimensions, markPng, pixelAt } from "./support/images.js"
|
||||
|
||||
const directories: string[] = []
|
||||
afterEach(async () => {
|
||||
await closeApps()
|
||||
for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const TEAL = [0x80, 0xcd, 0xc6, 255]
|
||||
const svg = (fill: string) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 80"><rect width="300" height="80" fill="${fill}"/></svg>`
|
||||
|
||||
/** A public folder holding the app brand's two logos: a blue rectangle for light, a red one for dark. */
|
||||
function publicFolder(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "share-public-"))
|
||||
directories.push(dir)
|
||||
writeFileSync(join(dir, "app-light.svg"), svg("#3366aa"))
|
||||
writeFileSync(join(dir, "app-dark.svg"), svg("#aa3333"))
|
||||
return dir
|
||||
}
|
||||
|
||||
const appIdentity = { name: "Example App", logo: { light: "/app-light.svg", dark: "/app-dark.svg" } }
|
||||
const empty: BrandView = { displayName: null, primary: null, accent: null, logoLight: null, logoDark: null }
|
||||
|
||||
async function acme(): Promise<{ project: SeededProject }> {
|
||||
const app = await makeDocumentsApp({
|
||||
document: { engine: { name: "unused", version: "0" }, page_count: 0, pages: [] },
|
||||
})
|
||||
return { project: await app.seedProject("acme", "owner@acme.test") }
|
||||
}
|
||||
|
||||
const upload = (project: SeededProject, variant: "light" | "dark", bytes: Uint8Array) =>
|
||||
project.call.callByName("branding.logo.set", {
|
||||
org: project.orgSlug,
|
||||
variant,
|
||||
contentType: "image/png",
|
||||
body: Buffer.from(bytes).toString("base64"),
|
||||
})
|
||||
|
||||
// The logo box of the share image: 300 x 80 at (80, 80); a wide logo's middle is at (230, 120).
|
||||
const logoCentre = (png: Uint8Array) => pixelAt(png, 230, 120)
|
||||
|
||||
describe("the app's own logo", () => {
|
||||
it("is the kit's mark when the public folder holds no file of that name", async () => {
|
||||
const { contentType, bytes } = await appLogoSource("/favicon.png", new URL(".", import.meta.url).pathname)
|
||||
expect(contentType).toBe("image/png")
|
||||
expect(Buffer.from(bytes).subarray(1, 4).toString()).toBe("PNG")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the app brand's logo from the public folder", () => {
|
||||
it("is read from a file of the folder and from nowhere else", async () => {
|
||||
const dir = publicFolder()
|
||||
expect(await appLogoSource("/app-dark.svg", dir)).toMatchObject({ contentType: "image/svg+xml" })
|
||||
await expect(appLogoSource("/../secret.svg", dir)).rejects.toThrow(/public folder/)
|
||||
await expect(appLogoSource("/app.exe", dir)).rejects.toThrow(/PNG, WebP or SVG/)
|
||||
await expect(appLogoSource("/missing.svg", dir)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("the share image", () => {
|
||||
it("draws the app brand's logo for the dark page when there is no organization", async () => {
|
||||
const dir = publicFolder()
|
||||
const share = await shareBrand({
|
||||
app: appIdentity,
|
||||
tenant: null,
|
||||
readers: { appLogo: (path) => appLogoSource(path, dir), tenantLogo: () => Promise.reject(new Error("none")) },
|
||||
})
|
||||
expect(share.name).toBe("Example App")
|
||||
const png = await renderShareImage(share, { title: share.name })
|
||||
expect(dimensions(png)).toEqual({ width: 1200, height: 630 })
|
||||
expect(logoCentre(png)).toEqual([0xaa, 0x33, 0x33, 255])
|
||||
})
|
||||
|
||||
it("draws an organization's own logo and display name, and the logo is the stored one", async () => {
|
||||
const { project } = await acme()
|
||||
await project.call.callByName("branding.brand.set", { org: project.orgSlug, displayName: "Acme Review" })
|
||||
await upload(project, "dark", markPng(2000, 500))
|
||||
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
||||
const share = await shareBrand({
|
||||
app: appIdentity,
|
||||
tenant: { name: "Acme Ltd", brand },
|
||||
readers: readersFor(project.call, project.orgSlug, publicFolder()),
|
||||
})
|
||||
expect(share.name).toBe("Acme Review")
|
||||
expect(share.logo.contentType).toBe("image/png")
|
||||
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual(TEAL)
|
||||
})
|
||||
|
||||
it("draws the only logo the organization has when it is the light one, because the page is dark", async () => {
|
||||
const { project } = await acme()
|
||||
await upload(project, "light", markPng(2000, 500))
|
||||
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
||||
expect(brand.logoDark).toBeNull()
|
||||
const share = await shareBrand({
|
||||
app: appIdentity,
|
||||
tenant: { name: "Acme Ltd", brand },
|
||||
readers: readersFor(project.call, project.orgSlug, publicFolder()),
|
||||
})
|
||||
expect(share.name).toBe("Acme Ltd")
|
||||
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual(TEAL)
|
||||
})
|
||||
|
||||
it("is not stopped by a missing app logo file when the organization has a good logo of its own", async () => {
|
||||
const { project } = await acme()
|
||||
await upload(project, "dark", markPng(2000, 500))
|
||||
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
||||
const share = await shareBrand({
|
||||
app: appIdentity,
|
||||
tenant: { name: "Acme Ltd", brand },
|
||||
readers: readersFor(project.call, project.orgSlug, mkdtempSync(join(tmpdir(), "share-nofile-"))),
|
||||
})
|
||||
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual(TEAL)
|
||||
})
|
||||
|
||||
it("falls back to the app brand's logo for an organization with none", async () => {
|
||||
const { project } = await acme()
|
||||
const share = await shareBrand({
|
||||
app: appIdentity,
|
||||
tenant: { name: "Acme Ltd", brand: empty },
|
||||
readers: readersFor(project.call, project.orgSlug, publicFolder()),
|
||||
})
|
||||
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual([0xaa, 0x33, 0x33, 255])
|
||||
})
|
||||
|
||||
it("draws the app brand's logo instead when the chosen one cannot be drawn, and does not fail", async () => {
|
||||
const dir = publicFolder()
|
||||
const broken: ShareBrand = {
|
||||
name: "Acme Ltd",
|
||||
logo: { contentType: "image/png", bytes: new TextEncoder().encode("<svg/>") },
|
||||
fallback: await appLogoSource("/app-dark.svg", dir),
|
||||
}
|
||||
expect(logoCentre(await renderShareImage(broken, { title: broken.name }))).toEqual([0xaa, 0x33, 0x33, 255])
|
||||
const refused: ShareBrand = { ...broken, fallback: broken.logo }
|
||||
await expect(renderShareImage(refused, { title: "x" })).rejects.toThrow(/logo cannot be used/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the email", () => {
|
||||
const recorded = () => {
|
||||
const sent: Array<ComposedEmail & { to: string }> = []
|
||||
const transport: MailTransport = {
|
||||
async send(message) {
|
||||
sent.push(message)
|
||||
},
|
||||
}
|
||||
return { sent, transport }
|
||||
}
|
||||
|
||||
async function shareOf(logo: Uint8Array | null, dir = publicFolder()): Promise<ShareBrand> {
|
||||
const { project } = await acme()
|
||||
if (logo) await upload(project, "dark", logo)
|
||||
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
||||
return shareBrand({
|
||||
app: appIdentity,
|
||||
tenant: { name: "Acme Ltd", brand: { ...brand, displayName: "Acme Review" } },
|
||||
readers: readersFor(project.call, project.orgSlug, dir),
|
||||
})
|
||||
}
|
||||
|
||||
it("carries the organization's name and its logo as an inline attachment, and names no host", async () => {
|
||||
const mail = await composeEmail(await shareOf(markPng(2000, 500)), {
|
||||
subject: "You are invited",
|
||||
paragraphs: ["Hello,", "Open the app to accept."],
|
||||
})
|
||||
expect(mail.subject).toBe("You are invited - Acme Review")
|
||||
expect(mail.html).toContain('alt="Acme Review"')
|
||||
expect(mail.html).toContain('src="cid:brand-logo"')
|
||||
expect(mail.html).not.toMatch(/https?:/)
|
||||
expect(mail.html.toLowerCase()).not.toContain("conusai")
|
||||
expect(mail.text).toBe("Hello,\n\nOpen the app to accept.\n\nAcme Review\n")
|
||||
const [logo] = mail.attachments
|
||||
expect(logo).toMatchObject({ cid: "brand-logo", contentType: "image/png" })
|
||||
expect(dimensions(logo?.content ?? new Uint8Array())).toEqual({ width: 420, height: 105 })
|
||||
expect(pixelAt(logo?.content ?? new Uint8Array(), 210, 52)).toEqual(TEAL)
|
||||
})
|
||||
|
||||
it("uses the app brand's logo for an organization with none", async () => {
|
||||
const mail = await composeEmail(await shareOf(null), { subject: "Hi", paragraphs: ["x"] })
|
||||
const content = mail.attachments[0]?.content ?? new Uint8Array()
|
||||
expect(dimensions(content).width).toBe(420)
|
||||
expect(pixelAt(content, 210, 52)).toEqual([0xaa, 0x33, 0x33, 255])
|
||||
})
|
||||
|
||||
it("escapes what it is given as text, and refuses a subject that is more than one line", async () => {
|
||||
const share = await shareOf(null)
|
||||
const mail = await composeEmail(share, { subject: "x", paragraphs: ['<script>alert("1")</script> & more'] })
|
||||
expect(mail.html).toContain("<script>alert("1")</script> & more")
|
||||
expect(mail.html).not.toContain("<script>")
|
||||
await expect(composeEmail(share, { subject: "x\r\nBcc: someone@example.test", paragraphs: ["x"] })).rejects.toThrow(
|
||||
/one line/,
|
||||
)
|
||||
await expect(composeEmail(share, { subject: "x", paragraphs: [] })).rejects.toThrow(/paragraph/)
|
||||
})
|
||||
|
||||
it("is handed to the transport with one address, and refuses an address that carries a header", async () => {
|
||||
const share = await shareOf(null)
|
||||
const { sent, transport } = recorded()
|
||||
await sendEmail(transport, share, "member@acme.test", { subject: "Welcome", paragraphs: ["Hello"] })
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toMatchObject({ to: "member@acme.test", subject: "Welcome - Acme Review" })
|
||||
for (const to of ["a@b.test\r\nBcc: c@d.test", "a@b.test, c@d.test", "no-address", "a b@c.test"]) {
|
||||
await expect(sendEmail(transport, share, to, { subject: "x", paragraphs: ["x"] })).rejects.toThrow(
|
||||
/one email address/,
|
||||
)
|
||||
}
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a loader does with a refusal on TanStack Start (F10): a real 302, 404 or 403, set before anything
|
||||
// streams. Start has no `forbidden()`, so the 403 is a not-found with `data.forbidden` plus the header the
|
||||
// server entry turns into the status.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { isNotFound, isRedirect } from "@tanstack/react-router"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const start = vi.hoisted(() => ({
|
||||
setResponseStatus: vi.fn<(status: number) => void>(),
|
||||
setResponseHeader: vi.fn<(name: string, value: string) => void>(),
|
||||
}))
|
||||
vi.mock("@tanstack/react-start/server", () => start)
|
||||
|
||||
const { orFallback, PAGE_STATUS_HEADER, pageForbidden, pageNotFound, signInHref, toSignIn } = await import(
|
||||
"../apps/web/src/server/guard"
|
||||
)
|
||||
|
||||
const refusal = (status: number): AppError =>
|
||||
new AppError({ type: "/problems/refused", code: "refused", title: "Refused", status })
|
||||
const thrownBy = async (run: () => unknown): Promise<unknown> => {
|
||||
try {
|
||||
await run()
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
throw new Error("it did not throw")
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
start.setResponseStatus.mockClear()
|
||||
start.setResponseHeader.mockClear()
|
||||
})
|
||||
|
||||
describe("signInHref", () => {
|
||||
it("names where to come back to, and nothing for the home page", () => {
|
||||
expect(signInHref("/acme/general/demo/notes?x=1")).toBe(
|
||||
"/auth/sign-in?return_to=%2Facme%2Fgeneral%2Fdemo%2Fnotes%3Fx%3D1",
|
||||
)
|
||||
expect(signInHref("/")).toBe("/auth/sign-in")
|
||||
expect(signInHref("/", "denied")).toBe("/auth/sign-in?error=denied")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the status pages", () => {
|
||||
it("answers a signed-out visitor with a 302 to sign in and back", async () => {
|
||||
const error = (await thrownBy(() => toSignIn("/a/b/c/notes"))) as { options: { href: string; statusCode: number } }
|
||||
expect(isRedirect(error)).toBe(true)
|
||||
expect(error.options.href).toBe("/auth/sign-in?return_to=%2Fa%2Fb%2Fc%2Fnotes")
|
||||
expect(error.options.statusCode).toBe(302)
|
||||
})
|
||||
|
||||
it("answers not found with 404, in the status and in the header the server entry applies", async () => {
|
||||
expect(isNotFound(await thrownBy(() => pageNotFound()))).toBe(true)
|
||||
expect(start.setResponseStatus).toHaveBeenCalledWith(404)
|
||||
expect(start.setResponseHeader).toHaveBeenCalledWith(PAGE_STATUS_HEADER, "404")
|
||||
})
|
||||
|
||||
it("answers forbidden with 403 and marks the not-found as the forbidden page", async () => {
|
||||
const error = (await thrownBy(() => pageForbidden())) as { data: unknown }
|
||||
expect(isNotFound(error)).toBe(true)
|
||||
expect(error.data).toEqual({ forbidden: true })
|
||||
expect(start.setResponseHeader).toHaveBeenCalledWith(PAGE_STATUS_HEADER, "403")
|
||||
})
|
||||
})
|
||||
|
||||
describe("orFallback", () => {
|
||||
it("passes an answer through", async () => {
|
||||
await expect(orFallback("/here", async () => "rows")).resolves.toBe("rows")
|
||||
expect(start.setResponseStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[404, "404"],
|
||||
[403, "403"],
|
||||
])("turns a procedure's %i into that page", async (status, header) => {
|
||||
const error = await thrownBy(() => orFallback("/here", () => Promise.reject(refusal(status))))
|
||||
expect(isNotFound(error)).toBe(true)
|
||||
expect(start.setResponseHeader).toHaveBeenCalledWith(PAGE_STATUS_HEADER, header)
|
||||
})
|
||||
|
||||
it("turns a procedure's 401 into the way to sign in", async () => {
|
||||
expect(isRedirect(await thrownBy(() => orFallback("/here", () => Promise.reject(refusal(401)))))).toBe(true)
|
||||
})
|
||||
|
||||
it("leaves anything else to the error boundary", async () => {
|
||||
const fault = new Error("database down")
|
||||
expect(await thrownBy(() => orFallback("/here", () => Promise.reject(fault)))).toBe(fault)
|
||||
expect(await thrownBy(() => orFallback("/here", () => Promise.reject(refusal(500))))).toBeInstanceOf(AppError)
|
||||
expect(start.setResponseStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a route loader reads (F10). `pageContext` and `permissionsIn` are kept per request, because React's
|
||||
// `cache` memoizes nothing outside server components, and `modulePageData` is the one module route's
|
||||
// loader: signed out is a redirect, every refusal is a real status, and a module's data passes through.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { isNotFound, isRedirect } from "@tanstack/react-router"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requestContext: vi.fn(),
|
||||
callByName: vi.fn(),
|
||||
setResponseHeader: vi.fn<(name: string, value: string) => void>(),
|
||||
load: vi.fn(),
|
||||
call: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getRequest: () => {
|
||||
throw new Error("the tests pass their request")
|
||||
},
|
||||
setResponseStatus: vi.fn(),
|
||||
setResponseHeader: mocks.setResponseHeader,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/request", () => ({ requestContext: mocks.requestContext }))
|
||||
vi.mock("../apps/web/src/server/app", () => ({
|
||||
webRuntime: () => ({
|
||||
observe: { config: { errors: false } },
|
||||
selfServiceOrganizations: "members",
|
||||
registry: {
|
||||
modules: [{ id: "notes" }, { id: "unpaged" }],
|
||||
nav: [{ module: "notes", scope: "project", href: "notes", label: "workspace.projects" }],
|
||||
},
|
||||
env: { deployment: {} },
|
||||
}),
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/tenancy-calls", () => ({ mayCreateOrganization: () => false }))
|
||||
vi.mock("../apps/web/src/server/platform-theme", () => ({
|
||||
organizationThemeStyle: async () => undefined,
|
||||
organizationIdentity: async () => ({}),
|
||||
}))
|
||||
vi.mock("@conusai-kit/host/http", () => ({ csrfSessionId: () => "session", csrfToken: () => "token" }))
|
||||
vi.mock("@app/modules/pages", () => ({ modulePages: { notes: async () => ({ load: mocks.load }) } }))
|
||||
|
||||
const { modulePageData, pageContext, permissionsIn, projectLayoutData, shellData } = await import(
|
||||
"../apps/web/src/server/page"
|
||||
)
|
||||
|
||||
const person = { actor: { kind: "user" } }
|
||||
const signedIn = () =>
|
||||
mocks.requestContext.mockResolvedValue({
|
||||
caller: { call: mocks.call, callByName: mocks.callByName },
|
||||
container: {},
|
||||
principal: person,
|
||||
user: { email: "ada@example.test", displayName: "Ada", locale: "en" },
|
||||
})
|
||||
const signedOut = () =>
|
||||
mocks.requestContext.mockResolvedValue({
|
||||
caller: { call: mocks.call, callByName: mocks.callByName },
|
||||
container: {},
|
||||
principal: null,
|
||||
})
|
||||
const request = (): Request => new Request("http://app.test/a/b/c/notes", { headers: { "x-path": "/a/b/c/notes" } })
|
||||
const params = { org: "a", workspace: "b", project: "c", module: "notes" }
|
||||
const refusal = (status: number): AppError =>
|
||||
new AppError({ type: "/problems/refused", code: "refused", title: "Refused", status })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.callByName.mockResolvedValue({ permissions: ["notes.note.read"], revision: "r1" })
|
||||
mocks.call.mockResolvedValue({})
|
||||
mocks.load.mockResolvedValue({ kind: "list", title: "Notes" })
|
||||
})
|
||||
|
||||
describe("the page context", () => {
|
||||
it("is built once per request, and again for the next one", async () => {
|
||||
signedIn()
|
||||
const first = request()
|
||||
await Promise.all([pageContext(first), pageContext(first)])
|
||||
expect(mocks.requestContext).toHaveBeenCalledOnce()
|
||||
await pageContext(request())
|
||||
expect(mocks.requestContext).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("reads the permissions of a scope once per request", async () => {
|
||||
signedIn()
|
||||
const current = request()
|
||||
await Promise.all([permissionsIn({ org: "a" }, current), permissionsIn({ org: "a" }, current)])
|
||||
expect(mocks.callByName).toHaveBeenCalledOnce()
|
||||
await permissionsIn({ org: "a", workspace: "b" }, current)
|
||||
expect(mocks.callByName).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the shell's loader data", () => {
|
||||
it("is plain values with its labels translated, and nothing a browser must not hold", async () => {
|
||||
vi.stubEnv("CONUSAI_PUBLIC_URL", "https://app.example.test")
|
||||
signedIn()
|
||||
const shell = await shellData(request())
|
||||
expect(JSON.parse(JSON.stringify(shell))).toEqual(shell)
|
||||
expect(shell.signedIn).toBe(true)
|
||||
expect(shell.labels.signIn.length).toBeGreaterThan(0)
|
||||
expect(shell.brand.style).toBeTypeOf("object")
|
||||
expect(shell.publicUrl).toBe("https://app.example.test")
|
||||
expect(JSON.stringify(shell)).not.toContain("ada@example.test")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the module route's loader", () => {
|
||||
const thrown = (run: Promise<unknown>): Promise<unknown> =>
|
||||
run.then(
|
||||
() => "resolved",
|
||||
(error: unknown) => error,
|
||||
)
|
||||
|
||||
it("sends a signed-out visitor to sign in, before reading anything", async () => {
|
||||
signedOut()
|
||||
expect(isRedirect(await thrown(modulePageData(params, request())))).toBe(true)
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
expect(mocks.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
["no registered module", { ...params, module: "nothing" }],
|
||||
["a registered module with no page", { ...params, module: "unpaged" }],
|
||||
["a name every object has", { ...params, module: "constructor" }],
|
||||
])("answers 404 for %s", async (_name, wanted) => {
|
||||
signedIn()
|
||||
expect(isNotFound(await thrown(modulePageData(wanted, request())))).toBe(true)
|
||||
expect(mocks.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[404, undefined],
|
||||
[403, { forbidden: true }],
|
||||
])("turns the module's own %i into that page", async (status, data) => {
|
||||
signedIn()
|
||||
mocks.load.mockRejectedValue(refusal(status))
|
||||
const error = (await thrown(modulePageData(params, request()))) as { data?: unknown }
|
||||
expect(isNotFound(error)).toBe(true)
|
||||
expect(error.data).toEqual(data)
|
||||
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", String(status))
|
||||
})
|
||||
|
||||
it("leaves a fault to the error boundary", async () => {
|
||||
signedIn()
|
||||
const fault = new Error("database down")
|
||||
mocks.load.mockRejectedValue(fault)
|
||||
expect(await thrown(modulePageData(params, request()))).toBe(fault)
|
||||
})
|
||||
|
||||
it("runs the module's loader with the slugs and the rest of the path", async () => {
|
||||
signedIn()
|
||||
const page = await modulePageData({ ...params, _splat: "note-1/" }, request())
|
||||
expect(mocks.load.mock.calls[0]?.[0]).toMatchObject({
|
||||
slugs: { org: "a", workspace: "b", project: "c" },
|
||||
segments: ["note-1"],
|
||||
})
|
||||
expect(page).toMatchObject({
|
||||
module: "notes",
|
||||
title: "Projects · Example App",
|
||||
segments: ["note-1"],
|
||||
data: { kind: "list", title: "Notes" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("the project route's loader", () => {
|
||||
const slugs = { org: "a", workspace: "b", project: "c" }
|
||||
const refused = (run: Promise<unknown>): Promise<unknown> => run.catch((error: unknown) => error)
|
||||
|
||||
it("sends a signed-out visitor to sign in, before it reads anything", async () => {
|
||||
signedOut()
|
||||
expect(isRedirect(await refused(projectLayoutData(slugs, request())))).toBe(true)
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("answers 404 for a project of another tenant", async () => {
|
||||
signedIn()
|
||||
mocks.call.mockRejectedValue(refusal(404))
|
||||
expect(isNotFound(await refused(projectLayoutData(slugs, request())))).toBe(true)
|
||||
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "404")
|
||||
})
|
||||
|
||||
it("returns the project's scope and the person's permissions there, the seed of useCan", async () => {
|
||||
signedIn()
|
||||
expect(await projectLayoutData(slugs, request())).toMatchObject({
|
||||
scope: slugs,
|
||||
seed: expect.objectContaining({ permissions: ["notes.note.read"] }),
|
||||
project: { id: slugs.project },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Start has no `onRequestError`: the loaders and the server entry report what they leave unhandled, and a
|
||||
// redirect or a not-found the app raised on purpose is control flow.
|
||||
|
||||
import { notFound, redirect } from "@tanstack/react-router"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({ current: undefined as unknown }))
|
||||
vi.mock("@app/config", () => ({ currentObservability: () => mocks.current }))
|
||||
|
||||
const { reportFailure } = await import("../apps/web/src/server/report")
|
||||
|
||||
const context = { routePath: "/$org", routeType: "loader" }
|
||||
const observer = () => ({
|
||||
captureException: vi.fn(),
|
||||
log: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
})
|
||||
const request = new Request("http://app.test/acme/notes?x=1", { headers: { traceparent: "t" } })
|
||||
|
||||
afterEach(() => {
|
||||
mocks.current = undefined
|
||||
})
|
||||
|
||||
describe("a failed request's report", () => {
|
||||
it("reports a fault with the route, and says nothing of a redirect or a not-found", () => {
|
||||
const observe = observer()
|
||||
mocks.current = observe
|
||||
|
||||
reportFailure(notFound(), request, context)
|
||||
reportFailure(redirect({ href: "/" }), request, context)
|
||||
expect(observe.captureException).not.toHaveBeenCalled()
|
||||
|
||||
const fault = new Error("fault")
|
||||
reportFailure(fault, request, context)
|
||||
expect(observe.captureException).toHaveBeenCalledWith(fault, { route: "/$org", kind: "loader" })
|
||||
expect(observe.log.error).toHaveBeenCalledOnce()
|
||||
expect(observe.log.error.mock.calls[0]?.[1]).toMatchObject({ "url.path": "/acme/notes" })
|
||||
})
|
||||
|
||||
it("does nothing in a process that started no observability", () => {
|
||||
expect(() => reportFailure(new Error("fault"), request, context)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The loaders of the pages outside an organization (M6b): each answers a signed-out visitor, a refused read
|
||||
// and a redirect with its real status, and returns data that survives the trip to the browser.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { isNotFound, isRedirect } from "@tanstack/react-router"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pageContext: vi.fn(),
|
||||
call: vi.fn(),
|
||||
config: vi.fn(),
|
||||
setResponseHeader: vi.fn<(name: string, value: string) => void>(),
|
||||
cookie: "",
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getRequest: () => new Request("http://app.test/", { headers: { cookie: mocks.cookie } }),
|
||||
setResponseStatus: vi.fn(),
|
||||
setResponseHeader: mocks.setResponseHeader,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/page", () => ({ pageContext: mocks.pageContext }))
|
||||
vi.mock("@conusai-kit/auth", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
loadAuthConfig: mocks.config,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/better-auth", () => ({
|
||||
MIN_PASSWORD_LENGTH: 12,
|
||||
betterAuthFormLabels: () => ({ submit: "Go" }),
|
||||
}))
|
||||
vi.mock("@app/modules/account/contract", async (importOriginal) => ({
|
||||
// The module list behind the app's catalogues reads the account module's entities.
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
ACCOUNT_PATH: "/settings/account",
|
||||
accountDeletionStatus: {},
|
||||
}))
|
||||
vi.mock("@app/modules/account/ui", () => ({
|
||||
analyticsLabels: () => ({}),
|
||||
deletionLabels: () => ({}),
|
||||
exportLabels: () => ({}),
|
||||
pageLabels: () => ({ title: "Account" }),
|
||||
}))
|
||||
vi.mock("@conusai-kit/tenancy", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
meGet: "me",
|
||||
invitationRead: "invitation.read",
|
||||
}))
|
||||
vi.mock("@conusai-kit/tenancy/ui", () => ({ builtInRoleName: (_i18n: unknown, role: string) => `kit:${role}` }))
|
||||
|
||||
const { signInSearch } = await import("../apps/web/src/ui/sign-in-search")
|
||||
const { accountData, homeData, invitationData, membersEntryData, onboardingData, signInData } = await import(
|
||||
"../apps/web/src/server/account-pages"
|
||||
)
|
||||
const { signUpData } = await import("../apps/web/src/server/account-pages") // kit:convex-better-auth
|
||||
|
||||
const i18n = { message: (key: string) => key, locale: "en" }
|
||||
const context = (extra: object) => ({ i18n, caller: { call: mocks.call }, path: "/here", ...extra })
|
||||
const person = (extra: object = {}) => mocks.pageContext.mockResolvedValue(context({ signedIn: true, ...extra }))
|
||||
const visitor = () => mocks.pageContext.mockResolvedValue(context({ signedIn: false }))
|
||||
const refusal = (status: number): AppError =>
|
||||
new AppError({ type: "/problems/refused", code: "refused", title: "Refused", status })
|
||||
const organizations = (...slugs: string[]) => ({
|
||||
organizations: slugs.map((slug) => ({ slug, name: slug, role: "owner" })),
|
||||
})
|
||||
const thrown = (run: Promise<unknown>): Promise<unknown> =>
|
||||
run.then(
|
||||
() => {
|
||||
throw new Error("expected a refusal")
|
||||
},
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const hrefOf = (error: unknown): string | undefined => (error as { options?: { href?: string } }).options?.href
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.cookie = ""
|
||||
mocks.config.mockReturnValue({ provider: "oidc", devLogin: true })
|
||||
})
|
||||
|
||||
describe("the entry page", () => {
|
||||
it("offers a visitor the way in", async () => {
|
||||
visitor()
|
||||
mocks.config.mockReturnValue({ provider: "oidc", oidc: {} })
|
||||
const data = await homeData()
|
||||
expect(data).toMatchObject({
|
||||
signedIn: false,
|
||||
signIn: {
|
||||
provider: { href: "/auth/sign-in/start?ui_locales=en", label: "app.signIn" },
|
||||
signUp: { href: "/auth/sign-in/start?ui_locales=en&intent=sign-up", label: "signin.createAccount" },
|
||||
},
|
||||
})
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("takes a person with one organization straight to it", async () => {
|
||||
person({ mayCreateOrganization: true })
|
||||
mocks.call.mockResolvedValue(organizations("acme"))
|
||||
expect(hrefOf(await thrown(homeData()))).toBe("/acme")
|
||||
})
|
||||
|
||||
it("takes a person with none to onboarding, or says why when they may not found one", async () => {
|
||||
person({ mayCreateOrganization: true })
|
||||
mocks.call.mockResolvedValue(organizations())
|
||||
expect(hrefOf(await thrown(homeData()))).toBe("/onboarding")
|
||||
person({ mayCreateOrganization: false })
|
||||
const data = await homeData()
|
||||
expect(data).toMatchObject({ signedIn: true, emptyHint: "app.askForInvitation" })
|
||||
expect(data).not.toHaveProperty("create")
|
||||
})
|
||||
|
||||
it("lists several organizations with their role translated", async () => {
|
||||
person({ mayCreateOrganization: false })
|
||||
mocks.call.mockResolvedValue(organizations("acme", "north"))
|
||||
expect(await homeData()).toEqual(
|
||||
expect.objectContaining({
|
||||
organizations: [
|
||||
{ slug: "acme", name: "acme", role: "kit:owner" },
|
||||
{ slug: "north", name: "north", role: "kit:owner" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("onboarding", () => {
|
||||
it("sends a visitor to sign in and back", async () => {
|
||||
visitor()
|
||||
expect(hrefOf(await thrown(onboardingData()))).toBe("/auth/sign-in?return_to=%2Fhere")
|
||||
})
|
||||
|
||||
it("is the forbidden page, 403, for a person who may not found an organization", async () => {
|
||||
person({ mayCreateOrganization: false })
|
||||
const error = (await thrown(onboardingData())) as { data?: unknown }
|
||||
expect(isNotFound(error)).toBe(true)
|
||||
expect(error.data).toEqual({ forbidden: true })
|
||||
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "403")
|
||||
})
|
||||
|
||||
it("names the first workspace in the deployment's language, whatever the founder's is", async () => {
|
||||
person({ mayCreateOrganization: true, deploymentI18n: { message: (key: string) => `lt:${key}` } })
|
||||
expect((await onboardingData()).workspace).toEqual({
|
||||
name: "lt:onboarding.workspaceName",
|
||||
slug: "lt:onboarding.workspaceSlug",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("/members", () => {
|
||||
it.each([
|
||||
[[], "/"],
|
||||
[["acme"], "/acme/members"],
|
||||
])("sends a person with organizations %j to %s", async (slugs, href) => {
|
||||
person()
|
||||
mocks.call.mockResolvedValue(organizations(...slugs))
|
||||
const error = await thrown(membersEntryData())
|
||||
expect(isRedirect(error)).toBe(true)
|
||||
expect(hrefOf(error)).toBe(href)
|
||||
})
|
||||
|
||||
it("lists several organizations", async () => {
|
||||
person()
|
||||
mocks.call.mockResolvedValue(organizations("a", "b"))
|
||||
expect((await membersEntryData()).organizations.map(({ slug }) => slug)).toEqual(["a", "b"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the account page", () => {
|
||||
it("uses only a remembered organization the person belongs to", async () => {
|
||||
person()
|
||||
mocks.call.mockImplementation(async (procedure: unknown) =>
|
||||
procedure === "me"
|
||||
? { displayName: "Ada", email: "ada@example.test", ...organizations("a", "b") }
|
||||
: { interactive: true, recentSignIn: false, deletion: null },
|
||||
)
|
||||
mocks.cookie = "app_workspace=b%2Fgeneral"
|
||||
expect(hrefOf(await thrown(accountData(true)))).toBe("/b/settings/account")
|
||||
mocks.cookie = "app_workspace=other%2Fgeneral"
|
||||
expect((await accountData(true)).page.title).toBe("Account")
|
||||
mocks.cookie = "app_workspace=b%2Fgeneral%2Fextra"
|
||||
expect((await accountData(true)).page.title).toBe("Account")
|
||||
})
|
||||
|
||||
it("uses the only organization when there is no workspace preference", async () => {
|
||||
person()
|
||||
mocks.call.mockImplementation(async (procedure: unknown) =>
|
||||
procedure === "me"
|
||||
? { displayName: "Ada", email: "ada@example.test", ...organizations("a") }
|
||||
: { interactive: true, recentSignIn: false, deletion: null },
|
||||
)
|
||||
expect(hrefOf(await thrown(accountData(true)))).toBe("/a/settings/account")
|
||||
})
|
||||
|
||||
it("sends a person back through the dev login to sign in again, when that is the deployment's way in", async () => {
|
||||
person()
|
||||
mocks.call.mockImplementation(async (procedure: unknown) =>
|
||||
procedure === "me"
|
||||
? { displayName: "Ada", email: "ada@example.test" }
|
||||
: { interactive: true, recentSignIn: false, deletion: null },
|
||||
)
|
||||
const data = await accountData()
|
||||
expect(data.deletion.reauthHref).toBe("/auth/dev-login?email=ada%40example.test&return_to=%2Fsettings%2Faccount")
|
||||
expect(() => JSON.stringify(data)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("the sign-in pages", () => {
|
||||
it("take a string from the query or nothing: the router parses `?return_to=123` as a number", () => {
|
||||
expect(signInSearch({ return_to: 123, error: true })).toEqual({})
|
||||
expect(signInSearch({ return_to: "/members", error: "denied" })).toEqual({ return_to: "/members", error: "denied" })
|
||||
})
|
||||
|
||||
it("sends somebody already in where they were going, and only inside this app", async () => {
|
||||
person()
|
||||
expect(hrefOf(await thrown(signInData({ return_to: "/members" })))).toBe("/members")
|
||||
expect(hrefOf(await thrown(signInData({ return_to: "https://evil.example/" })))).toBe("/")
|
||||
})
|
||||
|
||||
it("keeps somebody who was sent back with an error, and says what went wrong", async () => {
|
||||
person()
|
||||
expect(await signInData({ error: "denied" })).toMatchObject({ error: "signin.error.denied" })
|
||||
})
|
||||
|
||||
it("offers the identity provider when there is one, with where to come back to", async () => {
|
||||
visitor()
|
||||
mocks.config.mockReturnValue({ provider: "oidc", oidc: {} })
|
||||
const data = await signInData({ return_to: "/members" })
|
||||
expect(data.provider?.href).toBe("/auth/sign-in/start?ui_locales=en&return_to=%2Fmembers")
|
||||
expect(data).not.toHaveProperty("dev")
|
||||
})
|
||||
|
||||
it("offers the development login with no provider, and says so when there is neither", async () => {
|
||||
visitor()
|
||||
expect((await signInData({ return_to: "/members" })).dev?.returnTo).toBe("/members")
|
||||
mocks.config.mockReturnValue({ provider: "oidc", devLogin: false })
|
||||
expect(await signInData({})).toMatchObject({ notConfigured: "signin.notConfigured" })
|
||||
})
|
||||
// kit:convex-better-auth:begin
|
||||
|
||||
it("has no sign-up page in an OIDC deployment", async () => {
|
||||
visitor()
|
||||
expect(isNotFound(await thrown(signUpData({})))).toBe(true)
|
||||
})
|
||||
|
||||
it("offers the account form with the Convex Better Auth adapter", async () => {
|
||||
visitor()
|
||||
mocks.config.mockReturnValue({ provider: "convex-better-auth" })
|
||||
expect((await signInData({})).betterAuth?.toSignUp.href).toBe("/auth/sign-up")
|
||||
expect(await signUpData({ return_to: "/members" })).toMatchObject({
|
||||
returnTo: "/members",
|
||||
toSignIn: { href: "/auth/sign-in?return_to=%2Fmembers" },
|
||||
})
|
||||
})
|
||||
// kit:convex-better-auth:end
|
||||
})
|
||||
|
||||
describe("an invitation", () => {
|
||||
const open = {
|
||||
invitationId: "inv-1",
|
||||
state: "open",
|
||||
email: "guest@example.test",
|
||||
organization: { slug: "acme", name: "Acme" },
|
||||
role: "member",
|
||||
roleKey: "editor",
|
||||
expiresAt: "2026-11-10T09:00:00.000Z",
|
||||
}
|
||||
|
||||
it("tells a visitor who it is for and until when, and offers Create your account and Sign in", async () => {
|
||||
mocks.pageContext.mockResolvedValue(context({ signedIn: false, path: "/invitations/acme/token", theme: "dark" }))
|
||||
mocks.call.mockResolvedValue(open)
|
||||
const data = await invitationData({ org: "acme", token: "token" })
|
||||
expect(mocks.call).toHaveBeenCalledWith("invitation.read", { org: "acme", token: "token" })
|
||||
expect(data).toMatchObject({ state: "open", lead: "invite.lead", expires: "invite.expires" })
|
||||
expect(data.start).toEqual({
|
||||
org: "acme",
|
||||
token: "token",
|
||||
locale: "en",
|
||||
theme: "dark",
|
||||
createAccount: "invite.createAccount",
|
||||
signIn: "invite.haveAccount",
|
||||
})
|
||||
expect(data.signedIn).toBeUndefined()
|
||||
})
|
||||
|
||||
it("says plainly that a link was used, revoked or has expired, and that an unknown one is not valid", async () => {
|
||||
visitor()
|
||||
for (const state of ["used", "revoked", "expired"]) {
|
||||
mocks.call.mockResolvedValueOnce({ ...open, state })
|
||||
const data = await invitationData({ org: "acme", token: "token" })
|
||||
expect(data).toMatchObject({ state, lead: `invite.error.${state}` })
|
||||
expect(data.start).toBeUndefined()
|
||||
}
|
||||
mocks.call.mockRejectedValueOnce(refusal(404))
|
||||
expect(await invitationData({ org: "acme", token: "token" })).toMatchObject({
|
||||
state: "invalid",
|
||||
lead: "invite.error.unknown",
|
||||
})
|
||||
})
|
||||
|
||||
it("offers the invited address the accept button, and anybody else only who it is for", async () => {
|
||||
person({ user: { email: "Guest@Example.test" } })
|
||||
mocks.call.mockResolvedValue(open)
|
||||
const mine = await invitationData({ org: "acme", token: "token" })
|
||||
expect(mine.signedIn?.labels?.accept).toBe("invite.accept")
|
||||
expect(mine.signedIn?.otherAccount).toBeUndefined()
|
||||
person({ user: { email: "ada@example.test" } })
|
||||
const theirs = await invitationData({ org: "acme", token: "token" })
|
||||
expect(theirs.signedIn?.labels).toBeUndefined()
|
||||
expect(theirs.signedIn?.otherAccount).toBe("invite.otherAccount")
|
||||
})
|
||||
|
||||
it("asks a visitor to sign in first where the sign-in takes no invitation", async () => {
|
||||
mocks.config.mockReturnValue({ provider: "convex-better-auth", devLogin: false })
|
||||
mocks.pageContext.mockResolvedValue(context({ signedIn: false, path: "/invitations/acme/token" }))
|
||||
mocks.call.mockResolvedValue(open)
|
||||
expect((await invitationData({ org: "acme", token: "token" })).signInFirst?.href).toBe(
|
||||
"/auth/sign-in?return_to=%2Finvitations%2Facme%2Ftoken",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("a read refused", () => {
|
||||
it("is the page that belongs to it", async () => {
|
||||
person()
|
||||
mocks.call.mockRejectedValue(refusal(401))
|
||||
expect(isRedirect(await thrown(homeData()))).toBe(true)
|
||||
mocks.call.mockRejectedValue(refusal(404))
|
||||
expect(isNotFound(await thrown(homeData()))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,407 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The route files of the Next tree's replacement (M6b, M6c): the table in apps/web/ROUTES.md has a file for
|
||||
// every row, what reaches the browser imports no server code, and the loaders under an organization answer
|
||||
// every refusal with its status.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { isNotFound, isRedirect } from "@tanstack/react-router"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const web = new URL("../apps/web/", import.meta.url)
|
||||
const routes = new URL("src/routes/", web)
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pageContext: vi.fn(),
|
||||
holds: vi.fn(),
|
||||
requirePermission: vi.fn(),
|
||||
permissionsIn: vi.fn(),
|
||||
call: vi.fn(),
|
||||
requireSection: vi.fn(),
|
||||
membersLoad: vi.fn(),
|
||||
invitationsLoad: vi.fn(),
|
||||
setResponseHeader: vi.fn<(name: string, value: string) => void>(),
|
||||
nav: vi.fn((): unknown[] => []),
|
||||
request: vi.fn(() => new Request("http://app.test/")),
|
||||
singleOrganization: vi.fn(() => false),
|
||||
selected: new Set(["documents", "knowledge"]),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getRequest: () => mocks.request(),
|
||||
setResponseStatus: vi.fn(),
|
||||
setResponseHeader: mocks.setResponseHeader,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/page", () => ({
|
||||
holds: mocks.holds,
|
||||
pageContext: mocks.pageContext,
|
||||
permissionsIn: mocks.permissionsIn,
|
||||
requirePermission: mocks.requirePermission,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/app", () => ({
|
||||
webRuntime: () => ({
|
||||
env: { deployment: {} },
|
||||
observe: { log: { warn: vi.fn() } },
|
||||
registry: { nav: mocks.nav() },
|
||||
singleOrganization: mocks.singleOrganization(),
|
||||
}),
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/project-entries", () => ({
|
||||
PROJECT_ENTRIES: [{ href: "archive", label: "archive.title", module: "archive" }],
|
||||
PROJECT_MODULE_KEYS: ["archive"],
|
||||
}))
|
||||
vi.mock("@app/config", () => ({ PROFILE: "postgres" }))
|
||||
vi.mock("@app/modules", () => ({
|
||||
moduleSelected: (id: string) => mocks.selected.has(id),
|
||||
moduleAdopted: (id: string) => mocks.selected.has(id),
|
||||
appModules: [
|
||||
{
|
||||
id: "tenancy",
|
||||
version: "1.0.0",
|
||||
procedures: [{ name: "a function that must not reach the browser" }],
|
||||
ui: {
|
||||
nav: [
|
||||
{ scope: "org", label: "tenancy.nav.members", href: "members" },
|
||||
{ scope: "org", label: "tenancy.nav.settings", href: "settings", permission: "tenancy.org.manage" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: "worker-only", version: "1.0.0" },
|
||||
],
|
||||
}))
|
||||
vi.mock("@app/modules/branding", () => ({
|
||||
brandGet: "brand",
|
||||
logoUrl: (path: string, size: string) => `${path}?s=${size}`,
|
||||
tenantLogo: (brand: { logoLight: string | null }) => (brand.logoLight ? { path: brand.logoLight } : null),
|
||||
tenantStyle: () => ({ style: { "--tenant": "1" } }),
|
||||
}))
|
||||
vi.mock("@app/modules/branding/ui", () => ({ brandFormLabels: () => ({}) }))
|
||||
// kit:documents:begin
|
||||
vi.mock("@app/modules/documents", () => ({
|
||||
documentGet: "documentGet",
|
||||
documentList: "documentList",
|
||||
READABLE_CONTENT_TYPES: ["application/pdf"],
|
||||
}))
|
||||
vi.mock("@app/modules/documents/ui", () => ({
|
||||
exportLabels: () => ({}),
|
||||
listLabels: () => ({}),
|
||||
reviewLabels: () => ({}),
|
||||
uploadLabels: () => ({}),
|
||||
}))
|
||||
// kit:documents:end
|
||||
vi.mock("@app/modules/knowledge", () => ({ settingsGet: "settingsGet" })) // kit:knowledge
|
||||
vi.mock("@conusai-kit/brand", () => ({ tokens: { color: { dark: { accent: "#fff" } } } }))
|
||||
vi.mock("@conusai-kit/host/http", () => ({ isOwnedBy: () => true }))
|
||||
vi.mock("@conusai-kit/tenancy", () => ({
|
||||
brandingSchema: { safeParse: (value: unknown) => ({ success: true, data: value ?? {} }) },
|
||||
meGet: "me",
|
||||
ORG_ROLES: ["viewer", "owner"],
|
||||
orgGet: "org",
|
||||
projectGet: "project",
|
||||
projectList: "projects",
|
||||
roleList: "roles",
|
||||
workspaceList: "workspaces",
|
||||
}))
|
||||
vi.mock("@conusai-kit/tenancy/ui", () => ({
|
||||
accessAuditPage: { load: vi.fn() },
|
||||
builtInRoleName: (_i18n: unknown, role: string) => `kit:${role}`,
|
||||
collectAccessEvents: vi.fn(),
|
||||
invitationsPage: { load: mocks.invitationsLoad },
|
||||
membersPage: { load: mocks.membersLoad },
|
||||
requireSection: mocks.requireSection,
|
||||
roleName: (_i18n: unknown, role: { name: string }) => `kit:${role.name}`,
|
||||
rolePage: { load: vi.fn() },
|
||||
rolesPage: { load: vi.fn() },
|
||||
}))
|
||||
vi.mock("@conusai-sdk/node-sdk", () => ({ ConusAIClient: class {} }))
|
||||
|
||||
const {
|
||||
documentsData, // kit:documents
|
||||
invitationsData,
|
||||
membersData,
|
||||
orgHomeData,
|
||||
orgLayoutData,
|
||||
requireAccessSection,
|
||||
requireWorkspace,
|
||||
} = await import("../apps/web/src/server/org-pages")
|
||||
|
||||
const i18n = { message: (key: string) => key, date: () => "2 Nov 2026", locale: "en" }
|
||||
const page = (signedIn = true) => ({ i18n, caller: { call: mocks.call }, signedIn, path: "/acme" })
|
||||
const refusal = (status: number): AppError =>
|
||||
new AppError({ type: "/problems/refused", code: "refused", title: "Refused", status })
|
||||
const thrown = (run: Promise<unknown>): Promise<unknown> =>
|
||||
run.then(
|
||||
() => {
|
||||
throw new Error("expected a refusal")
|
||||
},
|
||||
(error: unknown) => error,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selected = new Set(["documents", "knowledge"])
|
||||
mocks.pageContext.mockResolvedValue(page())
|
||||
mocks.holds.mockResolvedValue(true)
|
||||
mocks.requireSection.mockResolvedValue(undefined)
|
||||
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.audit.read"] })
|
||||
})
|
||||
|
||||
describe("the route files", () => {
|
||||
const table = readFileSync(new URL("ROUTES.md", web), "utf8")
|
||||
const files = readdirSync(routes)
|
||||
|
||||
it("have nothing left of the Next tree", () => {
|
||||
expect(existsSync(new URL("app", web))).toBe(false)
|
||||
expect(table).not.toContain("to port")
|
||||
})
|
||||
|
||||
it("exist for every row of the table", () => {
|
||||
const named = [...table.matchAll(/\| `([^`|]+\.tsx?)`[^|]*\| done/g)].map((row) => row[1] as string)
|
||||
expect(named.length).toBeGreaterThan(30)
|
||||
// `kit template:prune` removes these routes, and ROUTES.md stays the whole record of the port.
|
||||
const pruned = new Set([
|
||||
"auth.sign-up.tsx",
|
||||
"$org.$workspace.$project.documents.index.tsx",
|
||||
"$org.$workspace.$project.documents.$fileId.tsx",
|
||||
])
|
||||
expect(named.filter((file) => !files.includes(file) && !pruned.has(file))).toEqual([])
|
||||
})
|
||||
|
||||
it.each(files.filter((file) => file !== "__root.tsx"))("%s keeps server code on the server", (file) => {
|
||||
const source = readFileSync(new URL(file.replaceAll("$", "%24"), routes), "utf8")
|
||||
// Only a type may be imported statically; the handlers import the file on the server.
|
||||
expect(source).not.toMatch(/^import \{[^}]*\} from "\.\.\/server\//m)
|
||||
expect(source).not.toMatch(/^import (?!type ).*"@app\/(config|modules)"$/m)
|
||||
expect(source).not.toMatch(/\bexport const (dynamic|runtime)\b/)
|
||||
expect(source).not.toMatch(/from "next/)
|
||||
})
|
||||
|
||||
it("reach the browser with no root of the app's own packages", () => {
|
||||
// Every file a route reaches by a static value import: what the browser's bundle starts from.
|
||||
const seen = new Set<string>()
|
||||
const walk = (url: URL): void => {
|
||||
if (seen.has(url.href)) return
|
||||
seen.add(url.href)
|
||||
const source = readFileSync(url, "utf8")
|
||||
for (const [, name] of source.matchAll(/^(?:import|export) (?!type )[^"]*"([^"]+)"/gm)) {
|
||||
const label = `${url.pathname.split("/apps/web/")[1]} imports ${name}`
|
||||
expect(label).not.toMatch(/imports @app\/(config|modules)$/)
|
||||
expect(label).not.toMatch(/imports (\.\.?\/)+server\//)
|
||||
if (!name?.startsWith(".") || name.includes("?")) continue
|
||||
const target = [".ts", ".tsx"].map((ext) => new URL(`${name}${ext}`, url)).find((file) => existsSync(file))
|
||||
if (target) walk(target)
|
||||
}
|
||||
}
|
||||
for (const file of files.filter((name) => name !== "__root.tsx")) walk(new URL(file.replaceAll("$", "%24"), routes))
|
||||
expect(seen.size).toBeGreaterThan(files.length)
|
||||
})
|
||||
|
||||
it("leave the guard free of the page loaders", () => {
|
||||
const guard = readFileSync(new URL("src/server/guard.ts", web), "utf8")
|
||||
expect(guard).not.toMatch(/from "\.\/page"/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the organization route's loader", () => {
|
||||
const organization = { name: "Acme", branding: { displayName: "Acme Co", logoLight: "/acme/brand/logo/light" } }
|
||||
const answers = (procedure: unknown): unknown =>
|
||||
procedure === "org"
|
||||
? organization
|
||||
: {
|
||||
userId: "u1",
|
||||
displayName: "Ada",
|
||||
email: "ada@example.test",
|
||||
organizations: [{ slug: "acme", name: "Acme" }],
|
||||
workspaces: [],
|
||||
}
|
||||
|
||||
it("sends a visitor to sign in", async () => {
|
||||
mocks.pageContext.mockResolvedValue(page(false))
|
||||
expect(isRedirect(await thrown(orgLayoutData({ org: "acme" })))).toBe(true)
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("answers a stranger and a missing organization with one 404", async () => {
|
||||
mocks.call.mockRejectedValue(refusal(404))
|
||||
expect(isNotFound(await thrown(orgLayoutData({ org: "acme" })))).toBe(true)
|
||||
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "404")
|
||||
})
|
||||
|
||||
it("hands the browser the navigation and nothing else of a manifest, with its labels translated", async () => {
|
||||
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
||||
const data = await orgLayoutData({ org: "acme" })
|
||||
// The organization's own tab first, as on stage, whatever order the manifest gives.
|
||||
expect(data.settings.sections).toEqual([
|
||||
{ group: "organization", label: "shell.general", href: "/acme/settings", permission: "tenancy.org.manage" },
|
||||
{ group: "organization", label: "tenancy.nav.members", href: "/acme/members" },
|
||||
{ group: "organization", label: "app.audit", href: "/acme/audit", permission: "tenancy.audit.read" },
|
||||
{ group: "personal", label: "account.link", href: "/acme/settings/account" },
|
||||
])
|
||||
expect(JSON.stringify(data)).not.toContain("a function that must not reach the browser")
|
||||
})
|
||||
|
||||
it("shows an organization's own page in the workspace the person was last in, else the default one", async () => {
|
||||
const workspaces = [
|
||||
{ slug: "general", name: "General", isDefault: true },
|
||||
{ slug: "research", name: "Research" },
|
||||
]
|
||||
mocks.call.mockImplementation(async (procedure: unknown) =>
|
||||
procedure === "workspaces" ? { workspaces } : answers(procedure),
|
||||
)
|
||||
const lastIn = async (cookie?: string) => {
|
||||
mocks.request.mockReturnValue(new Request("http://app.test/", cookie ? { headers: { cookie } } : {}))
|
||||
return (await orgLayoutData({ org: "acme" })).workspace?.workspace.id
|
||||
}
|
||||
expect(await lastIn("app_workspace=acme%2Fresearch")).toBe("research")
|
||||
expect(await lastIn()).toBe("general")
|
||||
// Another organization's workspace, and one the person no longer sees, are not this organization's.
|
||||
expect(await lastIn("app_workspace=northwind%2Fresearch")).toBe("general")
|
||||
expect(await lastIn("app_workspace=acme%2Fgone")).toBe("general")
|
||||
mocks.request.mockReturnValue(new Request("http://app.test/"))
|
||||
})
|
||||
|
||||
it("puts the audit in Settings, and opens Settings on the first tab the person may open", async () => {
|
||||
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
||||
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.audit.read"] })
|
||||
expect(await orgLayoutData({ org: "acme" })).toMatchObject({ settingsHref: "/acme/members" })
|
||||
expect(await orgLayoutData({ org: "acme" })).not.toHaveProperty("nav")
|
||||
})
|
||||
|
||||
it("offers a new workspace to whoever may make one, on a one-organization deployment too, and a new organization to whoever may found one", async () => {
|
||||
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
||||
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.workspace.manage"] })
|
||||
mocks.pageContext.mockResolvedValue({ ...page(), mayCreateOrganization: true })
|
||||
mocks.singleOrganization.mockReturnValue(true)
|
||||
expect(await orgLayoutData({ org: "acme" })).toMatchObject({
|
||||
workspaceCreateHref: "/acme/workspaces",
|
||||
organizationCreateHref: "/onboarding",
|
||||
})
|
||||
mocks.permissionsIn.mockResolvedValue({ permissions: [] })
|
||||
mocks.pageContext.mockResolvedValue({ ...page(), mayCreateOrganization: false })
|
||||
const member = await orgLayoutData({ org: "acme" })
|
||||
expect(member).not.toHaveProperty("workspaceCreateHref")
|
||||
expect(member).not.toHaveProperty("organizationCreateHref")
|
||||
mocks.pageContext.mockResolvedValue(page())
|
||||
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.audit.read"] })
|
||||
mocks.singleOrganization.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it("carries the organization's brand and the scope's choices", async () => {
|
||||
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
||||
const data = await orgLayoutData({ org: "acme" })
|
||||
expect(data).toMatchObject({
|
||||
name: "Acme Co",
|
||||
style: { "--tenant": "1" },
|
||||
logoLight: { src: "/acme/brand/logo/light?s=1x" },
|
||||
organizations: [{ id: "acme", label: "Acme" }],
|
||||
client: { org: "acme", user: "u1", locale: "en" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("a page under an organization", () => {
|
||||
it("is 404 for a workspace the person cannot see", async () => {
|
||||
mocks.call.mockResolvedValue({ workspaces: [{ slug: "general", name: "General" }] })
|
||||
const { workspace, nav } = await requireWorkspace({ org: "acme", workspace: "general" })
|
||||
expect(workspace).toEqual({ id: "general", label: "General" })
|
||||
expect(nav.map((item) => item.href)).toEqual(["/acme/general"])
|
||||
expect(isNotFound(await thrown(requireWorkspace({ org: "acme", workspace: "restricted" })))).toBe(true)
|
||||
})
|
||||
|
||||
it("links the modules' pages of the workspace this person may open, and no other scope's", async () => {
|
||||
mocks.call.mockResolvedValue({ workspaces: [{ slug: "general", name: "General" }] })
|
||||
mocks.nav.mockReturnValue([
|
||||
{
|
||||
scope: "workspace",
|
||||
label: "reports.title",
|
||||
href: "reports",
|
||||
permission: "tenancy.audit.read",
|
||||
module: "reports",
|
||||
},
|
||||
{
|
||||
scope: "workspace",
|
||||
label: "budgets.title",
|
||||
href: "budgets",
|
||||
permission: "budgets.budget.read",
|
||||
module: "budgets",
|
||||
},
|
||||
{ scope: "project", label: "notes.title", href: "notes", module: "notes" },
|
||||
])
|
||||
const { nav } = await requireWorkspace({ org: "acme", workspace: "general" })
|
||||
expect(nav.map((item) => item.href)).toEqual(["/acme/general", "/acme/general/modules/reports"])
|
||||
mocks.nav.mockReturnValue([])
|
||||
})
|
||||
|
||||
it("makes a page the module placed in Settings a tab there, and leaves it out of the sidebar", async () => {
|
||||
mocks.call.mockResolvedValue({ workspaces: [{ slug: "general", name: "General" }] })
|
||||
mocks.nav.mockReturnValue([
|
||||
{ scope: "workspace", label: "reports.title", href: "reports", module: "reports" },
|
||||
{
|
||||
scope: "workspace",
|
||||
label: "reports.ai",
|
||||
href: "reports/settings/ai",
|
||||
placement: "settings",
|
||||
module: "reports",
|
||||
},
|
||||
])
|
||||
const { nav, settings } = await requireWorkspace({ org: "acme", workspace: "general" })
|
||||
expect(nav.map((item) => item.href)).toEqual(["/acme/general", "/acme/general/modules/reports"])
|
||||
expect(settings).toEqual([
|
||||
{ group: "workspace", label: "reports.ai", href: "/acme/general/modules/reports/settings/ai" },
|
||||
])
|
||||
mocks.nav.mockReturnValue([])
|
||||
})
|
||||
|
||||
it("is 404 in every loader at once, so whichever finishes first gives the same status", async () => {
|
||||
mocks.call.mockRejectedValue(refusal(404))
|
||||
expect(isNotFound(await thrown(orgHomeData({ org: "other" })))).toBe(true)
|
||||
expect(isNotFound(await thrown(documentsData({ org: "other", workspace: "w", project: "p" })))).toBe(true) // kit:documents
|
||||
})
|
||||
|
||||
// kit:documents:begin
|
||||
it("refuses a default-off Documents page before any data call", async () => {
|
||||
mocks.selected.clear()
|
||||
expect(isNotFound(await thrown(documentsData({ org: "acme", workspace: "w", project: "p" })))).toBe(true)
|
||||
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "404")
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
// kit:documents:end
|
||||
|
||||
// kit:documents:begin
|
||||
it("carries the documents as the live list's seed, with the locale the browser writes their dates in", async () => {
|
||||
const documents = [{ uploadedAt: "2026-11-02T00:00:00.000Z" }]
|
||||
mocks.call.mockResolvedValue({ documents })
|
||||
const data = await documentsData({ org: "acme", workspace: "general", project: "demo" })
|
||||
expect(data.documents).toEqual(documents)
|
||||
expect(data.locale).toBe(i18n.locale)
|
||||
expect(data.accept).toEqual(["application/pdf"])
|
||||
})
|
||||
// kit:documents:end
|
||||
})
|
||||
|
||||
describe("kit-tenancy's access pages", () => {
|
||||
it("are 403 for a member without the section's permission", async () => {
|
||||
mocks.requireSection.mockRejectedValue(refusal(403))
|
||||
const error = (await thrown(requireAccessSection({ org: "acme", section: "members" }))) as { data?: unknown }
|
||||
expect(error.data).toEqual({ forbidden: true })
|
||||
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "403")
|
||||
})
|
||||
|
||||
it("load at the level the path says", async () => {
|
||||
mocks.membersLoad.mockResolvedValue({})
|
||||
await membersData({ org: "acme" })
|
||||
await membersData({ org: "acme", workspace: "w" })
|
||||
await membersData({ org: "acme", workspace: "w", project: "p" })
|
||||
expect(mocks.membersLoad.mock.calls.map((call) => call[1])).toEqual([
|
||||
{ level: "org" },
|
||||
{ level: "workspace", workspace: "w" },
|
||||
{ level: "project", workspace: "w", project: "p" },
|
||||
])
|
||||
})
|
||||
|
||||
it("hand the invitations page its link path", async () => {
|
||||
mocks.invitationsLoad.mockResolvedValue({})
|
||||
await invitationsData({ org: "acme" })
|
||||
expect(mocks.invitationsLoad.mock.calls[0]?.[1]).toEqual({ linkPath: "/invitations/acme/" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The server entry: what Next's proxy and instrumentation did. One nonce per request, named by the policy
|
||||
// and given to the renderer; `x-path`; the status a loader asked for; a contract check before the first
|
||||
// request; and a report for what escapes the handler.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetch: vi.fn<(request: Request, options?: unknown) => Promise<Response>>(),
|
||||
loadAppEnv: vi.fn(),
|
||||
loadAuthConfig: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-start/server-entry", () => ({
|
||||
default: { fetch: mocks.fetch },
|
||||
createServerEntry: <T>(entry: T): T => entry,
|
||||
}))
|
||||
vi.mock("@tanstack/react-start/server", () => ({ setResponseHeader: vi.fn(), setResponseStatus: vi.fn() }))
|
||||
vi.mock("@app/config", () => ({
|
||||
PROFILE: "postgres",
|
||||
loadAppEnv: mocks.loadAppEnv,
|
||||
currentObservability: () => ({ captureException: mocks.captureException, log: { error: mocks.logError } }),
|
||||
}))
|
||||
vi.mock("@conusai-kit/auth", () => ({ loadAuthConfig: mocks.loadAuthConfig }))
|
||||
|
||||
const { default: entry } = await import("../apps/web/src/server")
|
||||
const { PAGE_STATUS_HEADER } = await import("../apps/web/src/server/guard")
|
||||
|
||||
const serve = (url = "http://app.test/acme/general/demo/notes?x=1"): Promise<Response> =>
|
||||
(entry as unknown as { fetch(request: Request): Promise<Response> }).fetch(new Request(url))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fetch.mockReset()
|
||||
mocks.fetch.mockImplementation(async () => new Response("<html></html>", { status: 200 }))
|
||||
})
|
||||
afterEach(() => vi.clearAllMocks())
|
||||
|
||||
describe("the server entry", () => {
|
||||
it("gives the renderer the nonce the policy names, and tells a loader where the request is", async () => {
|
||||
const response = await serve()
|
||||
const request = mocks.fetch.mock.calls[0]?.[0] as Request
|
||||
const nonce = request.headers.get("x-nonce") as string
|
||||
expect(nonce).toBeTruthy()
|
||||
expect(request.headers.get("x-path")).toBe("/acme/general/demo/notes?x=1")
|
||||
expect(response.headers.get("content-security-policy")).toContain(`'nonce-${nonce}'`)
|
||||
expect(mocks.fetch.mock.calls[0]?.[1]).toMatchObject({ context: { nonce } })
|
||||
})
|
||||
|
||||
it("makes one nonce per request", async () => {
|
||||
await serve()
|
||||
await serve()
|
||||
const [first, second] = mocks.fetch.mock.calls.map(([request]) => request.headers.get("x-nonce"))
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
it("applies the status a loader asked for and removes the header that carried it", async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
new Response("<html>forbidden</html>", { status: 404, headers: { [PAGE_STATUS_HEADER]: "403" } }),
|
||||
)
|
||||
const response = await serve()
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.has(PAGE_STATUS_HEADER)).toBe(false)
|
||||
expect(await response.text()).toContain("forbidden")
|
||||
})
|
||||
|
||||
it("leaves a status nobody asked to change alone", async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/auth/sign-in" } }))
|
||||
const response = await serve()
|
||||
expect(response.status).toBe(302)
|
||||
expect(response.headers.get("location")).toBe("/auth/sign-in")
|
||||
})
|
||||
|
||||
it("parses the deployment contract once, before the first request is served", async () => {
|
||||
await serve()
|
||||
await serve()
|
||||
// The entry's module state: checked by an earlier test's request at the latest, and never again.
|
||||
expect(mocks.loadAppEnv.mock.calls.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it("reports what escapes the handler, and still fails the request", async () => {
|
||||
const fault = new Error("render failed")
|
||||
mocks.fetch.mockRejectedValueOnce(fault)
|
||||
await expect(serve()).rejects.toBe(fault)
|
||||
expect(mocks.captureException).toHaveBeenCalledWith(fault, expect.objectContaining({ kind: "handler" }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Real signed assertions through the template's request adapter, across separate requests.
|
||||
import { generateKeyPairSync, randomUUID, sign } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import type { AddressInfo } from "node:net"
|
||||
import { buildRegistry, Container } from "@conusai-kit/core"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({ runtime: vi.fn() }))
|
||||
vi.mock("../apps/web/src/server/app", () => ({ webRuntime: mocks.runtime }))
|
||||
vi.mock("@conusai-kit/tenancy", async (original) => ({
|
||||
...(await original<Record<string, unknown>>()),
|
||||
createTenancyHooks: () => ({}),
|
||||
}))
|
||||
|
||||
const { supportOboCaller } = await import("../apps/web/src/server/support-obo")
|
||||
const issuer = "https://platform.example.test"
|
||||
const audience = "https://app.example.test"
|
||||
const keys = generateKeyPairSync("ed25519")
|
||||
const jwk = { ...keys.publicKey.export({ format: "jwk" }), kid: "synthetic", alg: "EdDSA", use: "sig" }
|
||||
const registry = buildRegistry([])
|
||||
const httpFetch = globalThis.fetch
|
||||
|
||||
function configure() {
|
||||
mocks.runtime.mockReturnValue({
|
||||
data: {},
|
||||
registry,
|
||||
env: {
|
||||
deployment: {
|
||||
CONUSAI_API_URL: `${issuer}/api`,
|
||||
CONUSAI_PUBLIC_URL: audience,
|
||||
CONUSAI_ORG_ID: "3",
|
||||
CONUSAI_PROJECT_ID: "4",
|
||||
CONUSAI_ENVIRONMENT_ID: "5",
|
||||
},
|
||||
},
|
||||
})
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ keys: [jwk] })),
|
||||
)
|
||||
}
|
||||
|
||||
function assertion(extra: Record<string, unknown> = {}) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url")
|
||||
const body = `${encode({ alg: "EdDSA", typ: "conusai-obo+jwt", kid: "synthetic" })}.${encode({
|
||||
iss: issuer,
|
||||
aud: audience,
|
||||
sub: "synthetic-user",
|
||||
sub_ns: "app",
|
||||
app_tenant: "tenant-a",
|
||||
conv: "synthetic-conversation",
|
||||
channel: "sc_synthetic",
|
||||
org: "3",
|
||||
project: "4",
|
||||
env: "5",
|
||||
jti: randomUUID(),
|
||||
iat: now,
|
||||
exp: now + 60,
|
||||
...extra,
|
||||
})}`
|
||||
return `${body}.${sign(null, Buffer.from(body), keys.privateKey).toString("base64url")}`
|
||||
}
|
||||
|
||||
function request(token?: string, pathname = "/api/mcp") {
|
||||
return new Request(`${audience}${pathname}`, {
|
||||
headers: token ? { "Conusai-On-Behalf-Of": token } : {},
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("the Support OBO request adapter", () => {
|
||||
it("accepts a signed assertion once and refuses its replay on a separate request", async () => {
|
||||
configure()
|
||||
const token = assertion()
|
||||
expect(await supportOboCaller(request(token), new Container())).not.toBeNull()
|
||||
await expect(supportOboCaller(request(token), new Container())).rejects.toMatchObject({ status: 401 })
|
||||
})
|
||||
|
||||
it("refuses another environment and permits a new assertion", async () => {
|
||||
configure()
|
||||
await expect(supportOboCaller(request(assertion({ env: "6" })), new Container())).rejects.toMatchObject({
|
||||
status: 401,
|
||||
})
|
||||
expect(await supportOboCaller(request(assertion()), new Container())).not.toBeNull()
|
||||
})
|
||||
|
||||
it("keeps the replay boundary across real HTTP requests", async () => {
|
||||
configure()
|
||||
const server = createServer(async (incoming, response) => {
|
||||
try {
|
||||
const token = incoming.headers["conusai-on-behalf-of"]
|
||||
const caller = await supportOboCaller(request(typeof token === "string" ? token : undefined), new Container())
|
||||
response.writeHead(caller ? 200 : 204).end()
|
||||
} catch (error) {
|
||||
response.writeHead((error as { status?: number }).status ?? 500).end()
|
||||
}
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
const token = assertion()
|
||||
try {
|
||||
const headers = { "Conusai-On-Behalf-Of": token }
|
||||
expect((await httpFetch(`${origin}/api/mcp`, { headers })).status).toBe(200)
|
||||
expect((await httpFetch(`${origin}/api/mcp`, { headers })).status).toBe(401)
|
||||
expect((await httpFetch(`${origin}/api/mcp`)).status).toBe(204)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
||||
}
|
||||
})
|
||||
|
||||
it("leaves ordinary requests and non-MCP routes on session authentication", async () => {
|
||||
expect(await supportOboCaller(request(), new Container())).toBeNull()
|
||||
expect(await supportOboCaller(request("ignored", "/api/ready"), new Container())).toBeNull()
|
||||
expect(mocks.runtime).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Actual app caller and native readiness protocol over a synthetic transport; no live service proof.
|
||||
|
||||
import { loadAppEnv, platformClients } from "@app/config"
|
||||
import { type Actor, buildRegistry, Container, createCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { SUPPORT_PLATFORM, SUPPORT_PROCESSING_STATUS, supportModule } from "@conusai-kit/support"
|
||||
import { createTenancyHooks, orgMemberships, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { afterEach, expect, it, vi } from "vitest"
|
||||
import { bindWebPorts, nativeHelpSelectors } from "../apps/web/src/server/ports.js"
|
||||
import { businessServiceComposition } from "../config/src/business-services.js"
|
||||
|
||||
const selection = vi.hoisted(() => ({ documents: false }))
|
||||
vi.mock("@app/modules", async (original) => ({
|
||||
...(await original<Record<string, unknown>>()),
|
||||
moduleSelected: (id: string) =>
|
||||
id === "documents" ? selection.documents : ["agent", "guides", "support"].includes(id),
|
||||
}))
|
||||
afterEach(() => {
|
||||
selection.documents = false
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const descriptor = `sha256:${"a".repeat(64)}`
|
||||
const source = "b".repeat(40)
|
||||
const deployment = {
|
||||
CONUSAI_API_URL: "https://platform.example.test/api",
|
||||
CONUSAI_API_TOKEN: "synthetic-token",
|
||||
CONUSAI_ORG_ID: "1",
|
||||
CONUSAI_PROJECT_ID: "9",
|
||||
CONUSAI_ENVIRONMENT_ID: "3",
|
||||
CONUSAI_ORG_SLUG: "org",
|
||||
CONUSAI_WORKSPACE_SLUG: "workspace",
|
||||
CONUSAI_PROJECT_SLUG: "project",
|
||||
CONUSAI_PLATFORM_RELEASE_DESCRIPTOR: descriptor,
|
||||
CONUSAI_PLATFORM_SOURCE_COMMIT: source,
|
||||
}
|
||||
const observation = () => ({
|
||||
component: {
|
||||
id: "support",
|
||||
observed_at: new Date().toISOString(),
|
||||
facts: {
|
||||
installed: "true",
|
||||
enabled: "true",
|
||||
entitled: "true",
|
||||
configured: "true",
|
||||
ready: "true",
|
||||
},
|
||||
},
|
||||
authorized: "true",
|
||||
environment_bound: "true",
|
||||
compatible: "true",
|
||||
dependencies_ready: "true",
|
||||
policy_epoch: 2,
|
||||
descriptor_digest: descriptor,
|
||||
source_commit: source,
|
||||
organization_id: 1,
|
||||
project_id: 9,
|
||||
environment_id: 3,
|
||||
app_scope: "org",
|
||||
authorization_checked_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
function composition() {
|
||||
const modules = [tenancyModule, supportModule]
|
||||
const registry = buildRegistry(modules)
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const base = new Container()
|
||||
base.value(DATA_SOURCE, data)
|
||||
base.value(SYSTEM_DATA, data)
|
||||
const env = loadAppEnv("web", {
|
||||
...deployment,
|
||||
NODE_ENV: "test",
|
||||
POSTGRES_URL: "postgresql://app_runtime:fixture@db/app",
|
||||
POSTGRES_READER_URL: "postgresql://app_reader:fixture@db/app",
|
||||
})
|
||||
bindWebPorts(base, platformClients(env, registry), registry)
|
||||
const root = base.child()
|
||||
root.value(SUPPORT_PLATFORM, { mode: "platform", channel: "spk_actual", client: {} } as never)
|
||||
const hooks = businessServiceComposition({ deployment }, modules, "postgres", nativeHelpSelectors)
|
||||
const caller = (actor: Actor) => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, ...hooks }, { actor })
|
||||
}
|
||||
return { data, root, caller }
|
||||
}
|
||||
|
||||
it("returns empty without touching Documents when that module is not selected", async () => {
|
||||
const app = composition()
|
||||
const scope = {
|
||||
level: "project" as const,
|
||||
orgId: "local-org",
|
||||
workspaceId: "local-workspace",
|
||||
projectId: "local-project",
|
||||
actor: { userId: "member" },
|
||||
}
|
||||
const read = vi.spyOn(app.data, "forScope")
|
||||
expect(await app.root.resolve(SUPPORT_PROCESSING_STATUS).read(scope)).toEqual({
|
||||
state: "empty",
|
||||
processing: 0,
|
||||
extracted: 0,
|
||||
failed: 0,
|
||||
})
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// kit:documents:begin
|
||||
it("reads the requested project's processing counts when Documents is selected", async () => {
|
||||
selection.documents = true
|
||||
const app = composition()
|
||||
const scope = {
|
||||
level: "project" as const,
|
||||
orgId: "local-org",
|
||||
workspaceId: "local-workspace",
|
||||
projectId: "local-project",
|
||||
actor: { userId: "member" },
|
||||
}
|
||||
const list = vi.fn(async () => ({ items: [{ status: "processing" }, { status: "extracted" }, { status: "failed" }] }))
|
||||
const read = vi.spyOn(app.data, "forScope").mockReturnValue({ of: () => ({ list }) } as never)
|
||||
expect(await app.root.resolve(SUPPORT_PROCESSING_STATUS).read(scope)).toEqual({
|
||||
state: "failed",
|
||||
processing: 1,
|
||||
extracted: 1,
|
||||
failed: 1,
|
||||
})
|
||||
expect(read).toHaveBeenCalledWith(scope)
|
||||
})
|
||||
|
||||
// kit:documents:end
|
||||
|
||||
it("admits the containing org service after actual project authorization and preserves native denials", async () => {
|
||||
const app = composition()
|
||||
const user = await app.data.global(users).insert({
|
||||
issuer: "https://identity.example.test",
|
||||
subject: "owner",
|
||||
email: "owner@example.test",
|
||||
displayName: "Owner",
|
||||
locale: "en",
|
||||
})
|
||||
const actor = { userId: user.id }
|
||||
const caller = app.caller(actor)
|
||||
const org = (await caller.callByName("tenancy.org.create", { slug: "alpha", name: "Alpha" })) as { orgId: string }
|
||||
await caller.callByName("tenancy.project.create", {
|
||||
org: "alpha",
|
||||
workspace: "general",
|
||||
slug: "main",
|
||||
name: "Main",
|
||||
moduleKey: "support",
|
||||
})
|
||||
const input = { org: "alpha", workspace: "general", project: "main" }
|
||||
let current = observation()
|
||||
let status = 200
|
||||
const fetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async () => (status === 200 ? Response.json(current) : new Response(null, { status })))
|
||||
const dispatch = (query = input) => app.caller(actor).callByName("support.projectProcessingStatus.get", query)
|
||||
await expect(dispatch({ ...input, project: "missing" })).rejects.toMatchObject({ status: 404 })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
await expect(dispatch()).resolves.toEqual({ state: "empty", processing: 0, extracted: 0, failed: 0 })
|
||||
const first = fetch.mock.calls[0]
|
||||
if (!first) throw new Error("Expected native readiness request")
|
||||
const [url, request] = first
|
||||
expect(String(url)).toBe(
|
||||
"https://platform.example.test/api/v1/orgs/org/workspaces/workspace/projects/project/environments/3/platform-services/support/readiness",
|
||||
)
|
||||
expect(request?.redirect).toBe("error")
|
||||
expect(request?.headers).toMatchObject({ authorization: "Bearer synthetic-token" })
|
||||
expect(JSON.parse(String(request?.body))).toEqual({
|
||||
api_range: "=1.0.0-preview.20261103",
|
||||
scopes: ["support:widget-sessions"],
|
||||
app_scope: "org",
|
||||
channel: "spk_actual",
|
||||
})
|
||||
for (const changed of [{ project_id: 10 }, { app_scope: "project" }, { environment_id: 4 }]) {
|
||||
current = { ...observation(), ...changed }
|
||||
await expect(dispatch()).rejects.toMatchObject({ code: "service_permission_missing" })
|
||||
}
|
||||
current = { ...observation(), source_commit: "c".repeat(40) }
|
||||
await expect(dispatch()).rejects.toMatchObject({ code: "service_incompatible" })
|
||||
status = 403
|
||||
await expect(dispatch()).rejects.toMatchObject({ code: "service_permission_missing" })
|
||||
const beforeRevocation = fetch.mock.calls.length
|
||||
await app.data.unitOfWork.run({ level: "org", orgId: org.orgId, actor }, async (tx) => {
|
||||
const membership = (await tx.of(orgMemberships).list({ where: [{ field: "userId", op: "eq", value: user.id }] }))
|
||||
.items[0]
|
||||
if (!membership) throw new Error("Expected owner membership")
|
||||
await tx.of(orgMemberships).delete(membership.id)
|
||||
})
|
||||
await expect(dispatch()).rejects.toMatchObject({ status: 404 })
|
||||
expect(fetch).toHaveBeenCalledTimes(beforeRevocation)
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The whole app of this template, wired the way apps/web and apps/worker wire it, on the in-memory
|
||||
// data source and the fakes of fakes.ts: the registry of every module, one caller per actor, the
|
||||
// outbox relay's behaviour and the worker's job runner.
|
||||
//
|
||||
// `deliver()` stands in for the outbox relay (kit-jobs-pgboss): it drains the outbox once, runs each
|
||||
// record's subscribers in a child container with the record's organization and a system actor, and
|
||||
// marks it published -- exactly once per subscriber, never more forgiving than the real relay.
|
||||
// `runJobs()` stands in for the worker: it runs every queued job in a child container carrying the
|
||||
// scope the job was enqueued in, with a system actor. What neither does is retry, so a test of a
|
||||
// retry runs the handler again itself.
|
||||
|
||||
import {
|
||||
DOCUMENT_EXCLUDED_PAGE_CLASSES,
|
||||
DOCUMENT_MODEL,
|
||||
DOCUMENT_OCR,
|
||||
DOCUMENTS_SCOPE,
|
||||
documentsModule,
|
||||
MODEL_GATEWAY,
|
||||
type PageClass,
|
||||
} from "@app/modules/documents"
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import {
|
||||
createMemoryDataSource,
|
||||
DATA_SOURCE,
|
||||
entitiesOf,
|
||||
JOB_QUEUE,
|
||||
OBJECT_STORE,
|
||||
SYSTEM_DATA,
|
||||
} from "@conusai-kit/data"
|
||||
import { FILES_AUDIT, FILES_REGISTRY, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
|
||||
import { JOB_ON_BEHALF_OF, JOB_SCOPE } from "@conusai-kit/jobs-pgboss"
|
||||
import { NOTIFICATIONS_REPOS } from "@conusai-kit/notifications"
|
||||
import { createModelGateway } from "@conusai-kit/platform"
|
||||
import { PROPOSALS_AUDIT, PROPOSALS_REGISTRY, PROPOSALS_SCOPE } from "@conusai-kit/proposals"
|
||||
import { createTenancyHooks, TENANCY, users } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
fakeJobQueue,
|
||||
fakeObjectStore,
|
||||
recordingAudit,
|
||||
recordingUsage,
|
||||
uploadViaProcedures,
|
||||
} from "@conusai-kit/testing"
|
||||
import type { FakeChatResponder } from "@conusai-kit/testing/fake-platform"
|
||||
import { bindNativeJobAdmission } from "../../apps/worker/src/native-admission.js"
|
||||
import { appModules } from "../../modules/index.js"
|
||||
import { deterministicModel } from "./fake-model.js"
|
||||
import { syntheticNativeReadiness } from "./native-readiness.js"
|
||||
import { type FakePlatform, startOcr } from "./ocr.js"
|
||||
|
||||
const started: FakePlatform[] = []
|
||||
|
||||
/** Stops every fake platform the test file started: call it from `afterEach`. */
|
||||
export async function closeApps(): Promise<void> {
|
||||
await Promise.all(started.splice(0).map((platform) => platform.close()))
|
||||
}
|
||||
|
||||
export interface SeededProject {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly workspaceSlug: string
|
||||
readonly projectSlug: string
|
||||
readonly orgId: string
|
||||
readonly workspaceId: string
|
||||
readonly projectId: string
|
||||
/** The three slugs a project-scoped procedure's input carries. */
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
/** `document` is what the fake platform's OCR reads every file as: a `conusai.ocr.document/v1`. */
|
||||
export async function makeDocumentsApp(options: {
|
||||
document: Record<string, unknown>
|
||||
/** What the model answers; by default the deterministic reader of fake-model.ts. */
|
||||
responder?: FakeChatResponder
|
||||
/** The page classes the deployment leaves out; by default the module's own (drawings and empty pages). */
|
||||
nativeAdmission?: boolean
|
||||
excludedPageClasses?: readonly PageClass[]
|
||||
}) {
|
||||
const registry = buildRegistry([...appModules.filter((entry) => entry.id !== "documents"), documentsModule])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const store = fakeObjectStore()
|
||||
const queue = fakeJobQueue()
|
||||
const audit = recordingAudit()
|
||||
const usage = recordingUsage()
|
||||
const { platform, ocr } = await startOcr(options.document)
|
||||
started.push(platform)
|
||||
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
bindNativeJobAdmission(root, registry, options.nativeAdmission === false ? undefined : syntheticNativeReadiness)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(OBJECT_STORE, store)
|
||||
root.value(JOB_QUEUE, queue)
|
||||
root.value(FILES_REGISTRY, registry)
|
||||
root.value(FILES_AUDIT, audit)
|
||||
root.value(FILES_USAGE, usage)
|
||||
root.value(PROPOSALS_REGISTRY, registry)
|
||||
root.value(PROPOSALS_AUDIT, audit)
|
||||
root.value(DOCUMENT_OCR, ocr)
|
||||
// The model is the real gateway client over HTTP to the fake platform, whose reply is scripted.
|
||||
const modelRequests: Array<Record<string, unknown>> = []
|
||||
const script = (responder: FakeChatResponder | undefined): void =>
|
||||
platform.ai.respond(
|
||||
responder &&
|
||||
((request) => {
|
||||
modelRequests.push(request)
|
||||
return responder(request)
|
||||
}),
|
||||
)
|
||||
script(options.responder ?? deterministicModel)
|
||||
root.value(
|
||||
MODEL_GATEWAY,
|
||||
createModelGateway({ CONUSAI_API_URL: `${platform.url}/api`, CONUSAI_API_TOKEN: "deployment-token" }),
|
||||
)
|
||||
root.value(DOCUMENT_MODEL, "fake-model")
|
||||
if (options.excludedPageClasses) root.value(DOCUMENT_EXCLUDED_PAGE_CLASSES, options.excludedPageClasses)
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, serviceReadiness: syntheticNativeReadiness }, { actor })
|
||||
}
|
||||
|
||||
const signUp = async (email: string): Promise<Actor> => {
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
const delivered: Array<{ name: string }> = []
|
||||
const deliver = async (): Promise<number> => {
|
||||
const pending = await data.outbox.pending(500)
|
||||
let ran = 0
|
||||
for (const record of pending) {
|
||||
const entry = registry.events[record.name]
|
||||
if (entry) {
|
||||
for (const subscriber of entry.subscribers) {
|
||||
const container = root.child()
|
||||
const scope = { level: "org" as const, actor: systemActor("app.relay"), orgId: record.orgId }
|
||||
container.value(FILES_SCOPE, scope)
|
||||
container.value(PROPOSALS_SCOPE, scope)
|
||||
container.value(NOTIFICATIONS_REPOS, data.forScope(scope))
|
||||
await subscriber.handler(
|
||||
{ name: record.name, payload: record.payload },
|
||||
{ container, correlationId: record.id },
|
||||
)
|
||||
delivered.push({ name: record.name })
|
||||
ran += 1
|
||||
}
|
||||
}
|
||||
await data.outbox.markPublished([record.id])
|
||||
}
|
||||
return ran
|
||||
}
|
||||
|
||||
/** The jobs not yet run, in the order they were enqueued. */
|
||||
let consumed = 0
|
||||
const runJobs = async (options: { finalAttempt?: boolean; signal?: AbortSignal } = {}): Promise<number> => {
|
||||
let ran = 0
|
||||
while (consumed < queue.jobs.length) {
|
||||
const job = queue.jobs[consumed] as (typeof queue.jobs)[number]
|
||||
consumed += 1
|
||||
const registered = registry.jobs[job.job]
|
||||
if (!registered) throw new Error(`no module declares the job ${job.job}`)
|
||||
const container = root.child()
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
container.value(TENANCY, tenancy)
|
||||
const trusted = { ...job.scope, actor: systemActor(`${job.job}`) }
|
||||
container.value(DOCUMENTS_SCOPE, trusted)
|
||||
container.value(JOB_SCOPE, trusted)
|
||||
container.value(JOB_ON_BEHALF_OF, null)
|
||||
await registered.def.handler(registered.def.payload.parse(job.payload), {
|
||||
container,
|
||||
correlationId: job.id,
|
||||
attempt: 1,
|
||||
signal: options.signal ?? new AbortController().signal,
|
||||
...(options.finalAttempt === undefined ? {} : { finalAttempt: options.finalAttempt }),
|
||||
})
|
||||
ran += 1
|
||||
}
|
||||
return ran
|
||||
}
|
||||
|
||||
async function seedProject(slug: string, email: string): Promise<SeededProject> {
|
||||
const owner = await signUp(email)
|
||||
const call = caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string; slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "documents",
|
||||
})) as { projectId: string; slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
workspaceSlug: workspace.slug,
|
||||
projectSlug: project.slug,
|
||||
orgId: org.orgId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectId: project.projectId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A signed-up user who joined the project's organization with a role. */
|
||||
async function addMember(project: SeededProject, email: string, role: "viewer" | "member" | "admin") {
|
||||
const actor = await signUp(email)
|
||||
const invite = (await project.call.callByName("tenancy.member.invite", {
|
||||
org: project.orgSlug,
|
||||
email,
|
||||
role,
|
||||
})) as { token: string }
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: project.orgSlug, token: invite.token })
|
||||
return { actor, call }
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
data,
|
||||
entities,
|
||||
store,
|
||||
queue,
|
||||
audit,
|
||||
usage,
|
||||
/** The OCR port the worker uses, observed, and the fake platform behind it. */
|
||||
ocr,
|
||||
platform,
|
||||
/** Every request the model was sent, as the gateway received it, and a way to change its answers. */
|
||||
model: { requests: modelRequests, respond: script },
|
||||
caller,
|
||||
signUp,
|
||||
deliver,
|
||||
runJobs,
|
||||
delivered,
|
||||
seedProject,
|
||||
addMember,
|
||||
}
|
||||
}
|
||||
|
||||
export type DocumentsApp = Awaited<ReturnType<typeof makeDocumentsApp>>
|
||||
|
||||
/** Some text as the bytes of a file, for an upload. */
|
||||
export const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text)
|
||||
|
||||
/** Uploads a file (a PDF unless told otherwise) as its project's owner and carries it through the relay and the worker. */
|
||||
export async function uploadAndProcess(
|
||||
app: DocumentsApp,
|
||||
project: SeededProject,
|
||||
filename = "report.pdf",
|
||||
contentType = "application/pdf",
|
||||
) {
|
||||
const file = await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename,
|
||||
contentType,
|
||||
// The files module checks the first bytes against the type the upload declares, so a picture
|
||||
// starts with the PNG signature and a document with the PDF one.
|
||||
bytes:
|
||||
contentType === "image/png"
|
||||
? new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ...bytesOf(`fixture ${filename}`)])
|
||||
: bytesOf(`%PDF-1.4 fixture ${filename}`),
|
||||
})
|
||||
await app.deliver()
|
||||
await app.runJobs()
|
||||
return file
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The test double of the AI gateway for the documents module: a deterministic reader, standing in for a
|
||||
// model behind `platform.ai.respond` of the fake platform.
|
||||
//
|
||||
// It answers the extraction request exactly as a well-behaved model would: it finds the page's text
|
||||
// between the tags the prompt names, takes every `label: value` line as a field, and quotes the line
|
||||
// itself. It never sees an image, never invents a word, and returns the same lines for the same text,
|
||||
// so a test (or the offline eval) that runs it measures the module's own code -- the request it builds,
|
||||
// the schema it parses, the verification, the cache, the proposals -- and not a model.
|
||||
|
||||
import { PAGE_TEXT_CLOSE, PAGE_TEXT_OPEN } from "@app/modules/documents"
|
||||
import type { FakeChatReply } from "@conusai-kit/testing/fake-platform"
|
||||
|
||||
const FIELD = /^\s*([^::]+?)\s*[::]\s*(\S.*?)\s*$/u
|
||||
const MAX_LABEL_CHARS = 60
|
||||
const MAX_LABEL_WORDS = 6
|
||||
const MAX_LINE_CHARS = 2_000
|
||||
|
||||
export interface ReadLineOfModel {
|
||||
label: string
|
||||
value: string
|
||||
quote: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields a reader of two line shapes finds in a page's text: `label: value`, and a table row
|
||||
* `first cell | other cells`, whose label is the first cell and whose value is the rest, as the prompt
|
||||
* asks a model to read it. The first row of a block of rows is the table's header, which names the
|
||||
* columns and is not a field.
|
||||
*/
|
||||
export function keyValueLines(text: string): ReadLineOfModel[] {
|
||||
const found: ReadLineOfModel[] = []
|
||||
let inTable = false
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.includes(" | ")) {
|
||||
const header = !inTable
|
||||
inTable = true
|
||||
const [label, ...rest] = line.split(" | ").map((cell) => cell.trim())
|
||||
if (!header && label && rest.length > 0 && rest.every((cell) => cell !== "") && line.length <= MAX_LINE_CHARS) {
|
||||
found.push({ label, value: rest.join(" | "), quote: line.trim() })
|
||||
}
|
||||
continue
|
||||
}
|
||||
inTable = false
|
||||
const match = FIELD.exec(line)
|
||||
if (!match) continue
|
||||
const label = match[1] as string
|
||||
const value = match[2] as string
|
||||
if (label.length > MAX_LABEL_CHARS || label.split(/\s+/u).length > MAX_LABEL_WORDS) continue
|
||||
if (line.length > MAX_LINE_CHARS) continue
|
||||
found.push({ label, value, quote: line.trim() })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
type Part = { type?: string; text?: string }
|
||||
|
||||
/** The page text of an extraction request: what lies between the prompt's tags in the user's message. */
|
||||
export function pageTextOf(request: Record<string, unknown>): string | undefined {
|
||||
const messages = (request.messages ?? []) as Array<{ role?: string; content?: string | Part[] }>
|
||||
const user = [...messages].reverse().find((message) => message.role === "user")
|
||||
const content = user?.content
|
||||
const text = typeof content === "string" ? content : (content ?? []).map((part) => part.text ?? "").join("\n")
|
||||
const from = text.indexOf(PAGE_TEXT_OPEN)
|
||||
const to = text.lastIndexOf(PAGE_TEXT_CLOSE)
|
||||
return from < 0 || to < from ? undefined : text.slice(from + PAGE_TEXT_OPEN.length + 1, to - 1)
|
||||
}
|
||||
|
||||
/** What an extraction request is answered with by the deterministic reader. */
|
||||
export const deterministicModel = (request: Record<string, unknown>): FakeChatReply => {
|
||||
const text = pageTextOf(request)
|
||||
return { content: JSON.stringify({ lines: text === undefined ? [] : keyValueLines(text) }) }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Real images for the logo tests: a PNG written by hand, so a test can break one chunk, and a WebP
|
||||
// that libwebp wrote. The kit's logo checks read structure and decode pixels, so a few magic bytes
|
||||
// no longer stand in for an image.
|
||||
|
||||
import { deflateSync, inflateSync } from "node:zlib"
|
||||
|
||||
const TABLE = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
table[n] = c >>> 0
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
const crc32 = (bytes: Uint8Array): number => {
|
||||
let c = 0xffffffff
|
||||
for (const byte of bytes) c = (TABLE[(c ^ byte) & 0xff] as number) ^ (c >>> 8)
|
||||
return (c ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
const concat = (...parts: Uint8Array[]): Buffer => Buffer.concat(parts)
|
||||
|
||||
export const chunk = (type: string, data: Uint8Array): Buffer => {
|
||||
const out = Buffer.alloc(12 + data.length)
|
||||
out.writeUInt32BE(data.length, 0)
|
||||
out.write(type, 4, "latin1")
|
||||
out.set(data, 8)
|
||||
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length)
|
||||
return out
|
||||
}
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
/** A PNG over `pixel(x, y)` -> [r, g, b, a]. */
|
||||
export function pngOf(width: number, height: number, pixel: (x: number, y: number) => readonly number[]): Buffer {
|
||||
const header = Buffer.alloc(13)
|
||||
header.writeUInt32BE(width, 0)
|
||||
header.writeUInt32BE(height, 4)
|
||||
header.set([8, 6, 0, 0, 0], 8)
|
||||
const rows = Buffer.alloc(height * (1 + width * 4))
|
||||
for (let y = 0; y < height; y++)
|
||||
for (let x = 0; x < width; x++) rows.set(pixel(x, y), y * (1 + width * 4) + 1 + x * 4)
|
||||
return concat(SIGNATURE, chunk("IHDR", header), chunk("IDAT", deflateSync(rows)), chunk("IEND", new Uint8Array()))
|
||||
}
|
||||
|
||||
/** A teal disc on transparent that fills the height. */
|
||||
export const markPng = (width: number, height: number, colour: readonly number[] = [0x80, 0xcd, 0xc6, 255]): Buffer =>
|
||||
pngOf(width, height, (x, y) => {
|
||||
const dx = x - width / 2
|
||||
const dy = y - height / 2
|
||||
return dx * dx + dy * dy < (Math.min(width, height) / 2) ** 2 ? colour : [0, 0, 0, 0]
|
||||
})
|
||||
|
||||
/** Pseudo-random pixels, which PNG cannot compress: a file about as large as its pixels. */
|
||||
export function noisePng(width: number, height: number): Buffer {
|
||||
let seed = 12345
|
||||
const next = () => {
|
||||
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
|
||||
return seed >>> 24
|
||||
}
|
||||
return pngOf(width, height, () => [next(), next(), next(), 255])
|
||||
}
|
||||
|
||||
/** A lossless WebP of a teal disc, 400 x 100, written by libwebp. */
|
||||
export const MARK_WEBP = Buffer.from(
|
||||
"UklGRvwAAABXRUJQVlA4TO8AAAAvj8EYEA8wzQM4xvMf8HBU27bT0DNEwpeCNJCGlC/hD9NvKuXe91InEf135EaSInlgeU8DlfmEzM4yACXbFuj4j/SHeNfSX9Nfnu/A/oT+QQToH6RHgfxN+hfPj8CEJzn1l/Qfjs821P+i/1V6Fqj/Rf+r47OefxgAlmFCTjxw6IfOOGVjV5zS2pWmBLu2Uzq7jlN6u85T8NphUv7KDdPK/6D3cdV7G3chH3dsH5OHjwnKxyToY6JVzNmKWd5FwlDkHkW2UuQ3RUZU5FBF1lXkaUFmV/QCiu5B0W8oOhRBT6PoghR9k6LTEvRmdrq5DAA=",
|
||||
"base64",
|
||||
)
|
||||
|
||||
export const dimensions = (png: Uint8Array): { width: number; height: number } => ({
|
||||
width: Buffer.from(png).readUInt32BE(16),
|
||||
height: Buffer.from(png).readUInt32BE(20),
|
||||
})
|
||||
|
||||
/** The [r, g, b, a] of one pixel of an 8-bit RGBA, non-interlaced PNG (what the renderers write), by unfiltering its rows. */
|
||||
export function pixelAt(png: Uint8Array, x: number, y: number): number[] {
|
||||
const bytes = Buffer.from(png)
|
||||
const { width, height } = dimensions(bytes)
|
||||
if (bytes[24] !== 8 || bytes[25] !== 6 || bytes[28] !== 0)
|
||||
throw new Error("pixelAt reads 8-bit RGBA PNGs without interlace")
|
||||
const data: Buffer[] = []
|
||||
for (let at = 8; at < bytes.length; ) {
|
||||
const length = bytes.readUInt32BE(at)
|
||||
if (bytes.toString("latin1", at + 4, at + 8) === "IDAT") data.push(bytes.subarray(at + 8, at + 8 + length))
|
||||
at += 12 + length
|
||||
}
|
||||
const raw = inflateSync(Buffer.concat(data))
|
||||
const stride = width * 4
|
||||
const rows: Buffer[] = []
|
||||
for (let row = 0; row < height; row++) {
|
||||
const filter = raw[row * (stride + 1)] as number
|
||||
const line = Buffer.from(raw.subarray(row * (stride + 1) + 1, (row + 1) * (stride + 1)))
|
||||
const above = rows[row - 1]
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= 4 ? (line[i - 4] as number) : 0
|
||||
const up = above ? (above[i] as number) : 0
|
||||
const corner = above && i >= 4 ? (above[i - 4] as number) : 0
|
||||
const predictor = [0, left, up, (left + up) >> 1, paeth(left, up, corner)][filter] as number
|
||||
line[i] = ((line[i] as number) + predictor) & 0xff
|
||||
}
|
||||
rows.push(line)
|
||||
}
|
||||
const line = rows[y] as Buffer
|
||||
return [line[x * 4], line[x * 4 + 1], line[x * 4 + 2], line[x * 4 + 3]] as number[]
|
||||
}
|
||||
|
||||
function paeth(a: number, b: number, c: number): number {
|
||||
const p = a + b - c
|
||||
const [pa, pb, pc] = [Math.abs(p - a), Math.abs(p - b), Math.abs(p - c)]
|
||||
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Which `modules/<id>/__tests__/isolation.ts` files the tenant-isolation suite reads: those of the modules
|
||||
// the app registers. A module folder that is present but not registered (built and tested, held until its
|
||||
// owner opens it) serves no procedure, so its fixtures would name procedures this registry does not have.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
export function isolationFilesOf(modulesDir: string, registered: Iterable<string>): string[] {
|
||||
const ids = new Set(registered)
|
||||
return readdirSync(modulesDir)
|
||||
.filter((id) => ids.has(id))
|
||||
.map((id) => join(modulesDir, id, "__tests__", "isolation.ts"))
|
||||
.filter((path) => existsSync(path))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Explicit synthetic eligibility for local fake-provider fixtures only. No crypto/live proof.
|
||||
import type { ServiceReadiness } from "@conusai-kit/core"
|
||||
import type { OptionalModuleId } from "../../modules/inclusion.js"
|
||||
export const syntheticNativeReadiness: ServiceReadiness = {
|
||||
profile: "postgres",
|
||||
async observe() {
|
||||
return {
|
||||
desiredGeneration: 1,
|
||||
observedGeneration: 1,
|
||||
observedAt: Date.now(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
entitled: true,
|
||||
authorized: true,
|
||||
configured: true,
|
||||
compatible: true,
|
||||
dependenciesReady: true,
|
||||
partitionBound: "unknown",
|
||||
ready: true,
|
||||
nativeAuthority: {
|
||||
kind: "verified_release_set_v1",
|
||||
policyEpoch: 1,
|
||||
expectedDescriptorDigest: `sha256:${"a".repeat(64)}`,
|
||||
observedDescriptorDigest: `sha256:${"a".repeat(64)}`,
|
||||
expectedSourceCommit: "b".repeat(40),
|
||||
observedSourceCommit: "b".repeat(40),
|
||||
authorityCheckedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional modules whose source this app holds, as `modules/index.ts` composes them. `kit template:prune
|
||||
* --without` removes a line with its module, so a journey that asks for a pruned module runs without it.
|
||||
*/
|
||||
const WITH_SOURCE: readonly OptionalModuleId[] = [
|
||||
"activities",
|
||||
"archive",
|
||||
"catalog",
|
||||
"crm",
|
||||
"inventory",
|
||||
"invoicing",
|
||||
"ledger",
|
||||
"documents", // kit:documents
|
||||
"knowledge", // kit:knowledge
|
||||
"agent", // kit:agent
|
||||
"guides", // kit:guides
|
||||
"support", // kit:support
|
||||
]
|
||||
|
||||
/** A journey opts in only to the modules it exercises, of those this app has; unknown IDs remain off. */
|
||||
export function selectedModules(requested: readonly OptionalModuleId[]) {
|
||||
const selected = requested.filter((id) => WITH_SOURCE.includes(id))
|
||||
const record = { schemaVersion: 1 as const, selected, everAdopted: selected }
|
||||
return {
|
||||
moduleInclusion: record,
|
||||
moduleSelected: (id: OptionalModuleId) => selected.includes(id),
|
||||
moduleAdopted: (id: OptionalModuleId) => record.everAdopted.includes(id),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The whole app of this template on the in-memory data source, wired the way apps/web and apps/worker
|
||||
// wire it, for the notes module's tests: the registry of every module, one caller per actor, the
|
||||
// outbox relay's behaviour and the worker's job runner.
|
||||
//
|
||||
// `deliver()` stands in for the outbox relay: it drains the outbox once, runs each record's
|
||||
// subscribers in a child container with the record's organization and a system actor, and marks it
|
||||
// published -- exactly once per subscriber, never more forgiving than the real relay. `runJobs()`
|
||||
// stands in for the worker's job runner. What neither does is retry, so a test of a redelivery
|
||||
// runs a handler again itself.
|
||||
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import {
|
||||
createMemoryDataSource,
|
||||
DATA_SOURCE,
|
||||
entitiesOf,
|
||||
JOB_QUEUE,
|
||||
OBJECT_STORE,
|
||||
SYSTEM_DATA,
|
||||
} from "@conusai-kit/data"
|
||||
import { FILES_AUDIT, FILES_REGISTRY, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
|
||||
import { NOTIFICATIONS_REPOS } from "@conusai-kit/notifications"
|
||||
import { PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
|
||||
import { createTenancyHooks, TENANCY, users } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
fakeJobQueue,
|
||||
fakeObjectStore,
|
||||
recordingAudit,
|
||||
recordingUsage,
|
||||
uploadViaProcedures,
|
||||
} from "@conusai-kit/testing"
|
||||
import { appModules } from "../../modules/index.js"
|
||||
import { NOTES_FLAGS, type NotesFlags } from "../../modules/notes/index.js"
|
||||
|
||||
export interface SeededProject {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly orgId: string
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
/** Standard base64 of some text, for the upload procedure's body. */
|
||||
export const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text)
|
||||
|
||||
export function makeNotesApp(options: { flags?: NotesFlags } = {}) {
|
||||
const registry = buildRegistry([...appModules])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const store = fakeObjectStore()
|
||||
const queue = fakeJobQueue()
|
||||
const audit = recordingAudit()
|
||||
const usage = recordingUsage()
|
||||
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(OBJECT_STORE, store)
|
||||
root.value(JOB_QUEUE, queue)
|
||||
root.value(FILES_REGISTRY, registry)
|
||||
root.value(FILES_AUDIT, audit)
|
||||
root.value(FILES_USAGE, usage)
|
||||
root.value(PROPOSALS_REGISTRY, registry)
|
||||
if (options.flags) root.value(NOTES_FLAGS, options.flags)
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy }, { actor })
|
||||
}
|
||||
|
||||
const signUp = async (email: string): Promise<Actor> => {
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
/** Runs every subscriber of every record in the outbox, once each; answers how many ran. */
|
||||
const deliver = async (): Promise<number> => {
|
||||
let ran = 0
|
||||
for (const record of await data.outbox.pending(500)) {
|
||||
const entry = registry.events[record.name]
|
||||
for (const subscriber of entry?.subscribers ?? []) {
|
||||
const container = root.child()
|
||||
container.value(FILES_SCOPE, { level: "org", actor: systemActor("app.relay"), orgId: record.orgId })
|
||||
container.value(NOTIFICATIONS_REPOS, data.forScope(container.resolve(FILES_SCOPE)))
|
||||
await subscriber.handler(
|
||||
{ name: record.name, payload: record.payload },
|
||||
{ container, correlationId: record.id },
|
||||
)
|
||||
ran += 1
|
||||
}
|
||||
await data.outbox.markPublished([record.id])
|
||||
}
|
||||
return ran
|
||||
}
|
||||
|
||||
/** The jobs not yet run, in the order they were enqueued. */
|
||||
let consumed = 0
|
||||
const runJobs = async (): Promise<number> => {
|
||||
let ran = 0
|
||||
while (consumed < queue.jobs.length) {
|
||||
const job = queue.jobs[consumed] as (typeof queue.jobs)[number]
|
||||
consumed += 1
|
||||
const registered = registry.jobs[job.job]
|
||||
if (!registered) throw new Error(`no module declares the job ${job.job}`)
|
||||
const container = root.child()
|
||||
container.value(TENANCY, createTenancyHooks({ data, registry }))
|
||||
container.value(FILES_SCOPE, { ...job.scope, actor: systemActor(job.job) })
|
||||
await registered.def.handler(registered.def.payload.parse(job.payload), {
|
||||
container,
|
||||
correlationId: job.id,
|
||||
attempt: 1,
|
||||
})
|
||||
ran += 1
|
||||
}
|
||||
return ran
|
||||
}
|
||||
|
||||
async function seedProject(slug: string, email: string): Promise<SeededProject> {
|
||||
const owner = await signUp(email)
|
||||
const call = caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "notes",
|
||||
})) as { slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
orgId: org.orgId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A signed-up user who joined the project's organization with a role. */
|
||||
async function addMember(project: SeededProject, email: string, role: "viewer" | "member" | "admin") {
|
||||
const actor = await signUp(email)
|
||||
const invite = (await project.call.callByName("tenancy.member.invite", {
|
||||
org: project.orgSlug,
|
||||
email,
|
||||
role,
|
||||
})) as { token: string }
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: project.orgSlug, token: invite.token })
|
||||
return { actor, call }
|
||||
}
|
||||
|
||||
/** Uploads a small text file through the files module, the way the page's form does. */
|
||||
async function upload(project: SeededProject, filename = "minutes.txt", text = "agreed") {
|
||||
return await uploadViaProcedures(project.call, project.slugs, {
|
||||
filename: filename,
|
||||
contentType: "text/plain",
|
||||
bytes: bytesOf(text),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
/** The process container the procedures resolve their ports from (a seed script needs it). */
|
||||
root,
|
||||
data,
|
||||
entities,
|
||||
store,
|
||||
queue,
|
||||
audit,
|
||||
usage,
|
||||
caller,
|
||||
signUp,
|
||||
deliver,
|
||||
runJobs,
|
||||
seedProject,
|
||||
addMember,
|
||||
upload,
|
||||
}
|
||||
}
|
||||
|
||||
export type NotesApp = ReturnType<typeof makeNotesApp>
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The OCR path of a test: the real `@conusai-sdk/ocr` client (through kit-platform's `DocumentReader`)
|
||||
// over HTTP to the fake platform of @conusai-kit/testing, which accepts a job and answers it as the
|
||||
// platform's job API does. Nothing here stands in for the OCR path; `observed` only watches it, and can
|
||||
// make one call fail the way a network does, which the fake platform has no way to do.
|
||||
|
||||
import { type DocumentOcr, sdkDocumentOcr } from "@app/modules/documents"
|
||||
import { createDocumentReader } from "@conusai-kit/platform"
|
||||
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
||||
|
||||
export type FakePlatform = Awaited<ReturnType<typeof startFakePlatform>>
|
||||
|
||||
export interface ObservedOcr extends DocumentOcr {
|
||||
/** Every document accepted, with the idempotency key it came with. */
|
||||
readonly submitted: Array<{ jobId: number; filename: string; idempotencyKey: string }>
|
||||
/** Every `wait`, by job id: a retried job must resume its job and not submit another. */
|
||||
readonly waited: number[]
|
||||
/** The next `wait` throws this, once, before asking the platform anything. */
|
||||
failWaitOnce(error: Error): void
|
||||
}
|
||||
|
||||
export function observed(inner: DocumentOcr): ObservedOcr {
|
||||
const submitted: ObservedOcr["submitted"] = []
|
||||
const waited: number[] = []
|
||||
let nextWait: Error | undefined
|
||||
return {
|
||||
submitted,
|
||||
waited,
|
||||
failWaitOnce(error) {
|
||||
nextWait = error
|
||||
},
|
||||
async submit(bytes, options) {
|
||||
const accepted = await inner.submit(bytes, options)
|
||||
// The same key is the same job on the platform; a repeat is not a second submission.
|
||||
if (!submitted.some((entry) => entry.jobId === accepted.jobId)) {
|
||||
submitted.push({ jobId: accepted.jobId, filename: options.filename, idempotencyKey: options.idempotencyKey })
|
||||
}
|
||||
return accepted
|
||||
},
|
||||
page: (jobId, page, options) => inner.page(jobId, page, options),
|
||||
async wait(jobId, options) {
|
||||
waited.push(jobId)
|
||||
if (nextWait) {
|
||||
const error = nextWait
|
||||
nextWait = undefined
|
||||
throw error
|
||||
}
|
||||
return inner.wait(jobId, options)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A fake platform that reads every document as `document`, and the app-side OCR port over it. */
|
||||
export async function startOcr(
|
||||
document: Record<string, unknown>,
|
||||
): Promise<{ platform: FakePlatform; ocr: ObservedOcr }> {
|
||||
const platform = await startFakePlatform()
|
||||
platform.ocr.setDocument(document)
|
||||
const reader = createDocumentReader({ CONUSAI_API_URL: `${platform.url}/api`, CONUSAI_API_TOKEN: "deployment-token" })
|
||||
return { platform, ocr: observed(sdkDocumentOcr(reader, { intervalMs: 1 })) }
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The web process's tenancy rules around a request's caller (apps/web/src/server/tenancy-calls.ts,
|
||||
// ADR-062), over the app's real registry and kit-tenancy's procedures:
|
||||
//
|
||||
// - an invitation, once committed, admits its address through @conusai-kit/auth's real platform client
|
||||
// against the fake admission endpoint of @conusai-kit/testing, audited with ids only
|
||||
// - with no admission credential the invitation still answers, and the skip is logged with its reason
|
||||
// - a refused or unreachable admission is logged and audited as a failure, never thrown to the inviter
|
||||
// - an invitation that is refused (a role above the inviter's) asks for no admission at all
|
||||
// - with self-service off, only the app-access role `admin` founds an organization; a token without the
|
||||
// claim is not `admin`
|
||||
// - on the Convex profile a grant-changing write is announced to the organization's streams here
|
||||
|
||||
import { createPlatformIdentityAdmission } from "@conusai-kit/auth"
|
||||
import { Container } from "@conusai-kit/core"
|
||||
import { GRANTS_CHANGED } from "@conusai-kit/tenancy"
|
||||
import { fakeIdentityAdmission } from "@conusai-kit/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { type TenancyCallOptions, withTenancyCalls } from "../apps/web/src/server/tenancy-calls.js"
|
||||
import { identityLinkOf, organizationSelfServiceOf } from "../config/src/access.js"
|
||||
import { makeApp, type Person } from "../modules/account/__tests__/harness.js"
|
||||
|
||||
const app = makeApp()
|
||||
|
||||
type Audited = Parameters<TenancyCallOptions["audit"]>[1]
|
||||
|
||||
function wrapped(person: Person, overrides: Partial<TenancyCallOptions> = {}) {
|
||||
const audited: Audited[] = []
|
||||
const log = { info: vi.fn(), warn: vi.fn() }
|
||||
const caller = withTenancyCalls(person.call(), {
|
||||
principal: { actor: person.actor },
|
||||
container: new Container(),
|
||||
selfServiceOrganizations: true,
|
||||
admission: () => ({ skipped: "no OIDC_ADMISSION_TOKEN" }),
|
||||
audit: async (_action, event) => {
|
||||
audited.push(event)
|
||||
},
|
||||
log,
|
||||
...overrides,
|
||||
})
|
||||
return { caller, audited, log }
|
||||
}
|
||||
|
||||
describe("an invitation's admission (ADR-062 decision 4)", async () => {
|
||||
const ada = await app.signUp("ada-admission@example.test")
|
||||
await ada.call().callByName("tenancy.org.create", { slug: "acme-admission", name: "Acme" })
|
||||
|
||||
it("admits the invited address once the invitation committed, and audits ids only", async () => {
|
||||
const platform = fakeIdentityAdmission()
|
||||
const admission = createPlatformIdentityAdmission({
|
||||
apiUrl: platform.apiUrl,
|
||||
linkId: platform.linkId,
|
||||
token: platform.token,
|
||||
fetch: platform.fetch,
|
||||
})
|
||||
const { caller, audited, log } = wrapped(ada, { admission: () => admission })
|
||||
const invitation = (await caller.callByName("tenancy.member.invite", {
|
||||
org: "acme-admission",
|
||||
email: " New.Person@Example.test ",
|
||||
role: "member",
|
||||
})) as { invitationId: string; token: string }
|
||||
expect(invitation.token.length).toBeGreaterThan(16)
|
||||
expect(platform.admissions).toEqual([
|
||||
expect.objectContaining({ email: "new.person@example.test", invitationId: invitation.invitationId }),
|
||||
])
|
||||
expect(platform.calls[0]).toMatchObject({ method: "POST", credential: "valid", status: 202 })
|
||||
expect(audited).toEqual([{ orgSlug: "acme-admission", invitationId: invitation.invitationId, outcome: "success" }])
|
||||
// Neither the log nor the audit event carries the address.
|
||||
expect(JSON.stringify([log.info.mock.calls, log.warn.mock.calls, audited])).not.toContain("example.test")
|
||||
})
|
||||
|
||||
it("answers the invitation without an admission credential, and logs why it skipped", async () => {
|
||||
const { caller, audited, log } = wrapped(ada)
|
||||
const invitation = (await caller.callByName("tenancy.member.invite", {
|
||||
org: "acme-admission",
|
||||
email: "skipped@example.test",
|
||||
role: "viewer",
|
||||
})) as { invitationId: string }
|
||||
expect(invitation.invitationId).toBeTruthy()
|
||||
expect(audited).toEqual([])
|
||||
expect(log.info).toHaveBeenCalledWith("invitation: admission skipped: no OIDC_ADMISSION_TOKEN", {
|
||||
"conusai.invitation_id": invitation.invitationId,
|
||||
})
|
||||
})
|
||||
|
||||
it("answers the invitation when the admission client cannot be built", async () => {
|
||||
const { caller, log } = wrapped(ada, {
|
||||
admission: () => {
|
||||
throw new Error("set OIDC_AUDIENCE")
|
||||
},
|
||||
})
|
||||
const invitation = (await caller.callByName("tenancy.member.invite", {
|
||||
org: "acme-admission",
|
||||
email: "unbuilt@example.test",
|
||||
role: "viewer",
|
||||
})) as { invitationId: string }
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
"invitation: admission skipped: the admission client could not be built (Error)",
|
||||
{
|
||||
"conusai.invitation_id": invitation.invitationId,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[429, "rate_limited"],
|
||||
[401, "unauthorized"],
|
||||
[503, "unavailable"],
|
||||
["network", "unavailable"],
|
||||
] as const)(
|
||||
"keeps the invitation when the platform answers %s, logged and audited as %s",
|
||||
async (refusal, reason) => {
|
||||
const platform = fakeIdentityAdmission()
|
||||
platform.refuseNext(refusal)
|
||||
const admission = createPlatformIdentityAdmission({
|
||||
apiUrl: platform.apiUrl,
|
||||
linkId: platform.linkId,
|
||||
token: platform.token,
|
||||
fetch: platform.fetch,
|
||||
})
|
||||
const { caller, audited, log } = wrapped(ada, { admission: () => admission })
|
||||
const email = `refused-${String(refusal)}@example.test`
|
||||
const invitation = (await caller.callByName("tenancy.member.invite", {
|
||||
org: "acme-admission",
|
||||
email,
|
||||
role: "viewer",
|
||||
})) as { invitationId: string }
|
||||
expect(invitation.invitationId).toBeTruthy()
|
||||
expect(platform.admissions).toEqual([])
|
||||
expect(log.warn).toHaveBeenCalledWith(`invitation: admission failed: ${reason}`, {
|
||||
"conusai.invitation_id": invitation.invitationId,
|
||||
})
|
||||
expect(audited).toEqual([
|
||||
{ orgSlug: "acme-admission", invitationId: invitation.invitationId, outcome: "failure", reason },
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
it("asks for no admission when the invitation itself is refused", async () => {
|
||||
const grace = await app.signUp("grace-admission@example.test")
|
||||
const joined = (await ada.call().callByName("tenancy.member.invite", {
|
||||
org: "acme-admission",
|
||||
email: grace.email,
|
||||
role: "admin",
|
||||
})) as { token: string }
|
||||
await grace.call().callByName("tenancy.invitation.accept", { org: "acme-admission", token: joined.token })
|
||||
const platform = fakeIdentityAdmission()
|
||||
const admission = createPlatformIdentityAdmission({
|
||||
apiUrl: platform.apiUrl,
|
||||
linkId: platform.linkId,
|
||||
token: platform.token,
|
||||
fetch: platform.fetch,
|
||||
})
|
||||
const { caller, audited } = wrapped(grace, { admission: () => admission })
|
||||
// An administrator cannot invite an owner (kit-tenancy's ceiling): nothing committed, nothing admitted.
|
||||
await expect(
|
||||
caller.callByName("tenancy.member.invite", { org: "acme-admission", email: "owner@example.test", role: "owner" }),
|
||||
).rejects.toMatchObject({ status: 403 })
|
||||
expect(platform.calls).toEqual([])
|
||||
expect(audited).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("founding an organization when self-service is off (ADR-062 decision 5)", async () => {
|
||||
const linus = await app.signUp("linus-founder@example.test")
|
||||
|
||||
it("refuses a person without the app-access role, and one whose token carries no claim", async () => {
|
||||
for (const principal of [{ actor: linus.actor }, { actor: linus.actor, appAccess: { role: "user" as const } }]) {
|
||||
const { caller } = wrapped(linus, { selfServiceOrganizations: false, principal })
|
||||
await expect(
|
||||
caller.callByName("tenancy.org.create", { slug: "refused-founder", name: "Refused" }),
|
||||
).rejects.toMatchObject({ status: 403, code: "forbidden" })
|
||||
}
|
||||
// Nothing was written: the slug is still free for the operator below.
|
||||
expect(
|
||||
((await linus.call().callByName("tenancy.me.get", undefined)) as { organizations: unknown[] }).organizations,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("lets the app-access role admin found one, and anyone when self-service is on", async () => {
|
||||
const admin = wrapped(linus, {
|
||||
selfServiceOrganizations: false,
|
||||
principal: { actor: linus.actor, appAccess: { role: "admin" } },
|
||||
})
|
||||
await expect(
|
||||
admin.caller.callByName("tenancy.org.create", { slug: "refused-founder", name: "Ops" }),
|
||||
).resolves.toMatchObject({
|
||||
slug: "refused-founder",
|
||||
})
|
||||
const open = wrapped(linus, { selfServiceOrganizations: true })
|
||||
await expect(
|
||||
open.caller.callByName("tenancy.org.create", { slug: "self-founded", name: "Self" }),
|
||||
).resolves.toMatchObject({
|
||||
slug: "self-founded",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("a grant change made in the Convex deployment", async () => {
|
||||
const ada = await app.signUp("ada-remote-grants@example.test")
|
||||
await ada.call().callByName("tenancy.org.create", { slug: "acme-remote", name: "Acme" })
|
||||
const grace = await app.signUp("grace-remote-grants@example.test")
|
||||
const invitation = (await ada.call().callByName("tenancy.member.invite", {
|
||||
org: "acme-remote",
|
||||
email: grace.email,
|
||||
role: "member",
|
||||
})) as { token: string }
|
||||
await grace.call().callByName("tenancy.invitation.accept", { org: "acme-remote", token: invitation.token })
|
||||
|
||||
it("is announced to this process's streams of the organization, and only of a streamed one", async () => {
|
||||
const container = new Container()
|
||||
const announced: string[] = []
|
||||
container.value(GRANTS_CHANGED, { grantsChanged: async (orgId) => void announced.push(orgId) })
|
||||
const orgIdOf = (slug: string) => (slug === "acme-remote" ? "org-id-of-acme" : undefined)
|
||||
const { caller } = wrapped(ada, { container, remoteGrants: { orgIdOf } })
|
||||
await caller.callByName("tenancy.member.change-role", {
|
||||
org: "acme-remote",
|
||||
userId: grace.actor.userId,
|
||||
role: "viewer",
|
||||
})
|
||||
expect(announced).toEqual(["org-id-of-acme"])
|
||||
// A read is no grant change.
|
||||
await caller.callByName("tenancy.member.list", { org: "acme-remote" })
|
||||
expect(announced).toEqual(["org-id-of-acme"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the access settings of the deployment", () => {
|
||||
it("reads APP_ORGANIZATION_SELF_SERVICE as true by default and refuses anything but true or false", () => {
|
||||
expect(organizationSelfServiceOf({})).toBe(true)
|
||||
expect(organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "" })).toBe(true)
|
||||
expect(organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "false" })).toBe(false)
|
||||
expect(() => organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "no" })).toThrow(
|
||||
"set APP_ORGANIZATION_SELF_SERVICE to true or false",
|
||||
)
|
||||
})
|
||||
|
||||
it("takes the identity link with its admission credential, skips without both, and refuses one of the two", () => {
|
||||
expect(identityLinkOf({})).toEqual({
|
||||
skipped: "no OIDC_ADMISSION_TOKEN: the identity link issued no admission credential to this app",
|
||||
})
|
||||
expect(identityLinkOf({ OIDC_LINK_ID: "4f1c2a7e-6b1d", OIDC_ADMISSION_TOKEN: "cai_adm_x" })).toEqual({
|
||||
linkId: "4f1c2a7e-6b1d",
|
||||
})
|
||||
for (const env of [{ OIDC_LINK_ID: "4f1c" }, { OIDC_ADMISSION_TOKEN: "cai_adm_secret" }]) {
|
||||
expect(() => identityLinkOf(env)).toThrow("set OIDC_LINK_ID and OIDC_ADMISSION_TOKEN together")
|
||||
}
|
||||
// The credential is never quoted back.
|
||||
expect(() => identityLinkOf({ OIDC_ADMISSION_TOKEN: "cai_adm_secret" })).not.toThrow(/cai_adm_secret/)
|
||||
expect(() => identityLinkOf({ OIDC_LINK_ID: "../other", OIDC_ADMISSION_TOKEN: "t" })).toThrow(
|
||||
"set OIDC_LINK_ID to the identity link's id",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Two people's first paints, one server process. `useCan` caches a person's grants on the data client, so
|
||||
// a client shared across renders would answer the second person with the first person's grants.
|
||||
|
||||
import { Can } from "@conusai-kit/ui/data"
|
||||
import { createElement } from "react"
|
||||
import { renderToString } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { AppTransport } from "../apps/web/src/ui/transport.js"
|
||||
|
||||
const scope = { org: "acme", workspace: "main" }
|
||||
const firstPaint = (permissions: readonly `${string}.${string}.${string}`[]): string =>
|
||||
renderToString(
|
||||
createElement(AppTransport, {
|
||||
permissions: { scope, seed: { scope, permissions: [...permissions], revision: "1" } },
|
||||
children: createElement(Can, { permission: "notes.note.write", fallback: "reads only", children: "writes" }),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("a server render's permissions", () => {
|
||||
it("are the person's own, whoever the same process rendered first", () => {
|
||||
expect(firstPaint(["notes.note.write"])).toContain("writes")
|
||||
expect(firstPaint([])).toContain("reads only")
|
||||
expect(firstPaint(["notes.note.write"])).toContain("writes")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.paths.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"lib": ["ES2023", "dom"]
|
||||
},
|
||||
"include": ["**/*.ts", "../vitest.config.ts", "../scripts/*.cjs"],
|
||||
"//": "The tests read browser-facing modules as well as server ones, so the DOM library is in scope here even though nothing in test/ runs in a browser."
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// `ConusAI-Txid` (umbrella TS6.3): a write's answer carries the transaction id it committed under,
|
||||
// which the browser's live collections await before they let go of an optimistic row; a read, a
|
||||
// write that committed nothing and a refused write carry none. The kernel's caller is stubbed with
|
||||
// one that commits the way kit-data-kysely does inside a capture (`recordTxid` after the commit);
|
||||
// the capture itself is proven against a real Postgres in @conusai-kit/data-kysely's own gate.
|
||||
|
||||
import type { Principal } from "@conusai-kit/auth"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { recordTxid } from "@conusai-kit/data-kysely"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
const route = (kind: "read" | "write", scope: "project" | "user" = "project") => ({
|
||||
module: "notes",
|
||||
kind,
|
||||
scope,
|
||||
permission: "notes.note.read",
|
||||
idempotent: false,
|
||||
tool: false,
|
||||
download: false,
|
||||
})
|
||||
|
||||
vi.mock("../apps/web/src/server/app", () => ({
|
||||
convexCapabilityStatus: async () => ({ ready: true }),
|
||||
webRuntime: () => ({
|
||||
registry: {
|
||||
router: {
|
||||
"notes.note.list": route("read"),
|
||||
"notes.note.create": route("write"),
|
||||
"notes.note.noop": route("write"),
|
||||
"notes.note.refused": route("write"),
|
||||
},
|
||||
},
|
||||
platform: { audit: { log: async () => undefined }, usage: { track: () => undefined } },
|
||||
observe: {
|
||||
span: (_name: string, _attributes: unknown, run: () => unknown) => run(),
|
||||
log: { info: () => undefined, error: () => undefined },
|
||||
captureException: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const principal: Principal = { actor: { userId: "u-txid" }, method: "bearer" } as Principal
|
||||
|
||||
vi.mock("../apps/web/src/server/request", () => ({
|
||||
requestContext: async () => ({
|
||||
principal,
|
||||
caller: {
|
||||
callByName: async (name: string) => {
|
||||
// Two commits, as a write with an outbox event in a second transaction makes: the higher wins.
|
||||
if (name === "notes.note.create") {
|
||||
await Promise.resolve()
|
||||
recordTxid("7001")
|
||||
recordTxid("7003")
|
||||
}
|
||||
if (name === "notes.note.refused") {
|
||||
recordTxid("9999")
|
||||
throw new AppError({ type: "/problems/conflict", title: "Conflict", status: 409, code: "conflict" })
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
async function post(name: string): Promise<Response> {
|
||||
const { handle } = await import("../apps/web/src/server/router")
|
||||
return handle(
|
||||
new Request(`https://app.example.test/api/v1/${name}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer stub" },
|
||||
body: JSON.stringify({ org: "o", workspace: "w", project: "p" }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ConusAI-Txid", () => {
|
||||
it("is the highest transaction id a write committed", async () => {
|
||||
const answer = await post("notes.note.create")
|
||||
expect(answer.status).toBe(200)
|
||||
expect(answer.headers.get("conusai-txid")).toBe("7003")
|
||||
})
|
||||
|
||||
it("is absent from a read, from a write that committed nothing and from a refused write", async () => {
|
||||
expect((await post("notes.note.list")).headers.get("conusai-txid")).toBeNull()
|
||||
expect((await post("notes.note.noop")).headers.get("conusai-txid")).toBeNull()
|
||||
const refused = await post("notes.note.refused")
|
||||
expect(refused.status).toBe(409)
|
||||
expect(refused.headers.get("conusai-txid")).toBeNull()
|
||||
})
|
||||
|
||||
it("does not leak one request's id into the next", async () => {
|
||||
const [first, second] = await Promise.all([post("notes.note.create"), post("notes.note.noop")])
|
||||
expect(first.headers.get("conusai-txid")).toBe("7003")
|
||||
expect(second.headers.get("conusai-txid")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A screen is a declaration: data from `load`, columns, actions and labels, handed to the kit's patterns
|
||||
// (docs/how-to/compose-a-page.md in the platform's documentation). So a screen has no styling of its own: no `className`, no
|
||||
// colour value. React Aria is out of a screen's reach too, by the `ui-comes-from-the-kit` rule of
|
||||
// scripts/dep-rules.cjs. A screen that is truly domain-specific says so, and why, in its first lines:
|
||||
// `// kit:custom-ui <why>`. `pnpm boundaries` and this suite read the same marker.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { buildForbidden, listCustomScreens, listScreens } = require("../scripts/dep-rules.cjs") as {
|
||||
buildForbidden(
|
||||
moduleIds: string[],
|
||||
custom?: string[],
|
||||
): { name: string; from: { path?: string; pathNot?: string | string[] }; to: { path?: string | string[] } }[]
|
||||
listCustomScreens(root: string): string[]
|
||||
listScreens(root: string): string[]
|
||||
}
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname
|
||||
const COLOUR = /["'`](?:#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(|oklch\()/
|
||||
|
||||
/** What a declared screen may not hold, by file, with the way out in the message. */
|
||||
function styling(source: string): string[] {
|
||||
const found: string[] = []
|
||||
if (/<(?:button|input|select|textarea|table)(?:\s|>)/.test(source))
|
||||
found.push("raw control: compose kit primitives or patterns")
|
||||
if (/\bclassName=/.test(source)) found.push("className: use a pattern's slot, or a variant")
|
||||
if (COLOUR.test(source)) found.push("a colour value: the kit's roles carry every colour")
|
||||
return found
|
||||
}
|
||||
|
||||
describe("a screen is a declaration", () => {
|
||||
const custom = new Set(listCustomScreens(root))
|
||||
|
||||
it("holds no styling of its own unless it declares itself custom", () => {
|
||||
const problems = listScreens(root)
|
||||
.filter((file) => !custom.has(file))
|
||||
.flatMap((file) =>
|
||||
styling(readFileSync(join(root, file), "utf8")).map(
|
||||
(problem) => `${file}: ${problem}, or \`// kit:custom-ui <why>\``,
|
||||
),
|
||||
)
|
||||
expect(problems).toEqual([])
|
||||
})
|
||||
|
||||
it("catches what it means to", () => {
|
||||
expect(styling(`<p className="x" />`)).toHaveLength(1)
|
||||
expect(styling(`const c = "#123abc"`)).toHaveLength(1)
|
||||
expect(styling(`const c = "oklch(0.5 0 0)"`)).toHaveLength(1)
|
||||
expect(styling(`<a href="#section" />`)).toEqual([])
|
||||
expect(styling(`<button type="button" />`)).toHaveLength(1)
|
||||
expect(styling(`<PageHeader title={title} />`)).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps React Aria behind the kit, for every screen that has not declared itself", () => {
|
||||
const rule = buildForbidden([], ["modules/x/ui/own.tsx"]).find(({ name }) => name === "ui-comes-from-the-kit")
|
||||
const target = new RegExp(rule?.to.path as string)
|
||||
expect(target.test("react-aria-components")).toBe(true)
|
||||
expect(target.test("node_modules/.pnpm/x/node_modules/@react-aria/button/dist/index.js")).toBe(true)
|
||||
expect(target.test("@conusai-kit/ui/patterns")).toBe(false)
|
||||
expect(rule?.from.pathNot).toEqual(["^modules/x/ui/own\\.tsx$"])
|
||||
})
|
||||
|
||||
it("leaves custom screens the exception", () => {
|
||||
expect(custom.size).toBeLessThan(listScreens(root).length / 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// SIGTERM, which the platform sends before it replaces a container. The real `server.mjs` runs against a stand-in
|
||||
// for the Start build: the order is stop taking requests, finish the ones in flight, drain the runtime (usage queue,
|
||||
// exporters, pools), and only then exit. A process that exits on its own signal handler loses the queue.
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process"
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
const WEB = fileURLToPath(new URL("../apps/web/", import.meta.url))
|
||||
|
||||
/** The stand-in: `/slow` holds a request, `/start` builds the "runtime", which registers its drain as app.ts does. */
|
||||
const BUILD = `
|
||||
import { appendFileSync } from "node:fs"
|
||||
const log = process.env.SHUTDOWN_LOG
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const note = (line) => appendFileSync(log, line + "\\n")
|
||||
const drainMs = Number(process.env.SHUTDOWN_DRAIN_MS ?? 300)
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const { pathname } = new URL(request.url)
|
||||
if (pathname === "/start") {
|
||||
globalThis[Symbol.for("conusai.app.drain")] = async () => {
|
||||
note("drain began")
|
||||
await sleep(drainMs)
|
||||
note("drain ended")
|
||||
}
|
||||
}
|
||||
if (pathname === "/stream") {
|
||||
return new Response(new ReadableStream({ start(controller) { controller.enqueue("open\\n") } }))
|
||||
}
|
||||
if (pathname === "/slow") {
|
||||
await sleep(400)
|
||||
note("request finished")
|
||||
}
|
||||
return new Response("ok")
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
let directory: string | undefined
|
||||
let server: ChildProcess | undefined
|
||||
|
||||
afterEach(() => {
|
||||
server?.kill("SIGKILL")
|
||||
if (directory) rmSync(directory, { recursive: true, force: true })
|
||||
directory = undefined
|
||||
})
|
||||
|
||||
/** Starts the real server.mjs beside the stand-in, inside apps/web so that hono resolves as it does in the image. */
|
||||
async function boot(
|
||||
env: Record<string, string> = {},
|
||||
): Promise<{ origin: string; log: string; exited: Promise<number> }> {
|
||||
directory = mkdtempSync(join(WEB, ".shutdown-test-"))
|
||||
mkdirSync(join(directory, "dist", "server"), { recursive: true })
|
||||
mkdirSync(join(directory, "dist", "client"), { recursive: true })
|
||||
copyFileSync(join(WEB, "server.mjs"), join(directory, "server.mjs"))
|
||||
writeFileSync(join(directory, "dist", "server", "server.js"), BUILD)
|
||||
const log = join(directory, "shutdown.log")
|
||||
writeFileSync(log, "")
|
||||
const port = 34_000 + Math.floor(Math.random() * 20_000)
|
||||
const child = spawn(process.execPath, ["server.mjs", "--port", String(port)], {
|
||||
cwd: directory,
|
||||
env: { ...process.env, HOSTNAME: "127.0.0.1", SHUTDOWN_LOG: log, ...env },
|
||||
stdio: "ignore",
|
||||
})
|
||||
server = child
|
||||
const exited = new Promise<number>((resolve) => child.once("exit", (code) => resolve(code ?? -1)))
|
||||
const origin = `http://127.0.0.1:${port}`
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (
|
||||
await fetch(`${origin}/health`).then(
|
||||
(answer) => answer.ok,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
return { origin, log, exited }
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error("server.mjs did not start")
|
||||
}
|
||||
|
||||
const lines = (log: string): string[] => readFileSync(log, "utf8").split("\n").filter(Boolean)
|
||||
|
||||
describe("the web server on SIGTERM", () => {
|
||||
it("drains the runtime to its end before the process exits", async () => {
|
||||
const { origin, log, exited } = await boot()
|
||||
await fetch(`${origin}/start`)
|
||||
server?.kill("SIGTERM")
|
||||
expect(await exited).toBe(0)
|
||||
expect(lines(log)).toEqual(["drain began", "drain ended"])
|
||||
}, 15_000)
|
||||
|
||||
it("finishes the request in flight before it drains, so its usage is in the queue", async () => {
|
||||
const { origin, log, exited } = await boot()
|
||||
await fetch(`${origin}/start`)
|
||||
const slow = fetch(`${origin}/slow`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
server?.kill("SIGTERM")
|
||||
expect((await slow).status).toBe(200)
|
||||
expect(await exited).toBe(0)
|
||||
expect(lines(log)).toEqual(["request finished", "drain began", "drain ended"])
|
||||
}, 15_000)
|
||||
|
||||
it("does not let a stream that never ends hold the drain past its bound", async () => {
|
||||
const { origin, log, exited } = await boot({ SHUTDOWN_REQUESTS_MS: "300" })
|
||||
await fetch(`${origin}/start`)
|
||||
const stream = await fetch(`${origin}/stream`)
|
||||
expect(stream.status).toBe(200)
|
||||
server?.kill("SIGTERM")
|
||||
expect(await exited).toBe(0)
|
||||
expect(lines(log)).toEqual(["drain began", "drain ended"])
|
||||
}, 15_000)
|
||||
|
||||
it("exits at once when no request ever built a runtime", async () => {
|
||||
const { log, exited } = await boot()
|
||||
server?.kill("SIGTERM")
|
||||
expect(await exited).toBe(0)
|
||||
expect(lines(log)).toEqual([])
|
||||
}, 15_000)
|
||||
|
||||
it("exits non-zero rather than hang when the drain does not finish", async () => {
|
||||
const { origin, log, exited } = await boot({ SHUTDOWN_DRAIN_MS: "60000", SHUTDOWN_FLUSH_MS: "300" })
|
||||
await fetch(`${origin}/start`)
|
||||
server?.kill("SIGTERM")
|
||||
expect(await exited).toBe(1)
|
||||
expect(lines(log)).toEqual(["drain began"])
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A module's pages above a project (F10): `/<org>/<workspace>/modules/<module>/...`. Signed out is a redirect,
|
||||
// a workspace the person cannot see is the same 404 as one that does not exist, a missing permission is a 403
|
||||
// before `load` runs, and `load` receives the query string. `e2e/workspace-pages.spec.ts` proves the same four
|
||||
// answers over a build on a real Postgres; these say which line gives each one.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { isNotFound, isRedirect } from "@tanstack/react-router"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requestContext: vi.fn(),
|
||||
callByName: vi.fn(),
|
||||
setResponseHeader: vi.fn<(name: string, value: string) => void>(),
|
||||
load: vi.fn(),
|
||||
call: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getRequest: () => {
|
||||
throw new Error("the tests pass their request")
|
||||
},
|
||||
setResponseStatus: vi.fn(),
|
||||
setResponseHeader: mocks.setResponseHeader,
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/request", () => ({ requestContext: mocks.requestContext }))
|
||||
vi.mock("../apps/web/src/server/app", () => ({
|
||||
webRuntime: () => ({
|
||||
observe: { config: { errors: false } },
|
||||
selfServiceOrganizations: "members",
|
||||
registry: {
|
||||
modules: [{ id: "reports" }, { id: "unpaged" }],
|
||||
nav: [{ module: "reports", scope: "workspace", href: "reports/a/b", label: "workspace.overview" }],
|
||||
},
|
||||
env: { deployment: {} },
|
||||
}),
|
||||
}))
|
||||
vi.mock("../apps/web/src/server/tenancy-calls", () => ({ mayCreateOrganization: () => false }))
|
||||
vi.mock("../apps/web/src/server/platform-theme", () => ({ organizationThemeStyle: async () => undefined }))
|
||||
vi.mock("@conusai-kit/host/http", () => ({ csrfSessionId: () => "session", csrfToken: () => "token" }))
|
||||
vi.mock("@app/modules/pages", () => ({
|
||||
modulePages: {},
|
||||
workspacePages: {
|
||||
reports: async () => ({ permission: "reports.report.read", load: mocks.load }),
|
||||
},
|
||||
}))
|
||||
|
||||
const { workspacePageData } = await import("../apps/web/src/server/page")
|
||||
|
||||
const person = { actor: { kind: "user" } }
|
||||
const signedIn = () =>
|
||||
mocks.requestContext.mockResolvedValue({
|
||||
caller: { call: mocks.call, callByName: mocks.callByName },
|
||||
container: {},
|
||||
principal: person,
|
||||
user: { email: "ada@example.test", displayName: "Ada", locale: "en" },
|
||||
})
|
||||
const request = (): Request =>
|
||||
new Request("http://app.test/a/b/modules/reports", { headers: { "x-path": "/a/b/modules/reports" } })
|
||||
const params = { org: "a", workspace: "b", module: "reports" }
|
||||
const refusal = (status: number): AppError =>
|
||||
new AppError({ type: "/problems/refused", code: "refused", title: "Refused", status })
|
||||
const thrown = (run: Promise<unknown>): Promise<unknown> =>
|
||||
run.then(
|
||||
() => "resolved",
|
||||
(error: unknown) => error,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.callByName.mockResolvedValue({ permissions: ["reports.report.read"], revision: "r1" })
|
||||
mocks.load.mockResolvedValue({ title: "Reports" })
|
||||
})
|
||||
|
||||
describe("a workspace page's loader", () => {
|
||||
it("sends a signed-out visitor to sign in, before reading anything", async () => {
|
||||
mocks.requestContext.mockResolvedValue({ caller: {}, container: {}, principal: null })
|
||||
expect(isRedirect(await thrown(workspacePageData(params, request())))).toBe(true)
|
||||
expect(mocks.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("hands `load` the workspace, the path below the module and the query string", async () => {
|
||||
signedIn()
|
||||
const data = await workspacePageData({ ...params, _splat: "a/b", search: "?q=one&tab=two&q=three" }, request())
|
||||
expect(mocks.load).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
slugs: { org: "a", workspace: "b" },
|
||||
segments: ["a", "b"],
|
||||
search: { q: "three", tab: "two" },
|
||||
}),
|
||||
)
|
||||
expect(data).toMatchObject({
|
||||
module: "reports",
|
||||
title: "Workspace overview · Example App",
|
||||
segments: ["a", "b"],
|
||||
locale: "en",
|
||||
data: { title: "Reports" },
|
||||
scope: { org: "a", workspace: "b" },
|
||||
seed: { permissions: ["reports.report.read"] },
|
||||
})
|
||||
})
|
||||
|
||||
it("answers 404 for a workspace of another organization, before `load`", async () => {
|
||||
signedIn()
|
||||
mocks.callByName.mockRejectedValue(refusal(404))
|
||||
expect(isNotFound(await thrown(workspacePageData(params, request())))).toBe(true)
|
||||
expect(mocks.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("answers 403 to a member without the page's permission, before `load`", async () => {
|
||||
signedIn()
|
||||
mocks.callByName.mockResolvedValue({ permissions: [], revision: "r1" })
|
||||
const error = (await thrown(workspacePageData(params, request()))) as { data?: unknown }
|
||||
expect(isNotFound(error)).toBe(true)
|
||||
expect(error.data).toEqual({ forbidden: true })
|
||||
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "403")
|
||||
expect(mocks.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
["no registered module", { ...params, module: "nothing" }],
|
||||
["a registered module with no workspace page", { ...params, module: "unpaged" }],
|
||||
["a name every object has", { ...params, module: "constructor" }],
|
||||
])("answers 404 for %s", async (_name, wanted) => {
|
||||
signedIn()
|
||||
expect(isNotFound(await thrown(workspacePageData(wanted, request())))).toBe(true)
|
||||
expect(mocks.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[404, undefined],
|
||||
[403, { forbidden: true }],
|
||||
])("turns the module's own %i into that page", async (status, expected) => {
|
||||
signedIn()
|
||||
mocks.load.mockRejectedValue(refusal(status))
|
||||
const error = (await thrown(workspacePageData(params, request()))) as { data?: unknown }
|
||||
expect(isNotFound(error)).toBe(true)
|
||||
expect(error.data).toEqual(expected)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user