Files
suaveplan/packages/api-stack/api/api-manifest/src/validators/validators.test.ts
2026-05-27 23:29:53 +01:00

230 lines
7.5 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { MANIFEST_CODES } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import { MINIMAL_FIXTURE } from "../testing/conformance.js";
import { validateErrorStatusMapShape } from "./validate-error-status-map-shape.js";
import { validateOpenApiRequiresGraphqlOrRoutes } from "./validate-openapi-requires-graphql-or-routes.js";
import { validatePermissionsWhenSandboxed } from "./validate-permissions-when-sandboxed.js";
import { validateSagaOwnership } from "./validate-saga-ownership.js";
describe("validateSagaOwnership", () => {
it("rejects step referencing undeclared dependency", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
sagas: {
owns: [
{
name: "s1",
steps: [
{
plugin: "@acme/unknown",
action: "a",
compensate: "c",
},
],
},
],
},
};
const r = validateSagaOwnership(m);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(MANIFEST_CODES.INVALID_SAGA_OWNERSHIP);
}
});
it("accepts step naming the manifest itself", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
sagas: {
owns: [
{
name: "s1",
steps: [
{
plugin: MINIMAL_FIXTURE.name,
action: "a",
compensate: "c",
},
],
},
],
},
};
expect(validateSagaOwnership(m).ok).toBe(true);
});
it("accepts step naming a declared dependency", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
dependsOn: { "@acme/dep": "^1" },
sagas: {
owns: [
{
name: "s1",
steps: [
{
plugin: "@acme/dep",
action: "a",
compensate: "c",
},
],
},
],
},
};
expect(validateSagaOwnership(m).ok).toBe(true);
});
it("no-op when sagas absent", { timeout: 5000 }, () => {
expect(validateSagaOwnership(MINIMAL_FIXTURE).ok).toBe(true);
});
it("no-op when owns is empty", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
sagas: { owns: [] },
};
expect(validateSagaOwnership(m).ok).toBe(true);
});
});
describe("validatePermissionsWhenSandboxed", () => {
it("sandboxed isolation requires permissions.budgets", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
isolation: "sandboxed",
};
const r = validatePermissionsWhenSandboxed(m);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(MANIFEST_CODES.SANDBOX_REQUIRES_BUDGETS);
}
});
it("ok when sandboxed with budgets", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
isolation: "sandboxed",
permissions: { budgets: { cpuPercent: 25 } },
};
expect(validatePermissionsWhenSandboxed(m).ok).toBe(true);
});
it("no-op for non-sandboxed tiers", { timeout: 5000 }, () => {
expect(validatePermissionsWhenSandboxed(MINIMAL_FIXTURE).ok).toBe(true);
});
});
describe("validateOpenApiRequiresGraphqlOrRoutes", () => {
it("ok when not contributing", { timeout: 5000 }, () => {
expect(validateOpenApiRequiresGraphqlOrRoutes(MINIMAL_FIXTURE).ok).toBe(
true
);
});
it("fails when contributing without routes or graphql", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
};
const r = validateOpenApiRequiresGraphqlOrRoutes(m);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(
MANIFEST_CODES.OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES
);
}
});
it("ok with routes", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
routes: { prefix: "/x" },
};
expect(validateOpenApiRequiresGraphqlOrRoutes(m).ok).toBe(true);
});
it("ok with graphql.contributes=true", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
graphql: { contributes: true },
};
expect(validateOpenApiRequiresGraphqlOrRoutes(m).ok).toBe(true);
});
it("fails when openapi contributes and graphql.contributes=false", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
graphql: { contributes: false },
};
expect(validateOpenApiRequiresGraphqlOrRoutes(m).ok).toBe(false);
});
});
describe("validateErrorStatusMapShape", () => {
it("no-op when statusMap absent", { timeout: 5000 }, () => {
expect(validateErrorStatusMapShape(MINIMAL_FIXTURE).ok).toBe(true);
});
it("ok when every entry is well-formed", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
errors: { statusMap: { "PLUGIN.A.B": 409 } },
};
expect(validateErrorStatusMapShape(m).ok).toBe(true);
});
it("collects code-shape offenders", { timeout: 5000 }, () => {
// bypass Zod by passing a manually built object
const bad = {
...MINIMAL_FIXTURE,
errors: {
statusMap: {
"lower.case.code": 409,
},
},
} as unknown as PluginManifest;
const r = validateErrorStatusMapShape(bad);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(MANIFEST_CODES.INVALID_ERROR_STATUS_MAP);
const off = r.error.context?.offenders as unknown[];
expect(off.length).toBeGreaterThan(0);
}
});
it("collects status-range offenders", { timeout: 5000 }, () => {
const bad = {
...MINIMAL_FIXTURE,
errors: {
statusMap: { "PLUGIN.A.B": 200 },
},
} as unknown as PluginManifest;
const r = validateErrorStatusMapShape(bad);
expect(r.ok).toBe(false);
});
it("rejects non-integer status", { timeout: 5000 }, () => {
const bad = {
...MINIMAL_FIXTURE,
errors: {
statusMap: { "PLUGIN.A.B": 4.5 as unknown as number },
},
} as unknown as PluginManifest;
const r = validateErrorStatusMapShape(bad);
expect(r.ok).toBe(false);
});
});