Files
my-pi/pi-permission-system/test/config-schema.test.ts

254 lines
7.7 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, expectTypeOf, it } from "vitest";
import type {
DenyWithReason as SchemaDenyWithReason,
FlatPermissionConfig as SchemaFlatPermissionConfig,
PatternValue as SchemaPatternValue,
PermissionState as SchemaPermissionState,
} from "#src/config-schema";
import {
buildPermissionsJsonSchema,
PERMISSIONS_SCHEMA_URL,
unifiedConfigSchema,
} from "#src/config-schema";
import type {
DenyWithReason,
FlatPermissionConfig,
PatternValue,
PermissionState,
} from "#src/types";
describe("unifiedConfigSchema", () => {
describe("valid configs", () => {
it("accepts a full config with runtime knobs and flat permission", () => {
const result = unifiedConfigSchema.safeParse({
debugLog: true,
permissionReviewLog: false,
yoloMode: true,
toolInputPreviewMaxLength: 1000,
toolTextSummaryMaxLength: 120,
piInfrastructureReadPaths: ["/extra/path"],
permission: {
"*": "ask",
read: "allow",
bash: {
"*": "ask",
"git status": "allow",
"npm *": { action: "deny", reason: "Use pnpm instead" },
},
},
});
expect(result.success).toBe(true);
});
it("accepts an empty config", () => {
expect(unifiedConfigSchema.safeParse({}).success).toBe(true);
});
it("accepts a $schema field", () => {
expect(
unifiedConfigSchema.safeParse({ $schema: "https://example.com/s.json" })
.success,
).toBe(true);
});
});
describe("invalid configs are rejected", () => {
it("rejects an unknown top-level key", () => {
const result = unifiedConfigSchema.safeParse({ unknownField: "x" });
expect(result.success).toBe(false);
});
it("rejects a non-boolean debugLog", () => {
const result = unifiedConfigSchema.safeParse({ debugLog: "yes" });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.path).toEqual(["debugLog"]);
}
});
it("rejects a non-integer toolInputPreviewMaxLength", () => {
expect(
unifiedConfigSchema.safeParse({ toolInputPreviewMaxLength: 1.5 })
.success,
).toBe(false);
});
it("rejects a zero toolInputPreviewMaxLength", () => {
expect(
unifiedConfigSchema.safeParse({ toolInputPreviewMaxLength: 0 }).success,
).toBe(false);
});
it("rejects a non-string entry in piInfrastructureReadPaths", () => {
expect(
unifiedConfigSchema.safeParse({ piInfrastructureReadPaths: ["a", 1] })
.success,
).toBe(false);
});
it("rejects a string permission value", () => {
expect(
unifiedConfigSchema.safeParse({ permission: "allow" }).success,
).toBe(false);
});
it("rejects an invalid PermissionState inside a permission map", () => {
const result = unifiedConfigSchema.safeParse({
permission: { write: "invalid" },
});
expect(result.success).toBe(false);
});
it("rejects a deny-with-reason with a non-string reason", () => {
const result = unifiedConfigSchema.safeParse({
permission: { bash: { "npm *": { action: "deny", reason: 42 } } },
});
expect(result.success).toBe(false);
});
});
describe("shellTools field", () => {
it("accepts a shellTools map with a full alias", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: {
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
},
});
expect(result.success).toBe(true);
});
it("accepts an alias with only commandArgument", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: { exec_command: { commandArgument: "cmd" } },
});
expect(result.success).toBe(true);
});
it("accepts ask as a shell alias decision floor", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: {
ssh_bash: { commandArgument: "command", decisionFloor: "ask" },
},
});
expect(result.success).toBe(true);
});
it.each(["allow", "deny", "invalid"] as const)(
"rejects %s as a shell alias decision floor",
(decisionFloor) => {
const result = unifiedConfigSchema.safeParse({
shellTools: { ssh_bash: { commandArgument: "command", decisionFloor } },
});
expect(result.success).toBe(false);
},
);
it("rejects an alias missing commandArgument", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: { exec_command: { workdirArgument: "workdir" } },
});
expect(result.success).toBe(false);
});
it("rejects an unknown field inside an alias", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: { exec_command: { commandArgument: "cmd", extra: "x" } },
});
expect(result.success).toBe(false);
});
it("rejects a non-string commandArgument", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: { exec_command: { commandArgument: 42 } },
});
expect(result.success).toBe(false);
});
it("rejects an empty-string commandArgument", () => {
const result = unifiedConfigSchema.safeParse({
shellTools: { exec_command: { commandArgument: "" } },
});
expect(result.success).toBe(false);
});
});
});
describe("inferred types match the hand-written domain types", () => {
it("PermissionState is equivalent", () => {
expectTypeOf<SchemaPermissionState>().toEqualTypeOf<PermissionState>();
});
it("DenyWithReason is equivalent", () => {
expectTypeOf<SchemaDenyWithReason>().toEqualTypeOf<DenyWithReason>();
});
it("PatternValue is equivalent", () => {
expectTypeOf<SchemaPatternValue>().toEqualTypeOf<PatternValue>();
});
it("FlatPermissionConfig is equivalent", () => {
expectTypeOf<SchemaFlatPermissionConfig>().toEqualTypeOf<FlatPermissionConfig>();
});
});
describe("buildPermissionsJsonSchema", () => {
const schema = buildPermissionsJsonSchema();
it("targets Draft 2020-12", () => {
expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
});
it("sets the root $id to the monorepo raw URL", () => {
expect(schema.$id).toBe(PERMISSIONS_SCHEMA_URL);
expect(schema.$id).toContain("gotgenes/pi-packages");
});
it("forbids additional top-level properties", () => {
expect(schema.additionalProperties).toBe(false);
});
it("extracts the shared sub-schemas into $defs", () => {
const defs = schema.$defs as Record<string, unknown>;
expect(Object.keys(defs).sort()).toEqual([
"denyWithReason",
"permissionMap",
"permissionState",
]);
});
it("preserves markdownDescription for editor hovers", () => {
expect(typeof schema.markdownDescription).toBe("string");
const properties = schema.properties as Record<
string,
Record<string, unknown>
>;
expect(typeof properties.yoloMode.markdownDescription).toBe("string");
});
it("preserves the permission examples", () => {
const properties = schema.properties as Record<
string,
Record<string, unknown>
>;
expect(Array.isArray(properties.permission.examples)).toBe(true);
});
});
describe("committed schemas/permissions.schema.json is in sync", () => {
it("equals the generated schema (run `pnpm run gen:schema` if this fails)", () => {
const committedPath = join(
import.meta.dirname,
"..",
"schemas",
"permissions.schema.json",
);
const committed = JSON.parse(
readFileSync(committedPath, "utf-8"),
) as unknown;
expect(committed).toEqual(buildPermissionsJsonSchema());
});
});