diff --git a/src/state/schema.test.ts b/src/state/schema.test.ts new file mode 100644 index 0000000..a32d6e0 --- /dev/null +++ b/src/state/schema.test.ts @@ -0,0 +1,199 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { DesiredStateSchema, validateCrossReferences } from "./schema.ts"; + +const VALID_STATE = { + groups: { + pilots: { peers: ["Pilot-hawk-72"] }, + "ground-stations": { peers: ["GS-hawk-72"] }, + }, + setup_keys: { + "GS-hawk-72": { + type: "one-off" as const, + expires_in: 604800, + usage_limit: 1, + auto_groups: ["ground-stations"], + enrolled: true, + }, + "Pilot-hawk-72": { + type: "one-off" as const, + expires_in: 604800, + usage_limit: 1, + auto_groups: ["pilots"], + enrolled: false, + }, + }, + policies: { + "pilots-to-gs": { + description: "Allow pilots to reach ground stations", + enabled: true, + sources: ["pilots"], + destinations: ["ground-stations"], + bidirectional: true, + protocol: "all" as const, + }, + }, + routes: {}, + dns: { nameserver_groups: {} }, +}; + +Deno.test("DesiredStateSchema parses a valid state", () => { + const result = DesiredStateSchema.parse(VALID_STATE); + assertEquals(Object.keys(result.groups).length, 2); + assertEquals(Object.keys(result.setup_keys).length, 2); + assertEquals(Object.keys(result.policies).length, 1); + assertEquals(result.policies["pilots-to-gs"].action, "accept"); + assertEquals(result.policies["pilots-to-gs"].protocol, "all"); +}); + +Deno.test("DesiredStateSchema applies defaults for optional top-level fields", () => { + const minimal = { + groups: { ops: { peers: [] } }, + setup_keys: {}, + }; + const result = DesiredStateSchema.parse(minimal); + assertEquals(result.policies, {}); + assertEquals(result.routes, {}); + assertEquals(result.dns, { nameserver_groups: {} }); +}); + +Deno.test("DesiredStateSchema rejects invalid setup key type", () => { + const bad = { + ...VALID_STATE, + setup_keys: { + "bad-key": { + type: "permanent", + expires_in: 100, + usage_limit: 0, + auto_groups: [], + enrolled: false, + }, + }, + }; + assertThrows(() => DesiredStateSchema.parse(bad)); +}); + +Deno.test("DesiredStateSchema rejects negative expires_in", () => { + const bad = { + ...VALID_STATE, + setup_keys: { + "bad-key": { + type: "one-off", + expires_in: -1, + usage_limit: 0, + auto_groups: [], + enrolled: false, + }, + }, + }; + assertThrows(() => DesiredStateSchema.parse(bad)); +}); + +Deno.test("DesiredStateSchema rejects route metric out of range", () => { + const bad = { + ...VALID_STATE, + routes: { + "bad-route": { + peer_groups: ["pilots"], + metric: 10000, + distribution_groups: ["pilots"], + enabled: true, + }, + }, + }; + assertThrows(() => DesiredStateSchema.parse(bad)); +}); + +Deno.test("validateCrossReferences passes for a valid state", () => { + const state = DesiredStateSchema.parse(VALID_STATE); + const errors = validateCrossReferences(state); + assertEquals(errors, []); +}); + +Deno.test("validateCrossReferences catches missing group in policy source", () => { + const bad = DesiredStateSchema.parse({ + ...VALID_STATE, + policies: { + broken: { + enabled: true, + sources: ["nonexistent"], + destinations: ["pilots"], + bidirectional: false, + }, + }, + }); + const errors = validateCrossReferences(bad); + assertEquals(errors.length, 1); + assertEquals(errors[0].includes("nonexistent"), true); + assertEquals(errors[0].includes("source"), true); +}); + +Deno.test("validateCrossReferences catches peer without matching setup key", () => { + const bad = DesiredStateSchema.parse({ + groups: { + pilots: { peers: ["ghost-peer"] }, + }, + setup_keys: {}, + }); + const errors = validateCrossReferences(bad); + assertEquals(errors.length, 1); + assertEquals(errors[0].includes("ghost-peer"), true); + assertEquals(errors[0].includes("setup key"), true); +}); + +Deno.test("validateCrossReferences catches auto_group referencing nonexistent group", () => { + const bad = DesiredStateSchema.parse({ + groups: {}, + setup_keys: { + "some-key": { + type: "reusable", + expires_in: 3600, + usage_limit: 0, + auto_groups: ["phantom-group"], + enrolled: false, + }, + }, + }); + const errors = validateCrossReferences(bad); + assertEquals(errors.length, 1); + assertEquals(errors[0].includes("phantom-group"), true); + assertEquals(errors[0].includes("auto_group"), true); +}); + +Deno.test("validateCrossReferences catches missing group in route peer_groups", () => { + const bad = DesiredStateSchema.parse({ + groups: { ops: { peers: [] } }, + setup_keys: {}, + routes: { + "bad-route": { + peer_groups: ["ops", "missing"], + distribution_groups: ["ops"], + enabled: true, + }, + }, + }); + const errors = validateCrossReferences(bad); + assertEquals(errors.length, 1); + assertEquals(errors[0].includes("missing"), true); + assertEquals(errors[0].includes("peer_group"), true); +}); + +Deno.test("validateCrossReferences catches missing group in DNS nameserver group", () => { + const bad = DesiredStateSchema.parse({ + groups: {}, + setup_keys: {}, + dns: { + nameserver_groups: { + "my-dns": { + nameservers: [{ ip: "1.1.1.1" }], + enabled: true, + groups: ["ghost"], + primary: true, + domains: [], + }, + }, + }, + }); + const errors = validateCrossReferences(bad); + assertEquals(errors.length, 1); + assertEquals(errors[0].includes("ghost"), true); +}); diff --git a/src/state/schema.ts b/src/state/schema.ts new file mode 100644 index 0000000..7cade21 --- /dev/null +++ b/src/state/schema.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; + +// --- Leaf schemas --- + +export const SetupKeySchema = z.object({ + type: z.enum(["one-off", "reusable"]), + expires_in: z.number().int().positive(), + usage_limit: z.number().int().nonnegative(), + auto_groups: z.array(z.string()), + enrolled: z.boolean(), +}); + +export const GroupSchema = z.object({ + peers: z.array(z.string()), +}); + +export const PolicySchema = z.object({ + description: z.string().default(""), + enabled: z.boolean(), + sources: z.array(z.string()), + destinations: z.array(z.string()), + bidirectional: z.boolean(), + protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"), + action: z.enum(["accept", "drop"]).default("accept"), + ports: z.array(z.string()).optional(), +}); + +export const RouteSchema = z.object({ + description: z.string().default(""), + network: z.string().optional(), + domains: z.array(z.string()).optional(), + peer_groups: z.array(z.string()), + metric: z.number().int().min(1).max(9999).default(9999), + masquerade: z.boolean().default(true), + distribution_groups: z.array(z.string()), + enabled: z.boolean(), + keep_route: z.boolean().default(true), +}); + +export const NameserverSchema = z.object({ + ip: z.string(), + ns_type: z.string().default("udp"), + port: z.number().int().default(53), +}); + +export const DnsNameserverGroupSchema = z.object({ + description: z.string().default(""), + nameservers: z.array(NameserverSchema).min(1).max(3), + enabled: z.boolean(), + groups: z.array(z.string()), + primary: z.boolean(), + domains: z.array(z.string()), + search_domains_enabled: z.boolean().default(false), +}); + +// --- Top-level schema --- + +export const DesiredStateSchema = z.object({ + groups: z.record(z.string(), GroupSchema), + setup_keys: z.record(z.string(), SetupKeySchema), + policies: z.record(z.string(), PolicySchema).default({}), + routes: z.record(z.string(), RouteSchema).default({}), + dns: z.object({ + nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema) + .default({}), + }).default({ nameserver_groups: {} }), +}); + +// --- Inferred types --- + +export type DesiredState = z.infer; +export type SetupKeyConfig = z.infer; +export type GroupConfig = z.infer; +export type PolicyConfig = z.infer; +export type RouteConfig = z.infer; +export type DnsNameserverGroupConfig = z.infer; + +// --- Cross-reference validation --- + +/** + * Validates that all cross-references within a parsed DesiredState are + * consistent. Returns an array of human-readable error strings — an empty + * array means the state is internally consistent. + * + * Checks performed: + * 1. Every peer listed in a group corresponds to an existing setup key. + * 2. Every auto_group on a setup key references an existing group. + * 3. Every source/destination in a policy references an existing group. + * 4. Every peer_group and distribution_group in a route references an + * existing group. + * 5. Every group in a DNS nameserver group references an existing group. + */ +export function validateCrossReferences(state: DesiredState): string[] { + const errors: string[] = []; + const groupNames = new Set(Object.keys(state.groups)); + const setupKeyNames = new Set(Object.keys(state.setup_keys)); + + // 1. Peers in groups must reference existing setup keys + for (const [groupName, group] of Object.entries(state.groups)) { + for (const peer of group.peers) { + if (!setupKeyNames.has(peer)) { + errors.push( + `group "${groupName}": peer "${peer}" does not match any setup key`, + ); + } + } + } + + // 2. auto_groups on setup keys must reference existing groups + for (const [keyName, key] of Object.entries(state.setup_keys)) { + for (const ag of key.auto_groups) { + if (!groupNames.has(ag)) { + errors.push( + `setup_key "${keyName}": auto_group "${ag}" does not match any group`, + ); + } + } + } + + // 3. Policy sources and destinations must reference existing groups + for (const [policyName, policy] of Object.entries(state.policies)) { + for (const src of policy.sources) { + if (!groupNames.has(src)) { + errors.push( + `policy "${policyName}": source "${src}" does not match any group`, + ); + } + } + for (const dst of policy.destinations) { + if (!groupNames.has(dst)) { + errors.push( + `policy "${policyName}": destination "${dst}" does not match any group`, + ); + } + } + } + + // 4. Route peer_groups and distribution_groups must reference existing groups + for (const [routeName, route] of Object.entries(state.routes)) { + for (const pg of route.peer_groups) { + if (!groupNames.has(pg)) { + errors.push( + `route "${routeName}": peer_group "${pg}" does not match any group`, + ); + } + } + for (const dg of route.distribution_groups) { + if (!groupNames.has(dg)) { + errors.push( + `route "${routeName}": distribution_group "${dg}" does not match any group`, + ); + } + } + } + + // 5. DNS nameserver group references must match existing groups + for ( + const [nsGroupName, nsGroup] of Object.entries( + state.dns.nameserver_groups, + ) + ) { + for (const g of nsGroup.groups) { + if (!groupNames.has(g)) { + errors.push( + `dns.nameserver_groups "${nsGroupName}": group "${g}" does not match any group`, + ); + } + } + } + + return errors; +}