/** * `requires.networks` — a module NAMES the networks it depends on and never * carries their values * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * * The rejection below is the schema-level half of "a module has no path to * create a network definition". The other half is the write path * ([[cli/commands/system-apply-config.ts]]). */ import { describe, expect, test } from 'bun:test'; import { NetworkRequirementSchema, getRequiredNetworkNames } from './schema'; import type { ModuleManifest } from './schema'; describe('NetworkRequirementSchema', () => { test('a requirement is just a name', () => { expect(NetworkRequirementSchema.safeParse({ name: 'control-plane-vpn' }).success).toBe(true); }); test('names are kebab-case, like every other user-facing identifier', () => { expect(NetworkRequirementSchema.safeParse({ name: 'control_plane_vpn' }).success).toBe(false); expect(NetworkRequirementSchema.safeParse({ name: 'DMZ' }).success).toBe(false); }); test('a requirement carrying a subnet is rejected, and the message says why', () => { const result = NetworkRequirementSchema.safeParse({ name: 'control-plane-vpn', subnet: '10.255.255.0/24', }); expect(result.success).toBe(false); if (result.success) return; const message = result.error.errors.map((e) => e.message).join(' '); expect(message).toContain('celilo owns'); expect(message).toContain('$system:network..subnet'); }); test('a gateway or vlan on the requirement is rejected too', () => { expect(NetworkRequirementSchema.safeParse({ name: 'dmz', gateway: '10.0.10.1' }).success).toBe( false, ); expect(NetworkRequirementSchema.safeParse({ name: 'dmz', vlan: 10 }).success).toBe(false); }); /** * `from:` is the second form, for a module whose required set is decided per * install rather than at authoring time — a firewall requires the networks it * has legs on, and which legs it has is a property of the box it lands on. * It still names networks and still carries no values. */ test('a requirement may name a config array instead of a literal', () => { expect(NetworkRequirementSchema.safeParse({ from: '$self:zones' }).success).toBe(true); }); test("from must reference this module's own config, not another source", () => { // `$machine:` / `$capability:` would make some other thing the author of the // required set, which is the authority question this change exists to settle. expect(NetworkRequirementSchema.safeParse({ from: '$machine:zones' }).success).toBe(false); expect(NetworkRequirementSchema.safeParse({ from: '$capability:firewall.zones' }).success).toBe( false, ); expect(NetworkRequirementSchema.safeParse({ from: 'zones' }).success).toBe(false); }); test('exactly one of name or from — neither both nor neither', () => { expect(NetworkRequirementSchema.safeParse({ name: 'dmz', from: '$self:zones' }).success).toBe( false, ); expect(NetworkRequirementSchema.safeParse({}).success).toBe(false); }); }); describe('getRequiredNetworkNames', () => { function manifestWith( networks: Array<{ name?: string; from?: string }> | undefined, ): ModuleManifest { return { requires: { capabilities: [], networks }, } as unknown as ModuleManifest; } test('is empty for a module that requires no network', () => { expect(getRequiredNetworkNames(manifestWith(undefined))).toEqual([]); }); test('keeps declaration order and drops duplicates', () => { expect( getRequiredNetworkNames(manifestWith([{ name: 'dmz' }, { name: 'app' }, { name: 'dmz' }])), ).toEqual(['dmz', 'app']); }); test("resolves a from: requirement against the module's own config", () => { expect( getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), { zones: ['dmz', 'app', 'secure'], }), ).toEqual(['dmz', 'app', 'secure']); }); test('an array stored as JSON resolves too', () => { // Array config arrives as either shape depending on how it was written, and // a requirement that silently resolved to nothing would leave a firewall leg // undeclared — which is how an interface ends up classified alien. expect( getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), { zones: '["dmz","secure-mgmt"]', }), ).toEqual(['dmz', 'secure-mgmt']); }); /** * `external` is the RESIDUAL: an interface is external because it is publicly * routable and matched no declared zone, never because it is contained in a * subnet. Giving it one would reintroduce the overload that * `readDeclaredNetworks` and `deployFirewall` both already exclude. */ test('external is dropped, however it is named', () => { expect( getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), { zones: ['dmz', 'external'], }), ).toEqual(['dmz']); expect(getRequiredNetworkNames(manifestWith([{ name: 'external' }]))).toEqual([]); }); test('an unanswered config array requires nothing rather than throwing', () => { // The firewall's zones are answered by the config interview, which runs // before this. An empty result here means the interview has not happened // yet, not that the module needs no networks. expect(getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {})).toEqual([]); }); test('literal and dynamic requirements combine', () => { expect( getRequiredNetworkNames( manifestWith([{ name: 'control-plane-vpn' }, { from: '$self:zones' }]), { zones: ['dmz'] }, ), ).toEqual(['control-plane-vpn', 'dmz']); }); });