import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { type DbClient, createDbClient } from '../db/client'; import { auditModule } from '../module/packaging/audit'; import { computeFileChecksum } from '../module/packaging/checksum'; import { buildInstanceLinkFarm, deriveInstanceModuleId, ownedSystemModuleIds, planInstanceLinks, submoduleSourcePath, verifyInstanceLinks, } from './module-instances'; describe('deriveInstanceModuleId', () => { test('is deterministic, so an instantiate is safe to retry', () => { const a = deriveInstanceModuleId('byoi', 'lab', 'sub-abc123'); const b = deriveInstanceModuleId('byoi', 'lab', 'sub-abc123'); expect(a).toBe(b); }); test('produces a valid kebab-case module id from an opaque key', () => { // D2: celilo never interprets the key, so it may be anything at all. const id = deriveInstanceModuleId('byoi', 'lab', 'Sally Smith 🎉'); expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); expect(id.startsWith('byoi-lab-')).toBe(true); }); test('two parents using the same key get different ids', () => { const a = deriveInstanceModuleId('byoi', 'lab', 'shared-key'); const b = deriveInstanceModuleId('forgejo', 'lab', 'shared-key'); expect(a).not.toBe(b); }); test('one parent reusing a key across two submodules gets different ids', () => { const a = deriveInstanceModuleId('forgejo', 'runner', 'proj-1'); const b = deriveInstanceModuleId('forgejo', 'builder', 'proj-1'); expect(a).not.toBe(b); }); // Without a separator in the hash input, ("a", "b-c") and ("a-b", "c") hash // the same string. The kind of ambiguity that never shows up until it does. test('a hyphen moving between parent and submodule changes the id', () => { const a = deriveInstanceModuleId('a', 'b-c', 'k'); const b = deriveInstanceModuleId('a-b', 'c', 'k'); expect(a).not.toBe(b); }); test('a different key changes the id', () => { expect(deriveInstanceModuleId('byoi', 'lab', 'one')).not.toBe( deriveInstanceModuleId('byoi', 'lab', 'two'), ); }); }); describe('planInstanceLinks', () => { test('links the authored subtrees', () => { expect(planInstanceLinks(['ansible', 'terraform', 'scripts', 'manifest.yml'])).toEqual([ 'ansible', 'manifest.yml', 'scripts', 'terraform', ]); }); // Linking these would put every instance's writes back into one shared // directory, which is the exact failure the farm exists to prevent. test('never links generated/ or the derived hook outputs', () => { const planned = planInstanceLinks([ 'ansible', 'generated', 'screenshots', 'cookies.json', 'checksums.json', ]); expect(planned).toEqual(['ansible']); }); }); describe('the instance symlink farm', () => { let root: string; let submodulePath: string; let instancePath: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'celilo-instances-')); submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner'); instancePath = join(root, 'forgejo-runner-abc123def456'); await mkdir(join(submodulePath, 'ansible'), { recursive: true }); await mkdir(join(submodulePath, 'terraform'), { recursive: true }); await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n'); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); test('links the source and gives the instance its own real generated/', async () => { const linked = await buildInstanceLinkFarm({ instancePath, submodulePath }); expect(linked).toEqual(['ansible', 'manifest.yml', 'terraform']); expect((await lstat(join(instancePath, 'ansible'))).isSymbolicLink()).toBe(true); expect((await lstat(join(instancePath, 'generated'))).isSymbolicLink()).toBe(false); expect((await lstat(join(instancePath, 'generated'))).isDirectory()).toBe(true); }); // This is the invariant nine call sites depend on and the reason the farm // exists rather than a rewritten sourcePath. test('join(instancePath, "generated") is the instance own directory, not the submodule one', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); await writeFile(join(instancePath, 'generated', 'terraform.tfstate'), '{}'); expect(existsSync(join(submodulePath, 'generated'))).toBe(false); }); test('two instances of one submodule get separate generated/ and shared source', async () => { const other = join(root, 'forgejo-runner-999888777666'); await buildInstanceLinkFarm({ instancePath, submodulePath }); await buildInstanceLinkFarm({ instancePath: other, submodulePath }); await writeFile(join(instancePath, 'generated', 'terraform.tfstate'), 'first'); await writeFile(join(other, 'generated', 'terraform.tfstate'), 'second'); expect(await readFile(join(instancePath, 'generated', 'terraform.tfstate'), 'utf-8')).toBe( 'first', ); expect(await readFile(join(other, 'generated', 'terraform.tfstate'), 'utf-8')).toBe('second'); // Same authored bytes, one copy on disk. expect(await readFile(join(instancePath, 'manifest.yml'), 'utf-8')).toBe('id: runner\n'); expect(await readFile(join(other, 'manifest.yml'), 'utf-8')).toBe('id: runner\n'); }); test('links are relative, so the farm survives the data directory moving', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); const target = await readlink(join(instancePath, 'ansible')); expect(target.startsWith('/')).toBe(false); }); test('is idempotent', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); const second = await buildInstanceLinkFarm({ instancePath, submodulePath }); expect(second).toEqual(['ansible', 'manifest.yml', 'terraform']); expect(await readFile(join(instancePath, 'manifest.yml'), 'utf-8')).toBe('id: runner\n'); }); // A submodule gaining a directory in a later version would otherwise leave // every existing instance stale, which is why this converges rather than creates. test('picks up a subtree the submodule gained since the instance was built', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); await mkdir(join(submodulePath, 'base-module-aspect'), { recursive: true }); const linked = await buildInstanceLinkFarm({ instancePath, submodulePath }); expect(linked).toContain('base-module-aspect'); expect(existsSync(join(instancePath, 'base-module-aspect'))).toBe(true); }); test('removes a link whose subtree the submodule dropped', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); await rm(join(submodulePath, 'terraform'), { recursive: true }); await buildInstanceLinkFarm({ instancePath, submodulePath }); await expect(lstat(join(instancePath, 'terraform'))).rejects.toThrow(); }); test('repairs a link that points somewhere else', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); const elsewhere = join(root, 'somebody-elses-source'); await mkdir(elsewhere, { recursive: true }); await rm(join(instancePath, 'ansible'), { force: true }); await symlink(elsewhere, join(instancePath, 'ansible')); await buildInstanceLinkFarm({ instancePath, submodulePath }); expect(resolve(instancePath, await readlink(join(instancePath, 'ansible')))).toBe( resolve(submodulePath, 'ansible'), ); }); // existsSync follows links, so a dangling one reports absent while symlink() // still refuses to overwrite it. Worth a test because it fails confusingly. test('replaces a dangling link rather than failing on it', async () => { await mkdir(instancePath, { recursive: true }); await symlink('../gone-away', join(instancePath, 'ansible')); await buildInstanceLinkFarm({ instancePath, submodulePath }); expect(existsSync(join(instancePath, 'ansible'))).toBe(true); }); test('refuses to build against a submodule source that is not there', async () => { await expect( buildInstanceLinkFarm({ instancePath, submodulePath: join(root, 'nope') }), ).rejects.toThrow(/does not exist/); }); }); describe('verifyInstanceLinks', () => { let root: string; let submodulePath: string; let instancePath: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'celilo-verify-')); submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner'); instancePath = join(root, 'forgejo-runner-abc123def456'); await mkdir(join(submodulePath, 'ansible'), { recursive: true }); await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n'); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); test('a freshly built farm is clean', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); expect(await verifyInstanceLinks({ instancePath, submodulePath })).toEqual([]); }); // The one integrity property unique to an instance. Nothing else checks it, // because an instance carries no authored bytes of its own to checksum. test('reports a link repointed at somebody else source', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); const elsewhere = join(root, 'attacker'); await mkdir(elsewhere, { recursive: true }); await rm(join(instancePath, 'ansible'), { force: true }); await symlink(elsewhere, join(instancePath, 'ansible')); const violations = await verifyInstanceLinks({ instancePath, submodulePath }); expect(violations).toHaveLength(1); expect(violations[0]?.entry).toBe('ansible'); expect(violations[0]?.actual).toBe(resolve(elsewhere)); }); test('reports a missing link', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); await rm(join(instancePath, 'manifest.yml'), { force: true }); const violations = await verifyInstanceLinks({ instancePath, submodulePath }); expect(violations).toHaveLength(1); expect(violations[0]?.entry).toBe('manifest.yml'); expect(violations[0]?.actual).toBeNull(); }); test('reports a real file standing where a link belongs', async () => { await buildInstanceLinkFarm({ instancePath, submodulePath }); await rm(join(instancePath, 'manifest.yml'), { force: true }); await writeFile(join(instancePath, 'manifest.yml'), 'id: not-the-real-one\n'); const violations = await verifyInstanceLinks({ instancePath, submodulePath }); expect(violations).toHaveLength(1); expect(violations[0]?.entry).toBe('manifest.yml'); }); }); describe('module_instances storage (D2, D5)', () => { let root: string; let db: DbClient; const addModule = (id: string) => db.$client.run( "INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}','/tmp/'||?)", [id, id, id], ); const addInstance = (parent: string, submodule: string, key: string) => db.$client.run( 'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)', [deriveInstanceModuleId(parent, submodule, key), parent, submodule, key], ); const instanceCount = () => (db.$client.query('SELECT count(*) n FROM module_instances').get() as { n: number }).n; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'celilo-instance-db-')); db = createDbClient({ path: join(root, 'celilo.db') }); for (const id of ['byoi', 'forgejo']) addModule(id); }); afterEach(async () => { db.$client.close(); await rm(root, { recursive: true, force: true }); }); test('identity is the triple, not the key alone', () => { const columns = db.$client .query("PRAGMA index_info('module_instances_identity_idx')") .all() as Array<{ name: string }>; expect(columns.map((c) => c.name)).toEqual(['parent_id', 'submodule', 'instance_key']); }); // The spec scenario: identity is the owner, module and id together. test('two parents may use the same instance key', () => { addModule(deriveInstanceModuleId('byoi', 'lab', 'shared')); addModule(deriveInstanceModuleId('forgejo', 'lab', 'shared')); addInstance('byoi', 'lab', 'shared'); expect(() => addInstance('forgejo', 'lab', 'shared')).not.toThrow(); expect(instanceCount()).toBe(2); }); test('one parent cannot reuse a key for the same submodule', () => { addModule(deriveInstanceModuleId('byoi', 'lab', 'dup')); addInstance('byoi', 'lab', 'dup'); expect(() => addInstance('byoi', 'lab', 'dup')).toThrow(/UNIQUE constraint failed/); }); // D5's containment, at the storage layer. The cascade is not the whole of the // removal path (that refuses first and destroys deliberately), but a row must // never outlive the parent it names. test('removing a parent removes its instance rows and leaves other parents alone', () => { addModule(deriveInstanceModuleId('byoi', 'lab', 'a')); addModule(deriveInstanceModuleId('forgejo', 'runner', 'b')); addInstance('byoi', 'lab', 'a'); addInstance('forgejo', 'runner', 'b'); expect(instanceCount()).toBe(2); db.$client.run("DELETE FROM modules WHERE id='byoi'"); const left = db.$client.query('SELECT parent_id FROM module_instances').all() as Array<{ parent_id: string; }>; expect(left).toEqual([{ parent_id: 'forgejo' }]); }); test('an instance defaults to pending with no failure recorded', () => { addModule(deriveInstanceModuleId('byoi', 'lab', 'fresh')); addInstance('byoi', 'lab', 'fresh'); const row = db.$client .query('SELECT state, failure_reason, retryable FROM module_instances') .get() as { state: string; failure_reason: string | null; retryable: number | null; }; expect(row.state).toBe('pending'); expect(row.failure_reason).toBeNull(); expect(row.retryable).toBeNull(); }); }); describe('auditing an instance (D4, task 3.8)', () => { let root: string; let db: DbClient; let parentPath: string; let submodulePath: string; let instanceId: string; let instancePath: string; /** Baseline the parent's own tree, so its audit is genuinely clean. */ async function baselineParent() { const checksums: Record = {}; for (const rel of ['manifest.yml', 'submodules/runner/manifest.yml']) { checksums[rel] = await computeFileChecksum(join(parentPath, rel)); } db.$client.run( "INSERT INTO module_integrity (module_id, checksums, version) VALUES ('forgejo', ?, '1.0.0')", [JSON.stringify(checksums)], ); } beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'celilo-instance-audit-')); db = createDbClient({ path: join(root, 'celilo.db') }); parentPath = join(root, 'modules', 'forgejo'); submodulePath = submoduleSourcePath(parentPath, 'runner'); await mkdir(submodulePath, { recursive: true }); await writeFile(join(parentPath, 'manifest.yml'), 'id: forgejo\n'); await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n'); instanceId = deriveInstanceModuleId('forgejo', 'runner', 'proj-1'); instancePath = join(root, 'modules', instanceId); await buildInstanceLinkFarm({ instancePath, submodulePath }); for (const [id, path] of [ ['forgejo', parentPath], [instanceId, instancePath], ] as const) { db.$client.run( "INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}',?)", [id, id, path], ); } db.$client.run( 'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)', [instanceId, 'forgejo', 'runner', 'proj-1'], ); }); afterEach(async () => { db.$client.close(); await rm(root, { recursive: true, force: true }); }); // The failure this exists to prevent: forty instances each reporting // "No integrity data found" on a fleet that is entirely healthy. test('an instance does not report a missing baseline', async () => { await baselineParent(); const result = await auditModule(instanceId, db); expect(result.error).toBeUndefined(); expect(result.violations).toEqual([]); expect(result.success).toBe(true); }); // The one integrity property unique to an instance. Nothing else checks it, // and every checksum in the fleet still reconciles while it is wrong. test('reports a link repointed at another module source', async () => { await baselineParent(); const elsewhere = join(root, 'modules', 'somebody-else'); await mkdir(elsewhere, { recursive: true }); await rm(join(instancePath, 'manifest.yml'), { force: true }); await symlink(elsewhere, join(instancePath, 'manifest.yml')); const result = await auditModule(instanceId, db); expect(result.success).toBe(false); expect( result.violations.some((v) => v.message.includes('running source it does not own')), ).toBe(true); }); // An instance runs its parent's bytes, so a clean instance row must not read // as evidence when the parent itself is unverified. test("carries the parent's verdict rather than reporting clean beneath it", async () => { // No baseline for the parent at all: its own audit cannot pass. const result = await auditModule(instanceId, db); expect(result.success).toBe(false); expect(result.violations.some((v) => v.message.includes("belong to 'forgejo'"))).toBe(true); }); }); describe('ownedSystemModuleIds (allow-list for hook-process-boundary D12)', () => { let root: string; let db: DbClient; const addModule = (id: string) => db.$client.run( "INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}','/tmp/'||?)", [id, id, id], ); const addInstance = (parent: string, submodule: string, key: string) => { const id = deriveInstanceModuleId(parent, submodule, key); addModule(id); db.$client.run( 'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)', [id, parent, submodule, key], ); return id; }; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'celilo-owned-')); db = createDbClient({ path: join(root, 'celilo.db') }); for (const id of ['forgejo', 'caddy']) addModule(id); }); afterEach(async () => { db.$client.close(); await rm(root, { recursive: true, force: true }); }); test('a module owning no instances gets only itself', () => { expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']); }); test('a parent gets itself and every instance it owns', () => { const a = addInstance('forgejo', 'runner', 'proj-1'); const b = addInstance('forgejo', 'runner', 'proj-2'); const owned = ownedSystemModuleIds('forgejo', db); expect(owned).toContain('forgejo'); expect(owned).toContain(a); expect(owned).toContain(b); expect(owned).toHaveLength(3); }); test("a parent does not reach another parent's instances", () => { addInstance('forgejo', 'runner', 'proj-1'); expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']); }); // The whole point: nesting is refused at import, so ownership is one level // deep and this stays one indexed lookup rather than a walk. test('an instance owns nobody, so the answer is one level and terminates', () => { const instance = addInstance('forgejo', 'runner', 'proj-1'); expect(ownedSystemModuleIds(instance, db)).toEqual([instance]); }); test('a module that does not exist owns only the name it was asked about', () => { expect(ownedSystemModuleIds('never-installed', db)).toEqual(['never-installed']); }); }); describe('a hook writing into its module root stays in its own instance (task 3.6)', () => { let root: string; let submodulePath: string; let first: string; let second: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'celilo-hook-writes-')); submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner'); await mkdir(join(submodulePath, 'scripts'), { recursive: true }); await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n'); first = join(root, deriveInstanceModuleId('forgejo', 'runner', 'proj-1')); second = join(root, deriveInstanceModuleId('forgejo', 'runner', 'proj-2')); await buildInstanceLinkFarm({ instancePath: first, submodulePath }); await buildInstanceLinkFarm({ instancePath: second, submodulePath }); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); // This is the failure the symlink farm exists to prevent, and the reason // `sourcePath` was NOT pointed at the shared submodule directory. Hooks run // with the module directory as their root and write into it: browser.ts does // join(modulePath, 'screenshots', key), and `module build` uses it as cwd. test('two instances writing screenshots do not land on top of each other', async () => { for (const [instance, content] of [ [first, 'first'], [second, 'second'], ] as const) { await mkdir(join(instance, 'screenshots'), { recursive: true }); await writeFile(join(instance, 'screenshots', 'failure.png'), content); } expect(await readFile(join(first, 'screenshots', 'failure.png'), 'utf-8')).toBe('first'); expect(await readFile(join(second, 'screenshots', 'failure.png'), 'utf-8')).toBe('second'); }); test('cookies.json is per instance, not shared', async () => { await writeFile(join(first, 'cookies.json'), '["first"]'); await writeFile(join(second, 'cookies.json'), '["second"]'); expect(await readFile(join(first, 'cookies.json'), 'utf-8')).toBe('["first"]'); expect(await readFile(join(second, 'cookies.json'), 'utf-8')).toBe('["second"]'); }); test('neither write reaches the shared submodule source', async () => { await mkdir(join(first, 'screenshots'), { recursive: true }); await writeFile(join(first, 'screenshots', 'failure.png'), 'first'); await writeFile(join(first, 'cookies.json'), '["first"]'); expect(existsSync(join(submodulePath, 'screenshots'))).toBe(false); expect(existsSync(join(submodulePath, 'cookies.json'))).toBe(false); }); // A converge must not sweep away what a hook wrote. `generated/` and the // derived outputs are the instance's, not the farm's to manage. test('rebuilding the farm leaves what the hooks wrote alone', async () => { await writeFile(join(first, 'cookies.json'), '["first"]'); await writeFile(join(first, 'generated', 'terraform.tfstate'), '{}'); await buildInstanceLinkFarm({ instancePath: first, submodulePath }); expect(await readFile(join(first, 'cookies.json'), 'utf-8')).toBe('["first"]'); expect(existsSync(join(first, 'generated', 'terraform.tfstate'))).toBe(true); }); });