/** * Integration tests for container services and machine pool CLI commands * * These tests verify end-to-end functionality of service and machine management */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { createDbClient } from '../db/client'; import { containerServices, machines } from '../db/schema'; import { runCli } from '../test-utils/cli'; import { type IntegrationTestContext, setupIntegrationTest } from '../test-utils/integration'; describe('Container Services and Machine Pool CLI Integration', () => { let ctx: IntegrationTestContext; beforeEach(async () => { ctx = await setupIntegrationTest(); // Create master key for encryption tests const fs = await import('node:fs/promises'); const { join } = await import('node:path'); const masterKeyPath = join(ctx.dataDir, 'master.key'); process.env.CELILO_MASTER_KEY_PATH = masterKeyPath; await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8'); }); afterEach(async () => { delete process.env.CELILO_MASTER_KEY_PATH; await ctx.cleanup(); }); describe('service commands', () => { it('service list shows empty list when no services', () => { const output = runCli(ctx.cli, 'service list'); expect(output).toContain('No container services configured'); }); it('service list shows all services', async () => { // Add services directly to database const db = createDbClient({ path: ctx.dbPath }); const fs = await import('node:fs/promises'); const { join } = await import('node:path'); const masterKeyPath = join(ctx.dataDir, 'master.key'); const masterKeyHex = await fs.readFile(masterKeyPath, 'utf8'); const masterKey = Buffer.from(masterKeyHex, 'hex'); const { encryptSecret } = await import('../secrets/encryption'); const proxmoxCreds = encryptSecret( JSON.stringify({ api_url: 'https://proxmox.local:8006', token_id: 'root@pam!celilo', token_secret: 'test-secret', }), masterKey, ); await db.insert(containerServices).values({ id: '550e8400-e29b-41d4-a716-446655440000', serviceId: 'test-proxmox', name: 'Test Proxmox', providerName: 'proxmox', zones: ['dmz', 'app'], apiCredentialsEncrypted: JSON.stringify(proxmoxCreds), providerConfig: {}, createdAt: new Date(), updatedAt: new Date(), }); const output = runCli(ctx.cli, 'service list'); expect(output).toContain('Test Proxmox'); expect(output).toContain('proxmox'); expect(output).toContain('dmz'); expect(output).toContain('Total: 1 service'); }); it('service remove deletes service', async () => { // Add service const db = createDbClient({ path: ctx.dbPath }); const fs = await import('node:fs/promises'); const { join } = await import('node:path'); const masterKeyPath = join(ctx.dataDir, 'master.key'); const masterKeyHex = await fs.readFile(masterKeyPath, 'utf8'); const masterKey = Buffer.from(masterKeyHex, 'hex'); const { encryptSecret } = await import('../secrets/encryption'); const creds = encryptSecret( JSON.stringify({ api_url: 'https://test.local', token_id: 'test', token_secret: 'test', }), masterKey, ); await db.insert(containerServices).values({ id: 'test-service-id', serviceId: 'service-to-remove', name: 'Service To Remove', providerName: 'proxmox', zones: ['dmz'], apiCredentialsEncrypted: JSON.stringify(creds), providerConfig: {}, createdAt: new Date(), updatedAt: new Date(), }); // Remove service with --force flag to skip confirmation const output = runCli(ctx.cli, 'service remove service-to-remove --force'); // The command's RESULT, which is what stdout carries. This used to assert // on the lowercase "removed" of the `celiloOutro` banner, which clack put // on stdout while putting the result on stderr; celilo#699 swapped those // so stdout is the data channel and the banner is chrome on stderr. expect(output).toContain('Removed service: service-to-remove'); // Verify deletion const services = await db.select().from(containerServices).all(); expect(services).toHaveLength(0); }); }); describe('machine commands', () => { it('machine list shows empty list when no machines', () => { const output = runCli(ctx.cli, 'machine list'); expect(output).toContain('No machines in pool'); }); it('machine list shows all machines', async () => { // Add machine directly to database const db = createDbClient({ path: ctx.dbPath }); const fs = await import('node:fs/promises'); const { join } = await import('node:path'); const masterKeyPath = join(ctx.dataDir, 'master.key'); const masterKeyHex = await fs.readFile(masterKeyPath, 'utf8'); const masterKey = Buffer.from(masterKeyHex, 'hex'); const { encryptSecret } = await import('../secrets/encryption'); const sshKey = encryptSecret('ssh-ed25519 AAAA...', masterKey); await db.insert(machines).values({ id: 'machine-id-1', hostname: 'test-rpi4', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'ubuntu', sshKeyEncrypted: JSON.stringify(sshKey), hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 }, createdAt: new Date(), updatedAt: new Date(), }); const output = runCli(ctx.cli, 'machine list'); expect(output).toContain('test-rpi4'); expect(output).toContain('internal'); expect(output).toContain('192.168.1.100'); expect(output).toContain('4 cores'); expect(output).toContain('Total: 1 machine'); }); it('machine list filters by zone', async () => { // Add machines in different zones const db = createDbClient({ path: ctx.dbPath }); const fs = await import('node:fs/promises'); const { join } = await import('node:path'); const masterKeyPath = join(ctx.dataDir, 'master.key'); const masterKeyHex = await fs.readFile(masterKeyPath, 'utf8'); const masterKey = Buffer.from(masterKeyHex, 'hex'); const { encryptSecret } = await import('../secrets/encryption'); const sshKey = encryptSecret('ssh-ed25519 AAAA...', masterKey); await db.insert(machines).values([ { id: 'machine-dmz', hostname: 'dmz-machine', zone: 'dmz', ipAddress: '10.0.10.100', sshUser: 'root', sshKeyEncrypted: JSON.stringify(sshKey), hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 }, createdAt: new Date(), updatedAt: new Date(), }, { id: 'machine-internal', hostname: 'internal-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'ubuntu', sshKeyEncrypted: JSON.stringify(sshKey), hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 }, createdAt: new Date(), updatedAt: new Date(), }, ]); const output = runCli(ctx.cli, 'machine list --zone dmz'); expect(output).toContain('dmz-machine'); expect(output).not.toContain('internal-machine'); }); it('machine remove deletes machine', async () => { // Add machine const db = createDbClient({ path: ctx.dbPath }); const fs = await import('node:fs/promises'); const { join } = await import('node:path'); const masterKeyPath = join(ctx.dataDir, 'master.key'); const masterKeyHex = await fs.readFile(masterKeyPath, 'utf8'); const masterKey = Buffer.from(masterKeyHex, 'hex'); const { encryptSecret } = await import('../secrets/encryption'); const sshKey = encryptSecret('ssh-ed25519 AAAA...', masterKey); await db.insert(machines).values({ id: 'machine-to-remove', hostname: 'remove-me', zone: 'dmz', ipAddress: '10.0.10.100', sshUser: 'root', sshKeyEncrypted: JSON.stringify(sshKey), hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 }, createdAt: new Date(), updatedAt: new Date(), }); // Remove machine with --force flag to skip confirmation const output = runCli(ctx.cli, 'machine remove remove-me --force'); // See the service-remove case above: stdout now carries the result, not // the outro banner (celilo#699). expect(output).toContain('Removed machine: remove-me'); // Verify deletion const machinesList = await db.select().from(machines).all(); expect(machinesList).toHaveLength(0); }); }); });