This commit is contained in:
@@ -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,124 @@
|
||||
// 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/)
|
||||
})
|
||||
})
|
||||
|
||||
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,16 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { afterEach, expect, it, vi } from "vitest"
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it("keeps compiled chunk URLs stable when the platform injects a deployment asset prefix", async () => {
|
||||
// Compose serves the image's assets itself. Changing only the server's prefix at startup
|
||||
// leaves Turbopack waiting for chunk URLs that differ from those registered by the scripts.
|
||||
vi.stubEnv("CONUSAI_ASSET_PREFIX", "/_conusai/assets/app-42")
|
||||
vi.resetModules()
|
||||
const { default: config } = await import("../apps/web/next.config")
|
||||
expect(config.assetPrefix ?? "").toBe("")
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a failed sign-in becomes (spec §7): a redirect to the sign-in page with a code from a fixed
|
||||
// set, never the provider's text, and never to an address outside this app.
|
||||
|
||||
import { forbidden, unauthenticated } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
AUTH_ERROR_MESSAGES,
|
||||
AUTH_ERRORS,
|
||||
authErrorOf,
|
||||
parseAuthError,
|
||||
safeReturnTo,
|
||||
signInErrorHref,
|
||||
} from "../apps/web/src/server/auth-errors.js"
|
||||
import { appCatalogues } from "../apps/web/src/ui/messages.js"
|
||||
|
||||
const url = (query = ""): URL => new URL(`https://app.example.test/auth/callback${query}`)
|
||||
|
||||
describe("which code a failure gets", () => {
|
||||
it("is `denied` when the provider said no, whatever the provider's text", () => {
|
||||
expect(authErrorOf(new Error("anything"), url("?error=access_denied&error_description=secret+words"))).toBe(
|
||||
"denied",
|
||||
)
|
||||
})
|
||||
|
||||
it("is `noTransaction` for a callback no sign-in started in this browser", () => {
|
||||
const refusal = unauthenticated("no sign-in is in progress for this browser; start again at /auth/sign-in")
|
||||
expect(authErrorOf(refusal, url())).toBe("noTransaction")
|
||||
})
|
||||
|
||||
it("is `sessionExpired` for any other 401, `failed` for any other refusal of the app's own", () => {
|
||||
expect(authErrorOf(unauthenticated("the session ended"), url())).toBe("sessionExpired")
|
||||
expect(authErrorOf(forbidden("no"), url())).toBe("failed")
|
||||
})
|
||||
|
||||
it("is `failed` for a deployment that has no identity provider to complete a sign-in with", () => {
|
||||
expect(authErrorOf(unauthenticated("this deployment has no identity provider configured"), url())).toBe("failed")
|
||||
})
|
||||
|
||||
it("is `provider` for what is not the app's own error: the exchange with the provider failed", () => {
|
||||
expect(authErrorOf(new TypeError("fetch failed"), url())).toBe("provider")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the sign-in page's address", () => {
|
||||
it("carries the code and, when it is safe, where the person was going", () => {
|
||||
expect(signInErrorHref("denied")).toBe("/auth/sign-in?error=denied")
|
||||
expect(signInErrorHref("failed", "/invitations/acme/abc")).toBe(
|
||||
"/auth/sign-in?error=failed&return_to=%2Finvitations%2Facme%2Fabc",
|
||||
)
|
||||
})
|
||||
|
||||
it.each(["https://evil.example", "//evil.example", "/\\evil.example", "javascript:alert(1)", "", null, undefined])(
|
||||
"never returns to %j",
|
||||
(target) => {
|
||||
expect(safeReturnTo(target)).toBe("/")
|
||||
expect(signInErrorHref("failed", target)).toBe("/auth/sign-in?error=failed")
|
||||
},
|
||||
)
|
||||
|
||||
it("shows only the five words, not whatever the query holds", () => {
|
||||
expect(parseAuthError("denied")).toBe("denied")
|
||||
expect(parseAuthError("<script>alert(1)</script>")).toBeUndefined()
|
||||
expect(parseAuthError(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
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,95 @@
|
||||
// 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("../apps/web/src/server/csrf")
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,399 @@
|
||||
// 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",
|
||||
)
|
||||
expect(Object.keys(service.environment ?? {}).filter((name) => name.startsWith("NEXT_PUBLIC_"))).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(/NEXT_PUBLIC_[A-Z_]*BETTER_AUTH|NEXT_PUBLIC_[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,52 @@
|
||||
// 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[]
|
||||
expect(patterns.join("|")).toMatch(/kysely/)
|
||||
expect(patterns.join("|")).toMatch(/next/)
|
||||
expect(patterns.join("|")).toMatch(/@conusai-sdk/)
|
||||
})
|
||||
|
||||
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,101 @@
|
||||
// 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 } 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: string): 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.
|
||||
if (name === "node_modules" || name === ".next") return []
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) return walk(path, suffix)
|
||||
return path.endsWith(suffix) ? [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/app/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", () => {
|
||||
for (const file of [...walk(join(appDir, "apps/web/app"), ".tsx"), join(appDir, "apps/web/app/globals.css")]) {
|
||||
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.app?.memberCount).toContain("few {")
|
||||
expect(en.app?.memberCount).not.toContain("few {")
|
||||
})
|
||||
|
||||
it("hold the messages a page picks by a value rather than by name: roles, sign-in errors, invitation states", () => {
|
||||
const keys = [
|
||||
...["viewer", "member", "admin", "owner"].map((role) => `roles.${role}`),
|
||||
...["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.
|
||||
const used = new Set<string>()
|
||||
for (const file of walk(join(appDir, "apps/web"), ".tsx")) {
|
||||
for (const [, key] of readFileSync(file, "utf8").matchAll(/i18n\.message\("([^"]+)"/g)) used.add(key as string)
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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 { defineBrand } from "@app/config"
|
||||
import { createElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { brandMetadata, Document } from "../apps/web/src/ui/document"
|
||||
|
||||
const customer = defineBrand({
|
||||
name: "Acme Review",
|
||||
shortName: "Acme",
|
||||
primary: "#1a1a8c",
|
||||
logo: { light: "/acme-light.svg", dark: "/acme-dark.svg", favicon: "/acme.svg" },
|
||||
})
|
||||
|
||||
const render = (extra: { theme?: "dark" | "light"; observe?: { release: string; environment: string } } = {}): string =>
|
||||
renderToStaticMarkup(
|
||||
createElement(Document, {
|
||||
brand: customer,
|
||||
locale: "lt",
|
||||
theme: extra.theme ?? "dark",
|
||||
signedIn: false,
|
||||
signIn: "Prisijungti",
|
||||
signOut: null,
|
||||
controls: null,
|
||||
...(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 on the document element itself, so the first paint is already branded", () => {
|
||||
const root = /<html[^>]*>/.exec(html)?.[0] ?? ""
|
||||
expect(root).toContain('lang="lt"')
|
||||
expect(root).toContain("--brand-accent:#1a1a8c")
|
||||
expect(root).toMatch(/--brand-accent-ink:light-dark\(#[0-9a-f]{6}, ?#[0-9a-f]{6}\)/)
|
||||
})
|
||||
|
||||
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("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,70 @@
|
||||
// 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)
|
||||
expect(brandTokenStyle(brand)["--brand-accent"]).toBe("#1a1a8c")
|
||||
})
|
||||
|
||||
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,324 @@
|
||||
// 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["--brand-accent"]).toBe("#1a1a8c")
|
||||
expect(style["--brand-accent-foreground"]).toBe("#ffffff")
|
||||
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,59 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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_SCOPES",
|
||||
"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"))
|
||||
}
|
||||
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",
|
||||
])
|
||||
|
||||
describe("compose.yaml", () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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 [
|
||||
"OIDC_WEB_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE",
|
||||
"OIDC_INTROSPECTION_CLIENT_ID",
|
||||
"OIDC_INTROSPECTION_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE_STATUS_URL",
|
||||
"OIDC_SCOPES",
|
||||
"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))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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("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: pgvector/pgvector:pg18")
|
||||
expect(readFileSync(new URL("../conusai.json", import.meta.url), "utf8")).not.toContain("compose.dev.yaml")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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 { 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"
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// kit:guides:begin
|
||||
it("declares the code-guides bundle the image build writes, at an absolute path (ADR-045)", () => {
|
||||
expect(config.guides).toEqual({ bundle: "/app/.conusai/guides-bundle.json" })
|
||||
const dockerfile = readFileSync(new URL("../Dockerfile", import.meta.url), "utf8")
|
||||
// The build stage's WORKDIR is /app and the runtime stage copies /app whole, so the file the
|
||||
// build writes is where conusai.json says the platform will find it.
|
||||
expect(dockerfile).toContain(`node apps/worker/dist/guides-bundle.js ${config.guides?.bundle} guides-anchors.json`)
|
||||
})
|
||||
|
||||
// kit:guides:end
|
||||
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("requests the platform's canonical runtime scopes for document processing and usage metering", () => {
|
||||
const permissions = config.token?.permissions ?? []
|
||||
expect(permissions).toEqual(expect.arrayContaining(["ocr:use", "ai:execute", "usage:write"]))
|
||||
expect(permissions).not.toContain("billing:ingest")
|
||||
|
||||
const maria = JSON.parse(readFileSync(new URL("../profiles/mariadb/conusai.json", import.meta.url), "utf8")) as {
|
||||
token?: { permissions: string[] }
|
||||
}
|
||||
expect(maria.token?.permissions).toEqual(expect.arrayContaining(["ocr:use", "ai:execute", "usage:write"]))
|
||||
expect(maria.token?.permissions).not.toContain("billing:ingest")
|
||||
})
|
||||
|
||||
// The account page's platform analytics erasure and export (ADR-049) call the deployment-token
|
||||
// routes, which take `privacy:subjects` by name: `*` never carries it, so it is listed explicitly.
|
||||
it("requests privacy:subjects for the account page's analytics erasure and export", () => {
|
||||
expect(config.token?.permissions).toContain("privacy:subjects")
|
||||
expect(config.token?.permissions).not.toContain("*")
|
||||
})
|
||||
|
||||
// Every selectable profile's conusai.json replaces this one at the app root
|
||||
// (`kit template:prune --profile`), so each must request what the enabled modules need: the
|
||||
// Convex profile asked for none, so a Convex app's deployment token lacked guides:runtime and
|
||||
// support:widget-sessions and its guides and support calls were refused. `template:prune --without`
|
||||
// removes a module's permission from every copy, so the lists stay equal after a prune too.
|
||||
it.each(["convex", "mariadb"])("the %s profile requests the same runtime scopes as the default", (profile) => {
|
||||
const copy = JSON.parse(readFileSync(new URL(`../profiles/${profile}/conusai.json`, import.meta.url), "utf8")) as {
|
||||
token?: { permissions: string[] }
|
||||
}
|
||||
const moduleScopes: string[] = []
|
||||
moduleScopes.push("guides:runtime") // kit:guides
|
||||
moduleScopes.push("support:widget-sessions") // kit:support
|
||||
expect(config.token?.permissions).toEqual(expect.arrayContaining(moduleScopes))
|
||||
expect([...(copy.token?.permissions ?? [])].sort()).toEqual([...(config.token?.permissions ?? [])].sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,466 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { AppError, buildRegistry } from "@conusai-kit/core"
|
||||
import { generateConvex } from "@conusai-kit/data-convex"
|
||||
import { getFunctionName } from "convex/server"
|
||||
import { ConvexError } from "convex/values"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { parse } from "yaml"
|
||||
import { projectEntriesFor } from "../apps/web/src/ui/projects.js"
|
||||
import {
|
||||
CONVEX_ACTION_EFFECT_MODULES,
|
||||
CONVEX_EXCLUDED_MODULES,
|
||||
CONVEX_EXCLUDED_PROCEDURES,
|
||||
convexBffModules,
|
||||
convexModules,
|
||||
} from "../convex/lib/convexModules.mjs"
|
||||
import { appModules } from "../modules/index.js"
|
||||
|
||||
const root = new URL("..", import.meta.url)
|
||||
|
||||
describe("Convex deployable module closure", () => {
|
||||
it("commits generated wrappers that forward explicit idempotency keys to the kernel", () => {
|
||||
// The same profile filter as below: the Postgres-only modules are not Convex-deployed yet.
|
||||
const convexBound = appModules.filter(
|
||||
(module) => module.profiles === undefined || module.profiles.includes("convex"),
|
||||
)
|
||||
const registry = buildRegistry([...convexModules(convexBound)])
|
||||
const generated = generateConvex(registry, {
|
||||
header: "// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n",
|
||||
})
|
||||
for (const file of generated) {
|
||||
expect(readFileSync(new URL(`convex/${file.path}`, root), "utf8"), file.path).toBe(file.content)
|
||||
}
|
||||
const proposal = generated.find(({ path }) => path === "generated/proposals.ts")
|
||||
expect(proposal?.content).toContain(
|
||||
'ctx.run("proposals.proposal.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey })',
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps the documents review route visible in the Convex project navigation", () => {
|
||||
expect(projectEntriesFor("convex")).toContainEqual({
|
||||
href: "documents",
|
||||
label: "documents.title",
|
||||
module: "documents",
|
||||
})
|
||||
})
|
||||
|
||||
it("includes server-side auth handlers and only excludes modules with an explicit runtime gap", () => {
|
||||
// Modules that declare `profiles: ["postgres"]` (knowledge, agent) ship fail-closed Convex
|
||||
// binding files, but the integration review (2026-10-23) kept them Postgres-only: their
|
||||
// search/run effects and jobs leave the process and have no composed Convex Node actions yet
|
||||
// (the reasons are in their module READMEs). The generated closure is therefore the modules
|
||||
// that support the profile.
|
||||
const convexBound = appModules.filter(
|
||||
(module) => module.profiles === undefined || module.profiles.includes("convex"),
|
||||
)
|
||||
const modules = convexModules(convexBound)
|
||||
const closure = ["tenancy", "auth", "files", "proposals"]
|
||||
closure.push("guides") // kit:guides -- its effect procedures are generated as Convex actions
|
||||
closure.push("support") // kit:support -- its effect procedures are generated as Convex actions
|
||||
expect(modules.map(({ id }) => id)).toEqual([...closure, "branding", "documents", "notes", "account"])
|
||||
// The only effects generated as actions are those of the platform-call modules (convex/lib/effects.ts).
|
||||
const effects = modules.flatMap(({ id, procedures = [] }) =>
|
||||
procedures.filter(({ kind }) => kind === "effect").map(({ name }) => [id, name] as const),
|
||||
)
|
||||
expect(effects.every(([id]) => CONVEX_ACTION_EFFECT_MODULES.has(id))).toBe(true)
|
||||
const effectNames = effects.map(([, name]) => name)
|
||||
// The account module's export and deletion: actions over its bound store (convex/account.ts).
|
||||
const expectedEffects: string[] = ["account.data.export", "account.deletion.request"]
|
||||
// kit:guides:begin
|
||||
expectedEffects.push(
|
||||
"guides.builder.open",
|
||||
"guides.runtime.bootstrap",
|
||||
"guides.runtime.deleteFile",
|
||||
"guides.runtime.record",
|
||||
)
|
||||
// A survey file's upload is a streamed body: not a Convex action on this profile.
|
||||
expect(CONVEX_EXCLUDED_PROCEDURES.has("guides.runtime.uploadFile")).toBe(true)
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
expectedEffects.push("support.widgetSession.mint", "support.widget.config")
|
||||
// kit:support:end
|
||||
expect([...effectNames].sort()).toEqual(expectedEffects.sort())
|
||||
const fileProcedures = modules.find(({ id }) => id === "files")?.procedures?.map(({ name }) => name)
|
||||
expect(fileProcedures ?? []).toContain("files.file.get")
|
||||
expect(fileProcedures ?? []).toContain("files.file.list")
|
||||
expect(fileProcedures ?? []).not.toContain("files.file.delete")
|
||||
expect(CONVEX_EXCLUDED_PROCEDURES.has("files.file.delete")).toBe(true)
|
||||
expect([...CONVEX_EXCLUDED_MODULES.keys()]).toEqual([])
|
||||
const branding = modules.find(({ id }) => id === "branding")?.procedures?.map(({ name }) => name)
|
||||
expect(branding).toEqual(["branding.brand.get", "branding.brand.set"])
|
||||
for (const name of ["branding.logo.get", "branding.logo.set", "branding.logo.clear"]) {
|
||||
expect(CONVEX_EXCLUDED_PROCEDURES.has(name)).toBe(true)
|
||||
expect(
|
||||
convexBffModules(appModules)
|
||||
.flatMap(({ procedures = [] }) => procedures)
|
||||
.map(({ name }) => name),
|
||||
).not.toContain(name)
|
||||
}
|
||||
expect(CONVEX_EXCLUDED_MODULES.get("auth")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps composed file effects in the web BFF registry without generating Convex effect wrappers", () => {
|
||||
const filtered = appModules.filter((module) => module.profiles === undefined || module.profiles.includes("convex"))
|
||||
const backend = convexModules(filtered)
|
||||
const bff = convexBffModules(filtered)
|
||||
expect(
|
||||
backend.some(
|
||||
({ id, procedures = [] }) =>
|
||||
!CONVEX_ACTION_EFFECT_MODULES.has(id) && procedures.some(({ kind }) => kind === "effect"),
|
||||
),
|
||||
).toBe(false)
|
||||
const fileProcedures = bff.find(({ id }) => id === "files")?.procedures?.map(({ name }) => name)
|
||||
expect(fileProcedures ?? []).toContain("files.file.beginUpload")
|
||||
expect(fileProcedures ?? []).toContain("files.file.putPart")
|
||||
expect(fileProcedures ?? []).toContain("files.file.completeUpload")
|
||||
expect(fileProcedures ?? []).toContain("files.file.delete")
|
||||
expect(bff.find(({ id }) => id === "proposals")?.procedures?.map(({ name }) => name)).toContain(
|
||||
"proposals.proposal.approve",
|
||||
)
|
||||
expect(bff.find(({ id }) => id === "documents")?.procedures?.map(({ name }) => name)).toContain(
|
||||
"documents.document.get",
|
||||
)
|
||||
})
|
||||
|
||||
it("registers scheduled mutations through generated internal function references", () => {
|
||||
const crons = readFileSync(new URL("convex/crons.ts", root), "utf8")
|
||||
expect(crons).toContain("internal.relay.relayPass")
|
||||
expect(crons).toContain("internal.files.recoverRows")
|
||||
// The account module's deletion sweep, the Postgres worker's DELETION_SWEEP_JOB.
|
||||
expect(crons).toContain("internal.account.sweep")
|
||||
expect(crons).not.toContain('from "./relay"')
|
||||
})
|
||||
|
||||
it("keeps audit credentials behind a root internal action", () => {
|
||||
const config = readFileSync(new URL("convex/convex.config.ts", root), "utf8")
|
||||
const provider = readFileSync(new URL("convex/auditCredentials.ts", root), "utf8")
|
||||
const relay = readFileSync(new URL("convex/relay.ts", root), "utf8")
|
||||
expect(config).not.toContain("CONUSAI_API_TOKEN")
|
||||
expect(provider).toContain("export const get = internalAction(")
|
||||
expect(provider).toContain("apiUrl: process.env.CONUSAI_API_URL")
|
||||
expect(provider).toContain("apiToken: process.env.CONUSAI_API_TOKEN")
|
||||
expect(relay).toContain("credentialsProvider: internal.auditCredentials.get")
|
||||
})
|
||||
|
||||
it("deploys functions before web and backup with the provider's server-only credential names", () => {
|
||||
const compose = parse(readFileSync(new URL("profiles/convex/compose.yaml", root), "utf8")) as {
|
||||
services: Record<
|
||||
string,
|
||||
{
|
||||
command?: string[]
|
||||
environment?: Record<string, string>
|
||||
restart?: string
|
||||
depends_on?: Record<string, { condition: string }>
|
||||
}
|
||||
>
|
||||
}
|
||||
const profile = JSON.parse(readFileSync(new URL("profiles/convex/conusai.json", root), "utf8")) as {
|
||||
services: Record<string, unknown>
|
||||
}
|
||||
expect(Object.keys(compose.services).sort()).toEqual(["backup", "convex-deploy", "web"])
|
||||
expect(Object.keys(profile.services).sort()).toEqual(Object.keys(compose.services).sort())
|
||||
expect(compose.services["convex-deploy"]?.command).toEqual(["node", "profiles/convex/deploy.mjs"])
|
||||
expect(compose.services["convex-deploy"]?.restart).toBe("no")
|
||||
for (const name of ["web", "backup"] as const) {
|
||||
expect(compose.services[name]?.depends_on?.["convex-deploy"]).toEqual({
|
||||
condition: "service_completed_successfully",
|
||||
})
|
||||
}
|
||||
for (const name of ["web", "backup", "convex-deploy"] as const) {
|
||||
const environment = compose.services[name]?.environment ?? {}
|
||||
expect(environment.CONVEX_SELF_HOSTED_URL).toBe("$" + "{CONVEX_SELF_HOSTED_URL}")
|
||||
expect(environment.CONVEX_SELF_HOSTED_ADMIN_KEY).toBe("$" + "{CONVEX_SELF_HOSTED_ADMIN_KEY}")
|
||||
expect(Object.keys(environment).some((key) => key.startsWith("NEXT_PUBLIC_") && key.includes("ADMIN"))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(environment.CONVEX_URL).toBeUndefined()
|
||||
expect(environment.CONVEX_ADMIN_KEY).toBeUndefined()
|
||||
}
|
||||
expect(compose.services["convex-deploy"]?.environment).toMatchObject({
|
||||
CONUSAI_API_TOKEN: expect.stringMatching(/^\$\{CONUSAI_API_TOKEN\}$/),
|
||||
CONUSAI_PROJECT_ID: expect.stringMatching(/^\$\{CONUSAI_PROJECT_ID\}$/),
|
||||
CONUSAI_ORG_SLUG: expect.stringMatching(/^\$\{CONUSAI_ORG_SLUG\}$/),
|
||||
})
|
||||
expect(compose.services.web?.environment?.CONUSAI_ORG_SLUG).toBe("$" + "{CONUSAI_ORG_SLUG}")
|
||||
const deploy = readFileSync(new URL("profiles/convex/deploy.mjs", root), "utf8")
|
||||
expect(deploy).toContain('"env", "set", "--from-file", envFile')
|
||||
expect(deploy).toContain("mode: 0o600")
|
||||
expect(deploy).toContain('"deploy", "--typecheck", "disable"')
|
||||
const runtime = readFileSync(new URL("convex/lib/runtime.ts", root), "utf8")
|
||||
expect(runtime).toContain('"filesActions:recoverUpload"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("the account module on the Convex profile", () => {
|
||||
it("binds a store whose every method is one internal query (a read) or mutation (a change)", async () => {
|
||||
const { ACCOUNT_STORE_READS, ACCOUNT_STORE_WRITES, convexAccountStore } = await import("../convex/lib/effects")
|
||||
const { createAccountStore } = await import("../modules/account/index.js")
|
||||
const methods = Object.keys(createAccountStore({ data: {} as never, system: {} as never })).sort()
|
||||
expect([...ACCOUNT_STORE_READS, ...ACCOUNT_STORE_WRITES].sort()).toEqual(methods)
|
||||
const calls: Array<[string, string, unknown]> = []
|
||||
const record = (kind: string) => async (reference: unknown, args: unknown) => {
|
||||
calls.push([kind, getFunctionName(reference as never), args])
|
||||
return null
|
||||
}
|
||||
const store = convexAccountStore({
|
||||
auth: { getUserIdentity: async () => null },
|
||||
runQuery: record("query") as never,
|
||||
runMutation: record("mutation") as never,
|
||||
})
|
||||
await store.deletion("user-1")
|
||||
await store.identities("user-1")
|
||||
await store.changeDeletion("user-1", { kind: "step", step: "credentials" })
|
||||
expect(calls).toEqual([
|
||||
["query", "account:storeRead", { method: "deletion", args: ["user-1"] }],
|
||||
["query", "account:storeRead", { method: "identities", args: ["user-1"] }],
|
||||
[
|
||||
"mutation",
|
||||
"account:storeWrite",
|
||||
{ method: "changeDeletion", args: ["user-1", { kind: "step", step: "credentials" }] },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("brings a backend refusal back as the kernel's own problem", async () => {
|
||||
const { convexAccountStore } = await import("../convex/lib/effects")
|
||||
const store = convexAccountStore({
|
||||
auth: { getUserIdentity: async () => null },
|
||||
runQuery: (async () => {
|
||||
throw new ConvexError({ type: "/problems/not_found", title: "Not found", status: 404, code: "not_found" })
|
||||
}) as never,
|
||||
runMutation: (async () => null) as never,
|
||||
})
|
||||
const error = await store.deletion("user-1").then(
|
||||
() => null,
|
||||
(thrown: unknown) => thrown,
|
||||
)
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
expect((error as AppError).status).toBe(404)
|
||||
})
|
||||
|
||||
it("reads the account credential from the BFF's identity claims, and nothing from an absent one", async () => {
|
||||
const { accountCredentialOf } = await import("../convex/lib/runtime")
|
||||
const identity = (claims: Record<string, unknown>) =>
|
||||
({ issuer: "https://conusai.internal/bff", subject: "u", tokenIdentifier: "t", ...claims }) as never
|
||||
expect(accountCredentialOf(null)).toEqual({ interactive: false })
|
||||
expect(accountCredentialOf(identity({}))).toEqual({ interactive: false })
|
||||
expect(accountCredentialOf(identity({ conusaiCredential: "interactive", conusaiSessionId: "s-1" }))).toEqual({
|
||||
interactive: true,
|
||||
sessionId: "s-1",
|
||||
})
|
||||
expect(
|
||||
accountCredentialOf(
|
||||
identity({ conusaiCredential: "interactive", conusaiSignedInAt: "2026-10-25T00:00:00.000Z" }),
|
||||
),
|
||||
).toEqual({ interactive: true, signedInAt: "2026-10-25T00:00:00.000Z" })
|
||||
expect(accountCredentialOf(identity({ conusaiCredential: true, conusaiSessionId: "s-1" }))).toEqual({
|
||||
interactive: false,
|
||||
})
|
||||
})
|
||||
// kit:convex-better-auth:begin
|
||||
|
||||
it("installs the Better Auth deployment's variables in the backend only for a Better Auth app", () => {
|
||||
const deploy = readFileSync(new URL("profiles/convex/deploy.mjs", root), "utf8")
|
||||
expect(deploy).toContain(
|
||||
'variables.push("AUTH_PROVIDER", "AUTH_IDENTITY_NAMESPACE", "BETTER_AUTH_CONVEX_URL", "BETTER_AUTH_CONVEX_ADMIN_KEY")',
|
||||
)
|
||||
})
|
||||
// kit:convex-better-auth:end
|
||||
})
|
||||
|
||||
/**
|
||||
* The sign-out revocation outbox on the Convex profile (joint contract §14): the Postgres profile's
|
||||
* own outbox (modules/account/core/revocation.ts) on Convex storage. The web records a revoked app
|
||||
* session through the internal action `account:revokeSession`, which writes the row and delivers it
|
||||
* at once from the backend; what a platform did not confirm the one-minute cron `account:revocationSweep`
|
||||
* retries with the row's backoff, once per module, until 65 minutes have passed. Here the backend
|
||||
* functions are the very store the Convex functions run, over a memory data source in place of ctx.db.
|
||||
*/
|
||||
describe("the revocation outbox on the Convex profile", () => {
|
||||
it("keeps the outbox in the backend: every store method is one internal query or mutation", async () => {
|
||||
const { REVOCATION_STORE_READS, REVOCATION_STORE_WRITES, convexRevocationStore } = await import(
|
||||
"../convex/lib/effects"
|
||||
)
|
||||
const { createRevocationStore } = await import("../modules/account/index.js")
|
||||
expect([...REVOCATION_STORE_READS, ...REVOCATION_STORE_WRITES].sort()).toEqual(
|
||||
Object.keys(createRevocationStore({} as never)).sort(),
|
||||
)
|
||||
const calls: Array<[string, string, unknown]> = []
|
||||
const record = (kind: string) => async (reference: unknown, args: unknown) => {
|
||||
calls.push([kind, getFunctionName(reference as never), args])
|
||||
return kind === "query" ? [] : null
|
||||
}
|
||||
const store = convexRevocationStore({
|
||||
auth: { getUserIdentity: async () => null },
|
||||
runQuery: record("query") as never,
|
||||
runMutation: record("mutation") as never,
|
||||
})
|
||||
await store.due("2026-10-26T00:00:00.000Z", 10)
|
||||
await store.purge("2026-10-25T00:00:00.000Z")
|
||||
expect(calls).toEqual([
|
||||
["query", "account:revocationRead", { method: "due", args: ["2026-10-26T00:00:00.000Z", 10] }],
|
||||
["mutation", "account:revocationWrite", { method: "purge", args: ["2026-10-25T00:00:00.000Z"] }],
|
||||
])
|
||||
})
|
||||
|
||||
it("binds the device session a derived service session is minted for, from the BFF's identity", async () => {
|
||||
const { requestSessionOf } = await import("../convex/lib/runtime")
|
||||
const { effectContainer } = await import("../convex/lib/effects")
|
||||
const { REQUEST_SESSION } = await import("@conusai-kit/tenancy")
|
||||
const identity = (claims: Record<string, unknown>) =>
|
||||
({ issuer: "https://conusai.internal/bff", subject: "u", tokenIdentifier: "t", ...claims }) as never
|
||||
const fake = process.env.CONUSAI_FAKE_PLATFORM
|
||||
process.env.CONUSAI_FAKE_PLATFORM = "1"
|
||||
expect(requestSessionOf(null)).toBeNull()
|
||||
expect(requestSessionOf(identity({}))).toBeNull()
|
||||
expect(requestSessionOf(identity({ conusaiIdentitySession: 7 }))).toBeNull()
|
||||
expect(requestSessionOf(identity({ conusaiIdentitySession: "4:oidc:36:s-1" }))).toBe("4:oidc:36:s-1")
|
||||
const ctx = {
|
||||
auth: { getUserIdentity: async () => null },
|
||||
runQuery: (async () => null) as never,
|
||||
runMutation: (async () => null) as never,
|
||||
}
|
||||
expect(effectContainer(ctx, { interactive: true }, "4:oidc:36:s-1").resolve(REQUEST_SESSION)).toBe("4:oidc:36:s-1")
|
||||
expect(effectContainer(ctx).resolve(REQUEST_SESSION)).toBeNull()
|
||||
if (fake === undefined) delete process.env.CONUSAI_FAKE_PLATFORM
|
||||
else process.env.CONUSAI_FAKE_PLATFORM = fake
|
||||
// The BFF names the device session of every call it makes for a signed-in person.
|
||||
const caller = readFileSync(new URL("apps/web/src/server/convex-caller.ts", root), "utf8")
|
||||
expect(caller).toContain("conusaiIdentitySession: options.identitySession")
|
||||
const request = readFileSync(new URL("apps/web/src/server/request.ts", root), "utf8")
|
||||
expect(request).toContain("identitySession: sessionRefKey(session.sessionRef)")
|
||||
})
|
||||
|
||||
it("records a sign-out through an internal action and retries it on a one-minute cron", () => {
|
||||
const account = readFileSync(new URL("convex/account.ts", root), "utf8")
|
||||
for (const name of ["revocationRead = internalQuery", "revocationWrite = internalMutation"]) {
|
||||
expect(account).toContain(`export const ${name}`)
|
||||
}
|
||||
expect(account).toContain("export const revokeSession = internalAction")
|
||||
expect(account).toContain("export const revocationSweep = internalAction")
|
||||
const crons = readFileSync(new URL("convex/crons.ts", root), "utf8")
|
||||
expect(crons).toContain(
|
||||
'crons.interval("account session revocations", { minutes: 1 }, internal.account.revocationSweep, {})',
|
||||
)
|
||||
// The web asks the backend's outbox, and falls back to the modules only when nothing was recorded.
|
||||
const app = readFileSync(new URL("apps/web/src/server/app.ts", root), "utf8")
|
||||
expect(app).toContain("durable: (session: string) => revokeConvexSession(convex, session)")
|
||||
})
|
||||
// kit:support:begin
|
||||
|
||||
describe("delivered from an action's container, against the fake platform", () => {
|
||||
const START = Date.parse("2026-10-25T10:00:00.000Z")
|
||||
type Platform = Awaited<ReturnType<typeof import("@conusai-kit/testing/fake-platform").startFakePlatform>>
|
||||
type Clock = { at(ms: number): void }
|
||||
|
||||
/** One test against a fresh fake platform, the backend's deployment variables and a fixed clock. */
|
||||
async function withPlatform(run: (platform: Platform, clock: Clock) => Promise<void>): Promise<void> {
|
||||
const { vi } = await import("vitest")
|
||||
const { startFakePlatform } = await import("@conusai-kit/testing/fake-platform")
|
||||
const env = { ...process.env }
|
||||
const platform = await startFakePlatform()
|
||||
process.env.CONUSAI_API_URL = platform.url
|
||||
process.env.CONUSAI_API_TOKEN = "cnsi_test"
|
||||
process.env.CONUSAI_SUPPORT_CHANNEL = "web"
|
||||
delete process.env.CONUSAI_FAKE_PLATFORM
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
vi.setSystemTime(START)
|
||||
try {
|
||||
await run(platform, { at: (ms) => vi.setSystemTime(START + ms) })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
process.env = env
|
||||
await platform.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** The backend: `account:revocationRead`/`revocationWrite` over one store, as convex/account.ts runs them. */
|
||||
async function backend() {
|
||||
const { createMemoryDataSource } = await import("@conusai-kit/data")
|
||||
const { accountSessionRevocations, createRevocationStore } = await import("../modules/account/index.js")
|
||||
const data = createMemoryDataSource({ entities: [accountSessionRevocations] })
|
||||
const store = createRevocationStore(data)
|
||||
const run = (expected: string) => async (reference: unknown, input: unknown) => {
|
||||
const name = getFunctionName(reference as never)
|
||||
if (name !== expected) throw new Error(`unexpected backend call ${name}`)
|
||||
const { method, args } = input as { method: keyof typeof store; args: unknown[] }
|
||||
const call = store[method] as (...values: unknown[]) => Promise<unknown>
|
||||
return JSON.parse(JSON.stringify((await call.apply(store, args)) ?? null))
|
||||
}
|
||||
const ctx = {
|
||||
auth: { getUserIdentity: async () => null },
|
||||
runQuery: run("account:revocationRead") as never,
|
||||
runMutation: run("account:revocationWrite") as never,
|
||||
}
|
||||
const rows = async () => (await data.global(accountSessionRevocations).list({ limit: 50 })).items
|
||||
return { ctx, rows }
|
||||
}
|
||||
|
||||
const revocationsOf = async (platform: Platform, session: string) => {
|
||||
const response = await fetch(new URL("/__e2e/state", platform.url), {
|
||||
headers: { "x-conusai-organization": "e2e" },
|
||||
})
|
||||
const state = (await response.json()) as {
|
||||
support: Array<{ route: string; body?: { identity_session?: string } }>
|
||||
}
|
||||
return state.support.filter((call) => call.route === "revoke" && call.body?.identity_session === session)
|
||||
}
|
||||
|
||||
it("a platform down at sign-out gets the revocation once it is back, and nothing twice", () =>
|
||||
withPlatform(async (platform, clock) => {
|
||||
const { revokeSessionInBackend, sweepRevocationsInBackend } = await import("../convex/lib/effects")
|
||||
const { ctx, rows } = await backend()
|
||||
const session = "18:oidc:0199abcd-1234-7000-8000-00000000c001"
|
||||
platform.outage("/v1/support/", true)
|
||||
// The sign-out is not blocked: the action answers once the row is written.
|
||||
await expect(revokeSessionInBackend(ctx, session)).resolves.toBeUndefined()
|
||||
expect((await rows())[0]).toMatchObject({ session, state: "pending", pending: ["support"], attempts: 1 })
|
||||
expect(await revocationsOf(platform, session)).toEqual([])
|
||||
|
||||
// Due, still down: the cron's run fails (the line an operator is alerted on), the row waits longer.
|
||||
clock.at(31_000)
|
||||
await expect(sweepRevocationsInBackend(ctx)).rejects.toMatchObject({ code: "account_revocations_waiting" })
|
||||
expect((await rows())[0]).toMatchObject({ state: "pending", attempts: 2 })
|
||||
|
||||
platform.outage("/v1/support/", false)
|
||||
clock.at(31_000 + 61_000)
|
||||
await sweepRevocationsInBackend(ctx)
|
||||
expect((await rows())[0]).toMatchObject({ state: "delivered", pending: [], delivered: ["support"] })
|
||||
expect(await revocationsOf(platform, session)).toHaveLength(1)
|
||||
clock.at(20 * 60_000)
|
||||
await sweepRevocationsInBackend(ctx)
|
||||
expect(await revocationsOf(platform, session)).toHaveLength(1)
|
||||
// A second notification of the same revocation writes nothing and asks nobody.
|
||||
await revokeSessionInBackend(ctx, session)
|
||||
expect(await rows()).toHaveLength(1)
|
||||
expect(await revocationsOf(platform, session)).toHaveLength(1)
|
||||
}))
|
||||
|
||||
it("delivers at once when the platform is up", () =>
|
||||
withPlatform(async (platform) => {
|
||||
const { revokeSessionInBackend } = await import("../convex/lib/effects")
|
||||
const { ctx, rows } = await backend()
|
||||
const session = "18:oidc:0199abcd-1234-7000-8000-00000000c002"
|
||||
await revokeSessionInBackend(ctx, session)
|
||||
expect((await rows())[0]).toMatchObject({ state: "delivered", pending: [] })
|
||||
expect(await revocationsOf(platform, session)).toHaveLength(1)
|
||||
}))
|
||||
|
||||
it("lapses after 65 minutes, when no widget session it minted can still be alive", () =>
|
||||
withPlatform(async (platform, clock) => {
|
||||
const { revokeSessionInBackend, sweepRevocationsInBackend } = await import("../convex/lib/effects")
|
||||
const { ctx, rows } = await backend()
|
||||
const session = "18:oidc:0199abcd-1234-7000-8000-00000000c003"
|
||||
platform.outage("/v1/support/", true)
|
||||
await revokeSessionInBackend(ctx, session)
|
||||
platform.outage("/v1/support/", false)
|
||||
clock.at(66 * 60_000)
|
||||
await sweepRevocationsInBackend(ctx)
|
||||
expect((await rows())[0]).toMatchObject({ state: "lapsed" })
|
||||
expect(await revocationsOf(platform, session)).toEqual([])
|
||||
}))
|
||||
})
|
||||
// kit:support:end
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The CSRF wall (spec §6.4): a state-changing call made with the session cookie carries the
|
||||
// page's token, and a bearer or API-key caller is not asked for one, because no browser attaches
|
||||
// those by itself.
|
||||
|
||||
import type { Principal } from "@conusai-kit/auth"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
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
|
||||
|
||||
beforeAll(async () => {
|
||||
// loadAuthConfig reads the environment at call time, so the secret is set before the import
|
||||
// that uses it.
|
||||
process.env.AUTH_SESSION_SECRET = "0123456789abcdef0123456789abcdef"
|
||||
process.env.CONUSAI_PUBLIC_URL = "https://app.example.test"
|
||||
process.env.OIDC_ISSUER = "https://id.example.test"
|
||||
process.env.OIDC_WEB_CLIENT_ID = "web"
|
||||
process.env.OIDC_AUDIENCE = "api"
|
||||
const module = await import("../apps/web/src/server/csrf")
|
||||
csrfToken = module.csrfToken
|
||||
requireCsrf = module.requireCsrf
|
||||
})
|
||||
|
||||
const post = (headers: Record<string, string>): Request =>
|
||||
new Request("https://app.example.test/api/v1/tenancy.org.create", { method: "POST", headers })
|
||||
|
||||
describe("requireCsrf", () => {
|
||||
it("accepts a POST carrying this session's token", () => {
|
||||
const principal = session("session-1")
|
||||
expect(() =>
|
||||
requireCsrf(post({ "x-csrf-token": csrfToken("session-1"), "sec-fetch-site": "same-origin" }), principal),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it("refuses a POST with no token", () => {
|
||||
expect(() => requireCsrf(post({}), session("session-1"))).toThrow(AppError)
|
||||
})
|
||||
|
||||
it("refuses another session's token", () => {
|
||||
expect(() => requireCsrf(post({ "x-csrf-token": csrfToken("session-2") }), session("session-1"))).toThrow(
|
||||
/x-csrf-token/,
|
||||
)
|
||||
})
|
||||
|
||||
it("refuses a cross-site POST even with a token", () => {
|
||||
const headers = { "x-csrf-token": csrfToken("session-1"), "sec-fetch-site": "cross-site" }
|
||||
expect(() => requireCsrf(post(headers), session("session-1"))).toThrow(/cross-site/)
|
||||
})
|
||||
|
||||
it("asks nothing of a bearer or API-key caller", () => {
|
||||
const bearer: Principal = { actor: { userId: "u" }, method: "bearer" }
|
||||
const key: Principal = { actor: { userId: "u" }, method: "api-key", orgId: "o" }
|
||||
expect(() => requireCsrf(post({}), bearer)).not.toThrow()
|
||||
expect(() => requireCsrf(post({}), key)).not.toThrow()
|
||||
})
|
||||
|
||||
it("asks every browser session method for its token, including dev-login and an external provider session", () => {
|
||||
const dev: Principal = { actor: { userId: "u" }, method: "dev-login", sessionId: "session-1" }
|
||||
const external: Principal = {
|
||||
actor: { userId: "u" },
|
||||
method: "external-session",
|
||||
sessionRef: { provider: "convex-better-auth", opaqueId: "session-1" },
|
||||
}
|
||||
expect(() => requireCsrf(post({}), dev)).toThrow(/x-csrf-token/)
|
||||
expect(() => requireCsrf(post({}), external)).toThrow(/x-csrf-token/)
|
||||
// An external session's token is bound to its provider reference, never to a kit row of the same ID.
|
||||
expect(() => requireCsrf(post({ "x-csrf-token": csrfToken("session-1") }), external)).toThrow(/x-csrf-token/)
|
||||
})
|
||||
|
||||
it("fails closed on an external session disguised with a kit session row", () => {
|
||||
const disguised: Principal = {
|
||||
actor: { userId: "u" },
|
||||
method: "external-session",
|
||||
sessionId: "session-1",
|
||||
sessionRef: { provider: "convex-better-auth", opaqueId: "session-1" },
|
||||
}
|
||||
expect(() => requireCsrf(post({ "x-csrf-token": csrfToken("session-1") }), disguised)).toThrow(AppError)
|
||||
})
|
||||
|
||||
it("asks nothing of a GET", () => {
|
||||
const request = new Request("https://app.example.test/api/health", { headers: {} })
|
||||
expect(() => requireCsrf(request, session("session-1"))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
// 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 } 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 { appModules } from "../modules/index.js"
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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[]
|
||||
}
|
||||
|
||||
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("runs as a user that owns none of the files it runs", () => {
|
||||
expect(dockerfile).toMatch(/^USER node$/m)
|
||||
expect(dockerfile).toContain("--chown=root:root")
|
||||
})
|
||||
|
||||
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("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.toContain("next")
|
||||
})
|
||||
})
|
||||
@@ -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("a file with the same bytes is neither read nor asked about again", 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(1)
|
||||
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,415 @@
|
||||
// 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 { 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: {
|
||||
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()
|
||||
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)
|
||||
})
|
||||
|
||||
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({ fileId: file.fileId, status: "queued" })])
|
||||
expect(app.ocr.submitted).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
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).toContain("fixture-engine")
|
||||
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",
|
||||
})
|
||||
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,
|
||||
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,
|
||||
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 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, 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, 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,
|
||||
})
|
||||
@@ -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", ".next", "dist", ".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 file of ["ai.ts", "documents.ts"]) {
|
||||
for (const match of readFileSync(join(root, "config/src", file), "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 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_ORG_ID",
|
||||
"CONUSAI_ORG_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([
|
||||
"NEXT_RUNTIME",
|
||||
"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,73 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The security headers of spec §6.4, and the two things a page may not do: load a script inline
|
||||
// or reach an origin that is not this app's.
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { contentSecurityPolicy, securityHeaders } from "../apps/web/src/server/headers"
|
||||
|
||||
const policy = contentSecurityPolicy({ nonce: "test-nonce" })
|
||||
|
||||
describe("the content security policy", () => {
|
||||
it("allows a script only with this request's nonce", () => {
|
||||
expect(policy).toContain("script-src 'self' 'nonce-test-nonce' 'strict-dynamic'")
|
||||
expect(policy).not.toContain("script-src 'self' 'unsafe-inline'")
|
||||
expect(policy).not.toContain("unsafe-eval")
|
||||
})
|
||||
|
||||
it("refuses framing, a base tag and a plugin outright", () => {
|
||||
expect(policy).toContain("frame-ancestors 'none'")
|
||||
expect(policy).toContain("base-uri 'none'")
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
})
|
||||
|
||||
it("names no origin but this app's", () => {
|
||||
const sources = policy
|
||||
.split("; ")
|
||||
.flatMap((directive) => directive.split(" ").slice(1))
|
||||
.filter((source) => source.includes("."))
|
||||
expect(sources).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps fonts and connections on this origin, so no CDN or font host is reachable", () => {
|
||||
expect(policy).toContain("font-src 'self'")
|
||||
expect(policy).toContain("connect-src 'self'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the content security policy of a development run", () => {
|
||||
it("allows eval for React's development build, and only there", () => {
|
||||
expect(contentSecurityPolicy({ nonce: "n", development: true })).toContain("'strict-dynamic' 'unsafe-eval'")
|
||||
expect(contentSecurityPolicy({ nonce: "n" })).not.toContain("unsafe-eval")
|
||||
expect(contentSecurityPolicy({ nonce: "n", development: false })).not.toContain("unsafe-eval")
|
||||
expect(securityHeaders({ nonce: "n", secure: false, development: true })["content-security-policy"]).toContain(
|
||||
"unsafe-eval",
|
||||
)
|
||||
expect(securityHeaders({ nonce: "n", secure: true })["content-security-policy"]).not.toContain("unsafe-eval")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the response headers", () => {
|
||||
it("pin the transport only where there is one to pin", () => {
|
||||
expect(securityHeaders({ nonce: "n", secure: true })["strict-transport-security"]).toContain("max-age=63072000")
|
||||
expect(securityHeaders({ nonce: "n", secure: false })["strict-transport-security"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("carry the rest of the baseline", () => {
|
||||
const headers = securityHeaders({ nonce: "n", secure: true })
|
||||
expect(headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin")
|
||||
expect(headers["cross-origin-opener-policy"]).toBe("same-origin")
|
||||
expect(headers["permissions-policy"]).toContain("camera=()")
|
||||
})
|
||||
})
|
||||
|
||||
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,66 @@
|
||||
// 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 resolution through one internal mutation", () => {
|
||||
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).not.toContain("export const userInsert")
|
||||
expect(system).not.toContain("export const userByIdentity")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
// 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 { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { type Actor, buildRegistry, 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
|
||||
// kit:support:begin
|
||||
import {
|
||||
recordingSupportPlatform,
|
||||
supportIsolationExempt,
|
||||
supportIsolationFixtures,
|
||||
} from "@conusai-kit/support/testing"
|
||||
// kit:support:end
|
||||
import { createTenancyHooks, TENANCY, users } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
describeIsolation,
|
||||
fakeJobQueue,
|
||||
fakeObjectStore,
|
||||
type IsolationFixture,
|
||||
type IsolationTenant,
|
||||
runIsolation,
|
||||
snapshotOf,
|
||||
uploadViaProcedures,
|
||||
} from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { accountIsolationExempt } from "../modules/account/__tests__/exempt.js"
|
||||
import { appModules } from "../modules/index.js"
|
||||
import { KNOWLEDGE_ENABLEMENT, KNOWLEDGE_SEARCH } from "../modules/knowledge/index.js"
|
||||
import type { ModuleIsolation } from "../modules/notes/__tests__/isolation.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
|
||||
/**
|
||||
* 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. (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(
|
||||
readdirSync(modulesDir)
|
||||
.filter((id) => existsSync(join(modulesDir, id, "__tests__", "isolation.ts")))
|
||||
.map(async (id) => {
|
||||
const loaded = (await import(/* @vite-ignore */ join(modulesDir, id, "__tests__", "isolation.ts"))) 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 = 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
|
||||
// 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_SEARCH, {
|
||||
readiness: async () => ({ state: "ready" as const, reasons: [] }),
|
||||
index: async () => undefined,
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
})
|
||||
root.value(KNOWLEDGE_ENABLEMENT, {
|
||||
checkEligibility: async () => ({ ok: true as const }),
|
||||
indexingQuota: async () => Number.MAX_SAFE_INTEGER,
|
||||
linkManagedQdrant: async () => ({ serviceId: "fake", url: null }),
|
||||
probeQdrant: async () => ({ ok: true as const }),
|
||||
operatorQdrant: () => null,
|
||||
})
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy }, { 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 } }
|
||||
// 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"),
|
||||
})
|
||||
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 }
|
||||
// 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,
|
||||
apiKeyId: key.key.id,
|
||||
fileId: file.fileId,
|
||||
proposalId: proposal.proposalId,
|
||||
...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
|
||||
|
||||
/** One fixture per tenant-scoped procedure, built from the *other* tenant's ids. */
|
||||
const fixtures: Record<string, IsolationFixture> = {
|
||||
...Object.assign({}, ...moduleIsolations.map((isolation) => isolation.fixtures)),
|
||||
...guidesIsolationFixtures, // kit:guides
|
||||
...supportIsolationFixtures, // kit:support
|
||||
"tenancy.org.get": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"tenancy.org.update": {
|
||||
input: (t) => ({ org: t.orgSlug, name: "Updated Name", expectedVersion: 1 }),
|
||||
},
|
||||
"tenancy.invitation.list": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"tenancy.member.list": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"tenancy.member.invite": { input: (t) => ({ org: t.orgSlug, email: "intruder@example.test", role: "member" }) },
|
||||
"tenancy.member.change-role": { input: (t) => ({ org: t.orgSlug, userId: t.userId, role: "viewer" }) },
|
||||
"tenancy.member.remove": { input: (t) => ({ org: t.orgSlug, userId: t.userId }) },
|
||||
"tenancy.member.leave": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"tenancy.workspace.list": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"tenancy.workspace.create": {
|
||||
input: (t) => ({ org: t.orgSlug, slug: "intruder", name: "Intruder", visibility: "org" }),
|
||||
},
|
||||
"tenancy.workspace.set-member": {
|
||||
input: (t) => ({ org: t.orgSlug, workspace: t.workspaceSlug, userId: t.userId, role: "admin" }),
|
||||
},
|
||||
"tenancy.project.list": { input: (t) => ({ org: t.orgSlug, workspace: t.workspaceSlug }) },
|
||||
"tenancy.project.get": { input: (t) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug }) },
|
||||
"tenancy.project.create": {
|
||||
input: (t) => ({
|
||||
org: t.orgSlug,
|
||||
workspace: t.workspaceSlug,
|
||||
slug: "intruder",
|
||||
name: "Intruder",
|
||||
moduleKey: "tenancy",
|
||||
}),
|
||||
},
|
||||
"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),
|
||||
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" }),
|
||||
},
|
||||
"documents.document.list": { input: (t) => inProjectOf(t) },
|
||||
"documents.document.get": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
|
||||
"documents.line.edit": {
|
||||
input: (t) => ({ ...inProjectOf(t), lineId: proposalOf(t), label: "Intruder", value: "x" }),
|
||||
},
|
||||
"documents.export.create": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
|
||||
"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" }) },
|
||||
// User-scoped, so the kernel has no scope to resolve -- and it still names an organization,
|
||||
// which is exactly where a hole hides: `alsoCheck` puts it through the same test with the
|
||||
// other tenant's own open token.
|
||||
"tenancy.invitation.accept": { input: (t) => ({ org: t.orgSlug, token: t.extra.token ?? "z".repeat(43) }) },
|
||||
}
|
||||
|
||||
const alsoCheck = ["tenancy.invitation.accept"]
|
||||
|
||||
const exempt = {
|
||||
"tenancy.me.get": "it takes no organization and answers only about the signed-in user",
|
||||
"tenancy.me.update": "it takes no organization and no id: the only row it can write is the signed-in user's own",
|
||||
"tenancy.org.create": "a unique slug necessarily answers 409 for a taken one; it writes in no tenant",
|
||||
"auth.session.current": "it answers only about the signed-in user's own row",
|
||||
...accountIsolationExempt,
|
||||
...guidesIsolationExempt, // kit:guides
|
||||
...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("covers every procedure this app registers", async () => {
|
||||
const result = await runIsolation(await options())
|
||||
expect(result.failures).toEqual([])
|
||||
expect(result.passed.length).toBe(registry.isolationFixtures.length + alsoCheck.length)
|
||||
})
|
||||
|
||||
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(14)
|
||||
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: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"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 { 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 { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
||||
import { appUserIdentities, tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { kernelSchema } from "../apps/worker/src/migrations.js"
|
||||
import { SPDX_LINE } from "../scripts/licence.mjs"
|
||||
|
||||
// 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, not this historical snapshot.
|
||||
const kernelEntities = [
|
||||
...entitiesOf(buildRegistry([tenancyModule, authModule]), "postgres"),
|
||||
...KIT_JOBS_ENTITIES,
|
||||
].filter((entity) => entity.table !== appUserIdentities.table)
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Where the knowledge module's vectors backend keeps its vectors (decision 2026-10-25): a linked
|
||||
// Qdrant, or pgvector when chosen explicitly, and otherwise no store at all. pgvector is never the
|
||||
// silent default: the platform's default managed Postgres image has no pgvector, and the migrate
|
||||
// job of a fresh app must not stop on `extension "vector" is not available` before anyone asked for
|
||||
// document AI. The migrate job and the readiness probe read the same decision.
|
||||
|
||||
import {
|
||||
KNOWLEDGE_VECTOR_STORE_VAR,
|
||||
NO_VECTOR_STORE_REASON,
|
||||
vectorStoreMigrationName,
|
||||
vectorStoreOf,
|
||||
} from "@app/config"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { vectorStoreMigration } from "../apps/worker/src/migrations.js"
|
||||
|
||||
const QDRANT = { QDRANT_URL: "http://qdrant.internal:6333" }
|
||||
|
||||
describe("the knowledge vector store", () => {
|
||||
it("is none by default, so a fresh app migrates on a Postgres without pgvector", () => {
|
||||
expect(vectorStoreOf({})).toBeNull()
|
||||
expect(vectorStoreMigrationName({})).toBeNull()
|
||||
expect(vectorStoreMigration({})).toBeNull()
|
||||
expect(NO_VECTOR_STORE_REASON).toContain(`${KNOWLEDGE_VECTOR_STORE_VAR}=pgvector`)
|
||||
})
|
||||
|
||||
it("is the linked Qdrant when one is linked, with no vector table", () => {
|
||||
expect(vectorStoreOf(QDRANT)).toBe("qdrant")
|
||||
expect(vectorStoreMigrationName(QDRANT)).toBeNull()
|
||||
expect(vectorStoreOf({ ...QDRANT, KNOWLEDGE_VECTOR_STORE: "qdrant" })).toBe("qdrant")
|
||||
// Qdrant required but not linked: no store, never a fallback to pgvector.
|
||||
expect(vectorStoreOf({ KNOWLEDGE_VECTOR_STORE: "qdrant" })).toBeNull()
|
||||
expect(vectorStoreMigrationName({ KNOWLEDGE_VECTOR_STORE: "qdrant" })).toBeNull()
|
||||
})
|
||||
|
||||
it("is pgvector only when chosen, and then the migrate job builds the table", () => {
|
||||
const chosen = { KNOWLEDGE_VECTOR_STORE: " PgVector " }
|
||||
expect(vectorStoreOf(chosen)).toBe("pgvector")
|
||||
expect(vectorStoreMigrationName(chosen)).toBe("0006_vectors")
|
||||
const migration = vectorStoreMigration({ ...chosen, VECTOR_DIMENSIONS: "8" })
|
||||
expect(migration?.name).toBe("0006_vectors")
|
||||
expect(migration?.statements[0]).toBe("create extension if not exists vector")
|
||||
// An explicit choice wins over a linked Qdrant.
|
||||
expect(vectorStoreOf({ ...QDRANT, KNOWLEDGE_VECTOR_STORE: "pgvector" })).toBe("pgvector")
|
||||
})
|
||||
|
||||
it("has no store on the platform backend and refuses an unknown choice", () => {
|
||||
expect(vectorStoreOf({ KNOWLEDGE_BACKEND: "platform", KNOWLEDGE_VECTOR_STORE: "pgvector" })).toBeNull()
|
||||
expect(vectorStoreMigrationName({ KNOWLEDGE_BACKEND: "platform", ...QDRANT })).toBeNull()
|
||||
expect(() => vectorStoreOf({ KNOWLEDGE_VECTOR_STORE: "sqlite" })).toThrow(KNOWLEDGE_VECTOR_STORE_VAR)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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("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/app/layout.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,149 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Drive the same SDK handler with the official SDK client; authorization remains in the app caller.
|
||||
|
||||
import { buildRegistry, forbidden } from "@conusai-kit/core"
|
||||
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { createMcpHandler } from "../apps/web/src/server/mcp.js"
|
||||
import { appModules } from "../modules/index.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
const principal = { method: "bearer", actor: { userId: "user-a" } } as never
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
authenticated?: boolean
|
||||
call?: (name: string, args: unknown) => Promise<unknown>
|
||||
csrf?: () => void
|
||||
} = {},
|
||||
) {
|
||||
const callByName = vi.fn(options.call ?? (async () => ({ notes: [], hasMore: false })))
|
||||
const checkCsrf = vi.fn(options.csrf ?? (() => undefined))
|
||||
const handle = createMcpHandler({
|
||||
registry,
|
||||
contextFor: async () => ({
|
||||
principal: options.authenticated === false ? null : principal,
|
||||
caller: { callByName } as never,
|
||||
}),
|
||||
checkCsrf,
|
||||
})
|
||||
return { handle, callByName, checkCsrf }
|
||||
}
|
||||
|
||||
function rawRequest(body: unknown, extra: Record<string, string> = {}) {
|
||||
return new Request("https://app.example.test/api/mcp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...extra },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function connect(handle: ReturnType<typeof createMcpHandler>, requestId = "sdk-request") {
|
||||
const transport = new StreamableHTTPClientTransport(new URL("https://app.example.test/api/mcp"), {
|
||||
fetch: (url, init) => handle(new Request(url, init), requestId),
|
||||
})
|
||||
const client = new Client({ name: "app-template-test", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } })
|
||||
return { client, transport }
|
||||
}
|
||||
|
||||
describe("the stateless MCP endpoint", () => {
|
||||
it("negotiates with the official client, lists opted-in tools, and calls through the scoped caller", async () => {
|
||||
const output = { notes: [], hasMore: false }
|
||||
const { handle, callByName, checkCsrf } = harness({ call: async () => output })
|
||||
const { client, transport } = connect(handle)
|
||||
try {
|
||||
await client.connect(transport)
|
||||
expect(client.getProtocolEra()).toBe("modern")
|
||||
|
||||
const listed = await client.listTools()
|
||||
expect(listed.tools.map((tool) => tool.name)).toEqual(["agent.run.status", "knowledge.search", "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(callByName).toHaveBeenCalledWith("notes.note.list", {
|
||||
org: "org-a",
|
||||
workspace: "ws-a",
|
||||
project: "project-a",
|
||||
})
|
||||
expect(checkCsrf.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.isError).not.toBe(true)
|
||||
expect(result.structuredContent).toEqual(output)
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses unauthenticated requests before SDK protocol handling", async () => {
|
||||
const { handle, callByName } = harness({ authenticated: false })
|
||||
const response = await handle(rawRequest({ jsonrpc: "2.0", id: 4, method: "initialize" }), "request-4")
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("www-authenticate")).toBe("Bearer")
|
||||
expect(callByName).not.toHaveBeenCalled()
|
||||
expect(JSON.stringify(await response.json())).not.toContain("user-a")
|
||||
})
|
||||
|
||||
it("applies the same session CSRF refusal and does not disclose its detail", async () => {
|
||||
const { handle } = harness({
|
||||
csrf: () => {
|
||||
throw forbidden("sensitive csrf detail")
|
||||
},
|
||||
})
|
||||
const response = await handle(rawRequest({ jsonrpc: "2.0", id: 5, method: "initialize" }), "request-5")
|
||||
expect(response.status).toBe(403)
|
||||
expect(JSON.stringify(await response.json())).not.toContain("sensitive csrf detail")
|
||||
})
|
||||
|
||||
it("returns a safe tool error when kernel authorization refuses the caller", async () => {
|
||||
const call = vi.fn(async (_name: string, args: unknown) => {
|
||||
if (typeof args === "object" && args !== null && "org" in args && args.org === "other-org") {
|
||||
throw forbidden("tenant-private note data")
|
||||
}
|
||||
return { notes: [], hasMore: false }
|
||||
})
|
||||
const { handle } = harness({
|
||||
call,
|
||||
})
|
||||
const { client, transport } = connect(handle, "request-tool-refusal")
|
||||
try {
|
||||
await client.connect(transport)
|
||||
const result = await client.callTool({
|
||||
name: "notes.note.list",
|
||||
arguments: { org: "other-org", workspace: "other-ws", project: "other-project" },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "The tool request was refused." })
|
||||
expect(JSON.stringify(result)).not.toContain("tenant-private note data")
|
||||
expect(call).toHaveBeenCalledWith("notes.note.list", {
|
||||
org: "other-org",
|
||||
workspace: "other-ws",
|
||||
project: "other-project",
|
||||
})
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses non-tool names and non-POST methods", async () => {
|
||||
const { handle, callByName } = harness()
|
||||
const { client, transport } = connect(handle, "request-7")
|
||||
try {
|
||||
await client.connect(transport)
|
||||
await expect(client.callTool({ name: "notes.note.create", arguments: {} })).rejects.toMatchObject({
|
||||
code: -32602,
|
||||
})
|
||||
expect(callByName).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
|
||||
const get = await handle(new Request("https://app.example.test/api/mcp"), "request-8")
|
||||
expect(get.status).toBe(405)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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 { 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 global reader's grants", () => {
|
||||
const migration = readerGrants("postgresql://app_reader:secret@db:5432/app")
|
||||
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",
|
||||
])
|
||||
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("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").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,144 @@
|
||||
// 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 { DOCUMENTS_ENTITIES, documentsModule } from "@app/modules/documents"
|
||||
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 { 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)
|
||||
}
|
||||
|
||||
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 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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's validator is the current one without skipped_pages, and 0002 carries the current one.
|
||||
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 withoutSkippedPages = structuredClone(createDocument)
|
||||
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: createDocument.validator }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,341 @@
|
||||
// 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 { 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()
|
||||
container.value(FILES_SCOPE, { level: "org", actor: systemActor("app.relay"), orgId: record?.orgId ?? "" })
|
||||
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("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)
|
||||
})
|
||||
|
||||
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,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,69 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The browser half of observability (plan item AT4.2): `instrumentation-client.ts` 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 the SDK with the release and environment the server wrote, anonymously, errors and traces only", () => {
|
||||
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: "anonymous" }),
|
||||
)
|
||||
const options = start.mock.calls[0]?.[0]
|
||||
expect(options?.modules).toEqual({ traces: true })
|
||||
expect(options?.modules).not.toHaveProperty("analytics")
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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 { describe, expect, it } from "vitest"
|
||||
import { openapiDocument, openapiJson } from "../apps/web/src/server/openapi"
|
||||
import { appModules } from "../modules/index.js"
|
||||
|
||||
const registry = buildRegistry([...appModules])
|
||||
// The title is the app brand's name, as the route 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"]
|
||||
expected.push("guides.runtime.uploadFile") // kit:guides -- a survey file question's file
|
||||
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],
|
||||
])
|
||||
expect(path?.get?.responses["200"]?.content).toHaveProperty(
|
||||
["application/octet-stream", "schema", "format"],
|
||||
"binary",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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 { LoadingView, 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('<a href="/">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(LoadingView, { label: "Loading" }))
|
||||
expect(html).toContain('role="status"')
|
||||
expect(html).toContain('<span class="app-spinner" aria-hidden="true"></span>')
|
||||
expect(html).toContain("<span>Loading</span>")
|
||||
expect(html).not.toContain("<h1")
|
||||
})
|
||||
|
||||
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).toBe(
|
||||
'<main class="app-state"><h1>Page not found</h1><p>Nothing here.</p><p><a href="/">Back</a></p></main>',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,339 @@
|
||||
// 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"
|
||||
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"
|
||||
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 } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { bindWebPorts } from "../apps/web/src/server/ports.js"
|
||||
import { bindModulePorts } from "../apps/worker/src/ports.js"
|
||||
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL("../modules/documents/__tests__/fixtures/ocr-document.json", import.meta.url), "utf8"),
|
||||
) as Record<string, unknown>
|
||||
|
||||
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).toEqual({ answer: "four" })
|
||||
const [call] = (await stateOf()).ai
|
||||
expect(call?.request?.model).toBe(clients.model)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
DOCUMENT_MODEL,
|
||||
DOCUMENT_OCR,
|
||||
FILES_REGISTRY,
|
||||
PROPOSALS_REGISTRY,
|
||||
]),
|
||||
).toEqual([true, true, true, true, true, true])
|
||||
// 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,
|
||||
DOCUMENT_MODEL,
|
||||
DOCUMENT_OCR,
|
||||
FILES_REGISTRY,
|
||||
PROPOSALS_REGISTRY,
|
||||
FILES_AUDIT,
|
||||
FILES_USAGE,
|
||||
PROPOSALS_AUDIT,
|
||||
]),
|
||||
).toEqual([true, true, true, true, true, true, true, true, true])
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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, 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 app's default, then to the browser's, and ignores a language it does not serve", () => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
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}=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_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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// `/api/ready` (spec §13): the verdict, the migration level it compares against, and what the answer
|
||||
// does not say. The probes are functions, so the verdict is tested without a database; the migration
|
||||
// list is the real one, read from this repository's folders.
|
||||
|
||||
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"
|
||||
import { checkReadiness, type ReadinessProbes } from "../apps/web/src/server/readiness.js"
|
||||
|
||||
const ALL = [
|
||||
"0001_kernel",
|
||||
"0004_files",
|
||||
"0005_proposals",
|
||||
"0006_app-user-identity",
|
||||
"notes/0001_notes",
|
||||
...GRANT_MIGRATIONS,
|
||||
]
|
||||
|
||||
const probes = (overrides: Partial<ReadinessProbes> = {}, warnings: string[] = []): ReadinessProbes => ({
|
||||
store: async () => 1,
|
||||
reader: async () => 1,
|
||||
expected: async () => ALL,
|
||||
applied: async () => new Set(ALL),
|
||||
platform: async () => true,
|
||||
warn: (message) => warnings.push(message),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("the readiness verdict", () => {
|
||||
it("is ready when the store, the reader and the migration level are there", async () => {
|
||||
expect(await checkReadiness(probes())).toEqual({
|
||||
ready: true,
|
||||
checks: { store: true, reader: true, migrations: true, platform: true },
|
||||
})
|
||||
})
|
||||
|
||||
it("is not ready when the runtime role cannot query, or the reader cannot", async () => {
|
||||
const down = async () => {
|
||||
throw new Error("connect ECONNREFUSED postgresql://app_app:secret@db:5432/app")
|
||||
}
|
||||
expect((await checkReadiness(probes({ store: down }))).checks).toMatchObject({ store: false, reader: true })
|
||||
expect((await checkReadiness(probes({ reader: down }))).checks).toMatchObject({ store: true, reader: false })
|
||||
expect((await checkReadiness(probes({ store: down }))).ready).toBe(false)
|
||||
expect((await checkReadiness(probes({ reader: down }))).ready).toBe(false)
|
||||
})
|
||||
|
||||
it("is not ready while a migration this build expects has not run", async () => {
|
||||
const behind = new Set(ALL.slice(0, -1))
|
||||
const report = await checkReadiness(probes({ applied: async () => behind }))
|
||||
expect(report.ready).toBe(false)
|
||||
expect(report.checks.migrations).toBe(false)
|
||||
})
|
||||
|
||||
it("is ready when the database is ahead of this build: a newer release has already migrated it", async () => {
|
||||
const ahead = new Set([...ALL, "9999_from_the_future"])
|
||||
expect((await checkReadiness(probes({ applied: async () => ahead }))).ready).toBe(true)
|
||||
})
|
||||
|
||||
it("reports the platform without requiring it", async () => {
|
||||
const report = await checkReadiness(probes({ platform: async () => false }))
|
||||
expect(report.ready).toBe(true)
|
||||
expect(report.checks.platform).toBe(false)
|
||||
const thrown = await checkReadiness(
|
||||
probes({
|
||||
platform: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(thrown.checks.platform).toBe(false)
|
||||
})
|
||||
|
||||
it("answers with booleans and nothing else, and logs the reason where an operator reads it", async () => {
|
||||
const warnings: string[] = []
|
||||
const report = await checkReadiness(
|
||||
probes(
|
||||
{
|
||||
store: async () => {
|
||||
throw new Error("password authentication failed for user app_app\nHINT: pg_hba.conf")
|
||||
},
|
||||
applied: async () => new Set(["0001_kernel"]),
|
||||
},
|
||||
warnings,
|
||||
),
|
||||
)
|
||||
const answer = JSON.stringify(report)
|
||||
expect(answer).not.toContain("password")
|
||||
expect(answer).not.toContain("0004_files")
|
||||
expect(Object.values(report.checks).every((value) => typeof value === "boolean")).toBe(true)
|
||||
expect(warnings).toEqual([
|
||||
"ready: store failed: password authentication failed for user app_app",
|
||||
expect.stringMatching(/^ready: migrations failed: the migrate job has not applied 0004_files/),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
expect(expected.slice(-2)).toEqual([...GRANT_MIGRATIONS])
|
||||
})
|
||||
|
||||
it("names what a database lacks", () => {
|
||||
expect(missingMigrations(["a", "b", "c"], new Set(["a", "c", "z"]))).toEqual(["b"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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"]
|
||||
kernel.push("guides") // kit:guides
|
||||
kernel.push("support") // kit:support
|
||||
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,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,222 @@
|
||||
// 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 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,271 @@
|
||||
// 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,
|
||||
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 { 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 { appModules } from "../../modules/index.js"
|
||||
import { deterministicModel } from "./fake-model.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). */
|
||||
excludedPageClasses?: readonly PageClass[]
|
||||
}) {
|
||||
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 { platform, ocr } = await startOcr(options.document)
|
||||
started.push(platform)
|
||||
|
||||
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)
|
||||
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 }, { 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)
|
||||
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 (): 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)
|
||||
container.value(DOCUMENTS_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 { 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,193 @@
|
||||
// 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 { 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 })
|
||||
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,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."
|
||||
}
|
||||
Reference in New Issue
Block a user