import { describe, it, expect, jest, beforeEach } from '@jest/globals'; const mockExecFn = jest.fn< (cmd: string, opts?: unknown) => Promise<{ code: number; stdout: string; stderr: string }> >(); jest.unstable_mockModule('../util/exec.js', () => ({ exec: mockExecFn, throwIfFailed: (output: any, message: any) => { if (output.code !== 0) { throw new Error( typeof message === 'function' ? message(output) : message, ); } return output; }, })); const { AcaService } = await import('./aca.service.js'); function makeService(acaEnvOverrides?: Partial<{ subscription: string; resourceGroup: string; environment: string; app: string; }>) { const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), }; const acaConfig = { subscription: 'sub-123', resourceGroup: 'my-rg', environment: 'my-env', app: 'my-app', ...acaEnvOverrides, }; const environmentService = { environment: { platform: 'aca' as const, name: 'test', aca: acaConfig, }, }; const sqlService = { databaseConfig: null as any, }; return new AcaService(logger as any, environmentService as any, sqlService as any); } describe('AcaService', () => { it('is exported as a class', () => { expect(AcaService).toBeDefined(); expect(typeof AcaService).toBe('function'); }); describe('az()', () => { let service: InstanceType; beforeEach(() => { mockExecFn.mockReset(); service = makeService(); }); it('includes --subscription in the az command', async () => { mockExecFn.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); await service.az('some command'); const cmd = mockExecFn.mock.calls[0][0] as string; expect(cmd).toMatch(/--subscription\s+sub-123/); }); it('starts the command with "az "', async () => { mockExecFn.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); await service.az('containerapp list'); const cmd = mockExecFn.mock.calls[0][0] as string; expect(cmd).toMatch(/^az containerapp list/); }); it('returns the exec result', async () => { mockExecFn.mockResolvedValueOnce({ code: 0, stdout: 'some output', stderr: '' }); const result = await service.az('containerapp list'); expect(result).toEqual({ code: 0, stdout: 'some output', stderr: '' }); }); }); describe('azExec()', () => { let service: InstanceType; beforeEach(() => { mockExecFn.mockReset(); service = makeService(); }); it('runs the az command under a PTY via `script` (az exec aborts on a non-TTY stdin)', async () => { mockExecFn.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); await service.azExec('containerapp exec -n my-app'); const cmd = mockExecFn.mock.calls[0][0] as string; expect(cmd).toMatch(/^script -qec /); expect(cmd).toMatch(/\/dev\/null$/); expect(cmd).toContain('az containerapp exec -n my-app'); expect(cmd).toContain('--subscription sub-123'); }); it('retries a transient exec connection error, then returns success', async () => { mockExecFn .mockResolvedValueOnce({ code: 1, stdout: '', stderr: 'WebSocketBadStatusException: Handshake status 404 Not Found', }) .mockResolvedValueOnce({ code: 0, stdout: 'ok', stderr: '' }); const result = await service.azExec('containerapp exec -n my-app'); expect(result.stdout).toBe('ok'); expect(mockExecFn).toHaveBeenCalledTimes(2); }); it('does NOT retry a genuine (non-transient) command failure', async () => { mockExecFn.mockResolvedValue({ code: 1, stdout: '', stderr: 'psql: FATAL: password authentication failed', }); await service.azExec('containerapp exec -n my-app'); expect(mockExecFn).toHaveBeenCalledTimes(1); }); }); describe('setup()', () => { let service: InstanceType; let sqlService: { databaseConfig: any }; beforeEach(() => { mockExecFn.mockReset(); sqlService = { databaseConfig: null as any }; const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), }; const environmentService = { environment: { platform: 'aca' as const, name: 'test', aca: { subscription: 'sub-123', resourceGroup: 'my-rg', environment: 'my-env', app: 'my-app', }, }, }; service = new AcaService(logger as any, environmentService as any, sqlService as any); }); it('calls az containerapp show to get env vars', async () => { const envVars = [ { name: 'DB_HOST', value: 'db.example.com' }, { name: 'DB_PORT', value: '5432' }, { name: 'DB_DATABASE', value: 'mydb' }, { name: 'DB_USER', value: 'admin' }, { name: 'DB_PASSWORD', value: 's3cret' }, { name: 'DB_CLIENT', value: 'pg' }, ]; mockExecFn.mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }); await service.setup(); const cmd = mockExecFn.mock.calls[0][0] as string; expect(cmd).toMatch(/az containerapp show/); expect(cmd).toMatch(/-n my-app/); expect(cmd).toMatch(/-g my-rg/); }); it('throws with az stderr when containerapp show fails (not a JSON parse error)', async () => { mockExecFn.mockResolvedValue({ code: 1, stdout: '', stderr: 'ERROR: AADSTS70043: The refresh token has expired', }); await expect(service.setup()).rejects.toThrow(/AADSTS70043/); await expect(service.setup()).rejects.not.toThrow(/JSON/); }); it('parses DB env vars into a DatabaseConfig and assigns to sqlService', async () => { const envVars = [ { name: 'DB_HOST', value: 'db.example.com' }, { name: 'DB_PORT', value: '5432' }, { name: 'DB_DATABASE', value: 'mydb' }, { name: 'DB_USER', value: 'admin' }, { name: 'DB_PASSWORD', value: 's3cret' }, { name: 'DB_CLIENT', value: 'pg' }, ]; mockExecFn.mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }); await service.setup(); expect(sqlService.databaseConfig).toMatchObject({ host: 'db.example.com', port: '5432', name: 'mydb', user: 'admin', password: 's3cret', client: 'pg', }); }); it('maps DB_FILENAME to filename in DatabaseConfig', async () => { const envVars = [ { name: 'DB_CLIENT', value: 'sqlite3' }, { name: 'DB_FILENAME', value: '/data/database.db' }, { name: 'DB_HOST', value: '' }, { name: 'DB_PORT', value: '' }, { name: 'DB_DATABASE', value: '' }, { name: 'DB_USER', value: '' }, { name: 'DB_PASSWORD', value: '' }, ]; mockExecFn.mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }); await service.setup(); expect(sqlService.databaseConfig).toMatchObject({ client: 'sqlite3', filename: '/data/database.db', }); }); it('resolves secretRef env vars (e.g. DB_PASSWORD) from the app secrets', async () => { const envVars = [ { name: 'DB_HOST', value: 'db.example.com' }, { name: 'DB_PORT', value: '5432' }, { name: 'DB_DATABASE', value: 'mydb' }, { name: 'DB_USER', value: 'admin' }, { name: 'DB_PASSWORD', secretRef: 'db-password-secret' }, { name: 'DB_CLIENT', value: 'pg' }, ]; mockExecFn .mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }) .mockResolvedValueOnce({ code: 0, stdout: JSON.stringify([ { name: 'db-password-secret', value: 'resolved-pw' }, ]), stderr: '', }); await service.setup(); expect(sqlService.databaseConfig.password).toBe('resolved-pw'); // the secret list call must request the actual values const secretCall = mockExecFn.mock.calls[1][0] as string; expect(secretCall).toMatch(/containerapp secret list/); expect(secretCall).toMatch(/--show-values/); }); it('falls back to empty string when a secretRef cannot be resolved', async () => { const envVars = [ { name: 'DB_HOST', value: 'db.example.com' }, { name: 'DB_PASSWORD', secretRef: 'missing-secret' }, { name: 'DB_CLIENT', value: 'pg' }, ]; mockExecFn .mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }) .mockResolvedValueOnce({ code: 0, stdout: '[]', stderr: '' }); await expect(service.setup()).resolves.not.toThrow(); expect(sqlService.databaseConfig.password).toBe(''); }); it('tolerates missing optional fields (no client, no filename)', async () => { const envVars = [ { name: 'DB_HOST', value: 'db.example.com' }, { name: 'DB_PORT', value: '5432' }, { name: 'DB_DATABASE', value: 'mydb' }, { name: 'DB_USER', value: 'admin' }, { name: 'DB_PASSWORD', value: 's3cret' }, ]; mockExecFn.mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }); await service.setup(); expect(sqlService.databaseConfig.host).toBe('db.example.com'); expect(sqlService.databaseConfig.client).toBeUndefined(); expect(sqlService.databaseConfig.filename).toBeUndefined(); }); it('does not set client when DB_CLIENT is an empty string (secretRef fallback)', async () => { const envVars = [ { name: 'DB_HOST', value: 'db.example.com' }, { name: 'DB_PORT', value: '5432' }, { name: 'DB_DATABASE', value: 'mydb' }, { name: 'DB_USER', value: 'admin' }, { name: 'DB_PASSWORD', value: 's3cret' }, // secretRef that resolves to empty → no client set { name: 'DB_CLIENT', secretRef: 'some-secret-ref' }, ]; mockExecFn .mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }) .mockResolvedValueOnce({ code: 0, stdout: '[]', stderr: '' }); await service.setup(); expect(sqlService.databaseConfig.client).toBeUndefined(); }); it('does not set filename when DB_FILENAME is an empty string (secretRef fallback)', async () => { const envVars = [ { name: 'DB_HOST', value: 'db.example.com' }, { name: 'DB_PORT', value: '5432' }, { name: 'DB_DATABASE', value: 'mydb' }, { name: 'DB_USER', value: 'admin' }, { name: 'DB_PASSWORD', value: 's3cret' }, { name: 'DB_CLIENT', value: 'pg' }, // secretRef that resolves to empty → no filename set { name: 'DB_FILENAME', secretRef: 'some-secret-ref' }, ]; mockExecFn .mockResolvedValueOnce({ code: 0, stdout: JSON.stringify(envVars), stderr: '' }) .mockResolvedValueOnce({ code: 0, stdout: '[]', stderr: '' }); await service.setup(); expect(sqlService.databaseConfig.filename).toBeUndefined(); }); }); describe('restartDirectus()', () => { let service: InstanceType; beforeEach(() => { mockExecFn.mockReset(); service = makeService(); }); it('looks up the active revision, then restarts it (revision restart requires --revision)', async () => { mockExecFn // containerapp show → latestRevisionName .mockResolvedValueOnce({ code: 0, stdout: 'directus-stage--abc123\n', stderr: '', }) // containerapp revision restart .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); await service.restartDirectus(); const showCmd = mockExecFn.mock.calls[0][0] as string; expect(showCmd).toContain('containerapp show'); expect(showCmd).toContain('latestRevisionName'); const restartCmd = mockExecFn.mock.calls[1][0] as string; expect(restartCmd).toContain('containerapp revision restart'); expect(restartCmd).toContain('--revision directus-stage--abc123'); }); it('does not throw when the revision cannot be determined (best effort)', async () => { mockExecFn.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); await expect(service.restartDirectus()).resolves.toBeUndefined(); // Only the show call ran; no restart attempted on an empty revision. expect(mockExecFn).toHaveBeenCalledTimes(1); }); }); describe('execInDirectus()', () => { let service: InstanceType; beforeEach(() => { mockExecFn.mockReset(); service = makeService(); }); it('runs the command in the Directus ACA app via az containerapp exec', async () => { mockExecFn.mockResolvedValueOnce({ code: 0, stdout: 'done', stderr: '' }); const result = await service.execInDirectus( 'node /directus/cli.js roles create --role r --admin', ); const cmd = mockExecFn.mock.calls[0][0] as string; // az containerapp exec is interactive (SSH-style); it is run under a PTY // via `script` so it does not abort on a non-TTY stdin. expect(cmd).toMatch(/^script -qec /); expect(cmd).toMatch(/\/dev\/null$/); expect(cmd).toContain('containerapp exec'); expect(cmd).toContain('-n my-app'); expect(cmd).toContain('-g my-rg'); // The command runs through a real shell (`/bin/sh -c`) but its spaces are // hidden as `${IFS}`: az word-splits `--command` on whitespace and execs // argv directly, so the script must arrive as ONE word and let the // in-container sh re-split it. A raw space in the command would shatter it. expect(cmd).toContain('/bin/sh -c '); expect(cmd).toContain( 'node${IFS}/directus/cli.js${IFS}roles${IFS}create${IFS}--role${IFS}r${IFS}--admin', ); expect(result).toEqual({ code: 0, stdout: 'done', stderr: '' }); }); it('returns the az result (does not throw on non-zero, mirrors AcaContainerService.execute)', async () => { mockExecFn.mockResolvedValueOnce({ code: 1, stdout: '', stderr: 'err' }); const result = await service.execInDirectus('node /directus/cli.js'); expect(result.code).toBe(1); }); }); });