cast/test/manifest.test.ts
claude-hdb 9a1b7003b7 fix(manifest): refuse checkout paths that are not absolute (#49)
Coolify 4.1.2 validates every checkout-relative path on create and 422s
anything without a leading slash: `docker_compose_location` against
ValidationPatterns::FILE_PATH_PATTERN, `base_directory`/`publish_directory`
against DIRECTORY_PATH_PATTERN. cast passed all three through verbatim and
validated nothing about their shape — and docs/semantics.md taught the exact
value Coolify rejects (`compose_file: docker-compose.yaml`).

The 422 is a property of the manifest, not of the instance, so it is knowable
before a single API call. Refine at parse time, on every verb: a refusal that
names the fix, not a silent normalization — the manifest is the artifact under
review, so a bad value is fixed in the file, once.

Fixes the docs example and the fixtures that carried the broken value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:20:27 +00:00

288 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { loadBindings } from "../src/bindings.js";
import { loadManifest } from "../src/manifest.js";
const FIX = new URL("./fixtures/", import.meta.url).pathname;
describe("loadManifest", () => {
it("parses a valid manifest", () => {
const m = loadManifest(`${FIX}manifest.yaml`);
expect(m.project).toBe("widget");
expect(m.environments.prod.applications["core-api"].build.pack).toBe(
"nixpacks",
);
expect(m.environments.prod.databases?.postgres.backup?.retention).toBe(7);
});
it("rejects unknown build packs", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: x
environments:
prod:
applications:
a:
source: { repo: o/r, branch: main }
build: { pack: docker-compose, base_directory: / }
domains: []
`,
}),
).toThrow(/pack/);
});
it("rejects instance identity in manifests (no uuid-like fields)", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: x
environments:
prod:
applications:
a:
source: { repo: o/r, branch: main }
build: { pack: static, base_directory: / }
domains: []
server_uuid: abc123
`,
}),
).toThrow(/unrecognized|server_uuid/i);
});
it("accepts a dockercompose app with compose_file + service_domains and no port/healthcheck/domains", () => {
const m = loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
api: ["https://api.example.com"]
env_template: core.prod.env.template
`,
});
const app = m.environments.prod.applications.core;
expect(app.build.pack).toBe("dockercompose");
expect(app.build.compose_file).toBe("/docker-compose.yaml");
expect(app.service_domains).toEqual({ api: ["https://api.example.com"] });
expect(app.port).toBeUndefined();
expect(app.healthcheck).toBeUndefined();
expect(app.domains).toBeUndefined();
});
it("rejects a dockercompose app without compose_file", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: / }
service_domains:
api: ["https://api.example.com"]
`,
}),
).toThrow(/compose_file/);
});
// Coolify 4.1.2 validates docker_compose_location against
// ValidationPatterns::FILE_PATH_PATTERN on create and 422s a path with no
// leading slash — after `apply` has already made the project and the
// environment. The manifest knows this before any API call, so it refuses.
it("rejects a compose_file with no leading slash, and names the fix", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }
service_domains:
api: ["https://api.example.com"]
`,
}),
).toThrow(/compose_file must be an absolute path.*\/docker-compose\.yaml/s);
});
it("rejects a compose_file that is the bare root (a directory, not a file)", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: / }
service_domains:
api: ["https://api.example.com"]
`,
}),
).toThrow(/compose_file must be an absolute path/);
});
it("rejects a base_directory with no leading slash", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: apps/core }
domains: ["https://api.example.com"]
`,
}),
).toThrow(/base_directory must be an absolute path/);
});
it("rejects a publish_directory with no leading slash", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: static, base_directory: /, publish_directory: dist }
domains: ["https://api.example.com"]
`,
}),
).toThrow(/publish_directory must be an absolute path/);
});
// Coolify's DIRECTORY_PATH_PATTERN admits the bare "/" where FILE_PATH_PATTERN
// does not — every manifest in the wild says `base_directory: /`, so a shared
// "absolute path" rule that rejected it would refuse every manifest cast has.
it("accepts / as base_directory and a nested absolute publish_directory", () => {
const m = loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: static, base_directory: /, publish_directory: /apps/web/dist }
domains: ["https://api.example.com"]
`,
});
const app = m.environments.prod.applications.core;
expect(app.build.base_directory).toBe("/");
expect(app.build.publish_directory).toBe("/apps/web/dist");
});
it("rejects a dockercompose app with top-level domains", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
api: ["https://api.example.com"]
domains: ["https://api.example.com"]
`,
}),
).toThrow(/domains/);
});
it("rejects service_domains on a nixpacks app", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://api.example.com"]
service_domains:
api: ["https://api.example.com"]
`,
}),
).toThrow(/service_domains/);
});
});
describe("loadBindings", () => {
it("parses bindings", () => {
const b = loadBindings(`${FIX}environments.yaml`);
expect(b.environments.prod.server).toBe("prod-box");
expect(b.github_apps.widget).toBe("my-github-app");
});
it("carries an environment's forbidden_var_patterns through", () => {
const b = loadBindings(`${FIX}environments.yaml`);
expect(b.environments.prod.forbidden_var_patterns).toEqual(["^ALLOW_"]);
expect(b.environments.staging.forbidden_var_patterns).toBeUndefined();
});
it("carries an environment's expected team through", () => {
const b = loadBindings(`${FIX}environments.yaml`);
expect(b.environments.prod.team).toEqual({ id: 1, name: "heavy-duty" });
});
// Fail-closed at the schema: an environment with no declared team is one
// whose token cannot be verified, and an unverifiable target is exactly the
// duplicate-into-the-wrong-team failure the binding exists to prevent.
it("rejects an environment with no team", () => {
expect(() =>
loadBindings(`${FIX}environments.yaml`, {
overrideText: `
environments:
prod: { server: prod-box }
github_apps: { widget: my-github-app }
`,
}),
).toThrow(/team/);
});
it("rejects a team that names neither id nor name", () => {
expect(() =>
loadBindings(`${FIX}environments.yaml`, {
overrideText: `
environments:
prod: { server: prod-box, team: {} }
github_apps: { widget: my-github-app }
`,
}),
).toThrow(/at least one of/);
});
// Coolify's Root Team is id 0 (app/Models/User.php @ v4.1.2) — the team a
// single-admin instance keeps everything in. Rejecting it would make the id
// check unusable on exactly the topology that most needs it.
it("accepts team id 0, the Root Team", () => {
const b = loadBindings(`${FIX}environments.yaml`, {
overrideText: `
environments:
prod: { server: prod-box, team: { id: 0, name: Root Team } }
github_apps: { widget: my-github-app }
`,
});
expect(b.environments.prod.team).toEqual({ id: 0, name: "Root Team" });
});
it("accepts a team given by id alone, or by name alone", () => {
const byId = loadBindings(`${FIX}environments.yaml`, {
overrideText: `
environments:
prod: { server: prod-box, team: { id: 2 } }
github_apps: { widget: my-github-app }
`,
});
expect(byId.environments.prod.team).toEqual({ id: 2 });
const byName = loadBindings(`${FIX}environments.yaml`, {
overrideText: `
environments:
prod: { server: prod-box, team: { name: heavy-duty } }
github_apps: { widget: my-github-app }
`,
});
expect(byName.environments.prod.team).toEqual({ name: "heavy-duty" });
});
});