Files
template-app/test/conusai-json.test.ts

133 lines
6.7 KiB
TypeScript

// 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())
})
})