import 'dart:io'; import 'package:test/test.dart'; import 'package:oto/oto/agent/agent_config.dart'; const bootstrapAssetMatrix = >{ 'linux': ['oto-linux-x64.tar.gz', 'oto-linux-arm64.tar.gz'], 'macos': ['oto-macos-x64.tar.gz', 'oto-macos-arm64.tar.gz'], 'windows': ['oto-windows-x64.zip', 'oto-windows-arm64.zip'], }; Iterable allSupportedBootstrapAssets() sync* { for (final assets in bootstrapAssetMatrix.values) { yield* assets; } } class _BootstrapRunResult { final int exitCode; final String stdout; final String stderr; /// fake curl에 요청된 asset basename. curl이 호출되지 않으면 null. final String? requestedAsset; final String? configContent; const _BootstrapRunResult({ required this.exitCode, required this.stdout, required this.stderr, this.requestedAsset, this.configContent, }); } /// fake uname/curl/oto로 bootstrap script를 실행해 결과를 반환한다. /// /// [unameS]: `uname -s` 반환값 (e.g. "Linux", "Darwin", "FreeBSD") /// [unameM]: `uname -m` 반환값 (e.g. "x86_64", "aarch64", "s390x") /// [provideTarGz]: true이면 curl이 fake tar.gz를 -o 경로에 제공한다. /// unsupported 케이스처럼 curl 미호출이 예상되면 false로 설정한다. Future<_BootstrapRunResult> _runBootstrapWithFakeUname( File scriptFile, { required String unameS, required String unameM, bool provideTarGz = true, String serverUrl = 'https://server.example.com', String socketUrl = 'tcp://socket.example.com:18080', String releaseBaseUrl = 'https://example.com/release', String agentId = 'test-agent', String token = 'test-token-secret', int fakeOtoVersionExitCode = 0, bool passConnectionArgs = true, bool passAgentId = true, }) async { final tmpDir = await Directory.systemTemp.createTemp('oto_matrix_test_'); try { final tempHome = Directory('${tmpDir.path}/home'); await tempHome.create(recursive: true); final fakeBinDir = Directory('${tmpDir.path}/bin'); await fakeBinDir.create(recursive: true); // fake uname: -s → unameS, -m → unameM, 그 외는 시스템 uname 위임 final fakeUname = File('${fakeBinDir.path}/uname'); await fakeUname.writeAsString('''#!/bin/sh case "\$1" in -s) echo "$unameS" ;; -m) echo "$unameM" ;; *) /usr/bin/uname "\$@" ;; esac '''); await Process.run('chmod', ['+x', fakeUname.path]); final fakeHostname = File('${fakeBinDir.path}/hostname'); await fakeHostname.writeAsString('''#!/bin/sh echo "fake-device" '''); await Process.run('chmod', ['+x', fakeHostname.path]); // curl 호출 시 요청된 asset basename을 기록할 파일 final assetLog = File('${tmpDir.path}/requested_asset.txt'); // supported 조합에서 curl이 제공할 fake tar.gz (oto binary 포함) String fakeTarGzPath = ''; if (provideTarGz) { final fakeOtoSrc = Directory('${tmpDir.path}/fake_oto_src'); await fakeOtoSrc.create(recursive: true); final fakeOtoBin = File('${fakeOtoSrc.path}/oto'); await fakeOtoBin.writeAsString('''#!/bin/sh if [ "\$1" = "--version" ]; then echo "fake-oto-version" exit $fakeOtoVersionExitCode fi while true; do sleep 1; done '''); await Process.run('chmod', ['+x', fakeOtoBin.path]); final tarGz = File('${tmpDir.path}/fake.tar.gz'); await Process.run('tar', [ '-czf', tarGz.path, '-C', fakeOtoSrc.path, 'oto', ]); fakeTarGzPath = tarGz.path; } // fake curl: URL basename을 assetLog에 기록하고, provideTarGz이면 -o 경로에 복사 final fakeCurl = File('${fakeBinDir.path}/curl'); final copyLine = provideTarGz ? 'if [ -n "\$out_file" ]; then cp "$fakeTarGzPath" "\$out_file"; fi' : ''; await fakeCurl.writeAsString('''#!/bin/sh url="" out_file="" while [ \$# -gt 0 ]; do case "\$1" in -o) out_file="\$2"; shift 2 ;; -*) shift ;; *) url="\$1"; shift ;; esac done asset=\$(basename "\$url") echo "\$asset" >> "${assetLog.path}" $copyLine '''); await Process.run('chmod', ['+x', fakeCurl.path]); final env = Map.from(Platform.environment) ..['PATH'] = '${fakeBinDir.path}:${Platform.environment['PATH'] ?? ''}' ..['HOME'] = tempHome.path; if (!passConnectionArgs) { env['OTO_BOOTSTRAP_SERVER_URL'] = serverUrl; env['OTO_BOOTSTRAP_SOCKET_URL'] = socketUrl; env['OTO_BOOTSTRAP_RELEASE_BASE_URL'] = releaseBaseUrl; } final args = [ scriptFile.absolute.path, if (passConnectionArgs) ...[ '--server-url', serverUrl, ], if (passAgentId) ...[ '--agent-id', agentId, ], '--token', token, if (passConnectionArgs) ...[ '--release-base-url', releaseBaseUrl, ], ]; final result = await Process.run('bash', args, environment: env); String? requestedAsset; if (await assetLog.exists()) { final raw = (await assetLog.readAsString()).trim(); if (raw.isNotEmpty) requestedAsset = raw.split('\n').first.trim(); } final configFile = File('${tempHome.path}/.oto/agent/config.yaml'); final configContent = await configFile.exists() ? await configFile.readAsString() : null; // background로 실행된 fake oto 프로세스 종료 final pidFile = File('${tempHome.path}/.oto/agent/oto-agent.pid'); if (await pidFile.exists()) { final pidStr = (await pidFile.readAsString()).trim(); final pid = int.tryParse(pidStr); if (pid != null) Process.killPid(pid); } return _BootstrapRunResult( exitCode: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString(), requestedAsset: requestedAsset, configContent: configContent, ); } finally { await tmpDir.delete(recursive: true); } } void main() { final scriptFile = File('assets/script/shell/oto_agent_bootstrap.sh'); final rootReadmeFile = File('../../README.md'); group('Linux Bootstrap Script Contract Tests', () { late String content; setUpAll(() async { expect( await scriptFile.exists(), isTrue, reason: 'Bootstrap script file must exist', ); content = await scriptFile.readAsString(); }); test('should contain required flags and optional connection overrides', () { expect(content, contains('--token')); expect(content, contains('--agent-id')); expect(content, isNot(contains('--enrollment-token'))); expect(content, contains('--server-url')); expect(content, contains('--release-base-url')); }); test('should contain optional/default flags and paths', () { expect(content, contains('--agent-alias')); expect(content, contains('--install-dir')); expect(content, contains('--config-path')); expect(content, contains('--workspace-root')); expect(content, contains('--log-dir')); expect(content, contains('--no-background')); expect(content, contains('.oto/bin')); expect(content, contains('.oto/agent/config.yaml')); }); test( 'should support all Unix asset names via dynamic os/arch combination', () { expect(content, contains('"linux"')); expect(content, contains('"macos"')); expect(content, contains('"x64"')); expect(content, contains('"arm64"')); expect(content, contains(r'oto-${os_name}-${arch_name}.tar.gz')); }, ); test('should detect OS with uname -s and arch with uname -m', () { expect(content, contains('uname -s')); expect(content, contains('uname -m')); expect(content, contains('Linux')); expect(content, contains('Darwin')); }); test('should fail with unsupported OS message', () { expect(content, contains('Unsupported OS:')); }); test('should fail with unsupported architecture message', () { expect(content, contains('Unsupported architecture:')); }); test( 'should keep README bootstrap asset matrix aligned with test fixture', () async { expect( await rootReadmeFile.exists(), isTrue, reason: 'Root README must document bootstrap asset names', ); final readmeContent = await rootReadmeFile.readAsString(); for (final asset in allSupportedBootstrapAssets()) { expect(readmeContent, contains(asset)); } }, ); test('should write expected keys to configuration yaml', () { expect(content, contains('agent:')); expect(content, contains('id:')); expect(content, contains('alias:')); expect(content, contains('enrollment_token:')); expect(content, contains('server:')); expect(content, contains('url:')); expect(content, contains('runtime:')); expect(content, contains('install_dir:')); expect(content, contains('workspace_root:')); expect(content, contains('log_dir:')); }); test('should contain agent runtime commands and background behavior', () { expect(content, contains('agent run')); expect(content, contains('--config')); expect(content, contains('nohup')); expect(content, contains('oto-agent.pid')); }); test('should contain binary verification step', () { expect(content, contains('Verifying OTO binary...')); expect(content, contains('--version')); expect(content, contains('Error: OTO binary verification failed.')); }); }); group('Bootstrap Script OS/Arch Matrix Tests', () { const testCases = [ (unameS: 'Linux', unameM: 'x86_64', expected: 'oto-linux-x64.tar.gz'), (unameS: 'Linux', unameM: 'aarch64', expected: 'oto-linux-arm64.tar.gz'), (unameS: 'Darwin', unameM: 'x86_64', expected: 'oto-macos-x64.tar.gz'), (unameS: 'Darwin', unameM: 'arm64', expected: 'oto-macos-arm64.tar.gz'), ]; for (final tc in testCases) { test('selects ${tc.expected} for ${tc.unameS}/${tc.unameM}', () async { final r = await _runBootstrapWithFakeUname( scriptFile, unameS: tc.unameS, unameM: tc.unameM, provideTarGz: true, ); expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}'); expect(r.requestedAsset, tc.expected); }); } test( 'uses served bootstrap defaults so command only needs token', () async { final r = await _runBootstrapWithFakeUname( scriptFile, unameS: 'Darwin', unameM: 'arm64', provideTarGz: true, passConnectionArgs: false, passAgentId: false, ); expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}'); expect(r.requestedAsset, 'oto-macos-arm64.tar.gz'); expect(r.configContent, matches(RegExp(r'id: "oto-[0-9a-f]{32}"'))); expect(r.configContent, matches(RegExp(r'alias: ".+"'))); expect(r.configContent, contains('url: "https://server.example.com"')); expect( r.configContent, contains('socket_url: "tcp://socket.example.com:18080"'), ); }, ); test('fails before download for unsupported OS (FreeBSD)', () async { const token = 'secret-os-token-789'; final r = await _runBootstrapWithFakeUname( scriptFile, unameS: 'FreeBSD', unameM: 'x86_64', provideTarGz: false, token: token, ); expect(r.exitCode, isNot(0)); expect(r.stderr, contains('Unsupported OS:')); expect( r.requestedAsset, isNull, reason: 'download must not start for unsupported OS', ); expect(r.stdout, isNot(contains(token))); expect(r.stderr, isNot(contains(token))); }); test( 'fails before download for unsupported arch on Linux (s390x)', () async { const token = 'secret-arch-token-456'; final r = await _runBootstrapWithFakeUname( scriptFile, unameS: 'Linux', unameM: 's390x', provideTarGz: false, token: token, ); expect(r.exitCode, isNot(0)); expect(r.stderr, contains('Unsupported architecture:')); expect( r.requestedAsset, isNull, reason: 'download must not start for unsupported arch', ); expect(r.stdout, isNot(contains(token))); expect(r.stderr, isNot(contains(token))); }, ); test( 'fails before download for unsupported arch on macOS (i386)', () async { const token = 'secret-arch-token-123'; final r = await _runBootstrapWithFakeUname( scriptFile, unameS: 'Darwin', unameM: 'i386', provideTarGz: false, token: token, ); expect(r.exitCode, isNot(0)); expect(r.stderr, contains('Unsupported architecture:')); expect( r.requestedAsset, isNull, reason: 'download must not start for unsupported arch', ); expect(r.stdout, isNot(contains(token))); expect(r.stderr, isNot(contains(token))); }, ); test('fails when OTO binary verification fails', () async { const token = 'secret-verification-token-999'; final r = await _runBootstrapWithFakeUname( scriptFile, unameS: 'Linux', unameM: 'x86_64', provideTarGz: true, token: token, fakeOtoVersionExitCode: 1, ); expect(r.exitCode, isNot(0)); expect(r.stderr, contains('Error: OTO binary verification failed.')); expect(r.stdout, isNot(contains(token))); expect(r.stderr, isNot(contains(token))); }); test('escapes YAML double-quoted config values', () async { const agentId = r'agent-"quoted-\id'; const token = r'token-"quoted-\secret'; final r = await _runBootstrapWithFakeUname( scriptFile, unameS: 'Linux', unameM: 'x86_64', provideTarGz: true, agentId: agentId, token: token, ); expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}'); expect(r.configContent, isNotNull); expect(r.configContent!, contains(r'id: "agent-\"quoted-\\id"')); expect( r.configContent!, contains(r'enrollment_token: "token-\"quoted-\\secret"'), ); final config = AgentConfig.fromYamlContent(r.configContent!); expect(config.agent.id, agentId); expect(config.agent.enrollmentToken, token); }); }); group('Bootstrap Config Fixture', () { // Mirrors the exact YAML template the bootstrap script writes at runtime // (oto_agent_bootstrap.sh lines 141-152). If the script or AgentConfig // parser ever diverge on key names, this test will catch it. const bootstrapScriptGeneratedYaml = ''' agent: id: "agent-test-001" alias: "test-agent-alias" enrollment_token: "test-enrollment-token" server: url: "https://oto-core.example.com" runtime: install_dir: "/home/user/.oto/bin" workspace_root: "/home/user/.oto/workspace" log_dir: "/home/user/.oto/agent/log" '''; const bootstrapScriptGeneratedYamlEmptyAlias = ''' agent: id: "agent-test-002" alias: "" enrollment_token: "enrollment-token-xyz" server: url: "https://oto-core.example.com" runtime: install_dir: "/home/user/.oto/bin" workspace_root: "/home/user/.oto/workspace" log_dir: "/home/user/.oto/agent/log" '''; test('AgentConfig parses bootstrap-generated server section config', () { final config = AgentConfig.fromYamlContent(bootstrapScriptGeneratedYaml); expect(config.agent.id, 'agent-test-001'); expect(config.agent.alias, 'test-agent-alias'); expect(config.agent.enrollmentToken, 'test-enrollment-token'); expect(config.server.url, 'https://oto-core.example.com'); expect(config.runtime.installDir, '/home/user/.oto/bin'); expect(config.runtime.workspaceRoot, '/home/user/.oto/workspace'); expect(config.runtime.logDir, '/home/user/.oto/agent/log'); }); test('AgentConfig parses bootstrap-generated config with empty alias', () { final config = AgentConfig.fromYamlContent( bootstrapScriptGeneratedYamlEmptyAlias, ); expect(config.agent.id, 'agent-test-002'); expect(config.agent.alias, ''); expect(config.agent.enrollmentToken, 'enrollment-token-xyz'); expect(config.server.url, 'https://oto-core.example.com'); expect(config.runtime.installDir, '/home/user/.oto/bin'); expect(config.runtime.workspaceRoot, '/home/user/.oto/workspace'); expect(config.runtime.logDir, '/home/user/.oto/agent/log'); }); }); group('Linux Bootstrap Script Functional & Regressions Tests', () { test('should fail when release base URL is not HTTPS', () async { final result = await Process.run('bash', [ scriptFile.absolute.path, '--server-url', 'https://server.example.com', '--token', 'secret-token-123', '--release-base-url', 'http://example.com/release', // HTTP URL ]); expect(result.exitCode, isNot(0)); expect( result.stderr.toString(), contains('--release-base-url must use https://'), ); expect( result.stdout.toString(), isNot(contains('Downloading OTO agent')), ); // enrollment token이 출력에 노출되지 않는지 확인 expect(result.stdout.toString(), isNot(contains('secret-token-123'))); expect(result.stderr.toString(), isNot(contains('secret-token-123'))); }); test( 'should write PID to standard agent state path when custom config-path is specified', () async { final tempDir = await Directory.systemTemp.createTemp( 'oto_bootstrap_test_', ); try { final tempHome = Directory('${tempDir.path}/home'); await tempHome.create(recursive: true); // 1. 가짜 OTO 실행 파일 생성 final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src'); await fakeOtoSource.create(recursive: true); final fakeOtoFile = File('${fakeOtoSource.path}/oto'); await fakeOtoFile.writeAsString('''#!/bin/sh if [ "\$1" = "--version" ]; then echo "fake-oto-version" exit 0 fi while true; do sleep 1; done '''); await Process.run('chmod', ['+x', fakeOtoFile.path]); // 2. 가짜 tar.gz 아카이브 생성 final fakeTarGz = File('${tempDir.path}/oto-linux-x64.tar.gz'); await Process.run('tar', [ '-czf', fakeTarGz.path, '-C', fakeOtoSource.path, 'oto', ]); // 3. 가짜 curl 스크립트 생성 final fakeBinDir = Directory('${tempDir.path}/bin'); await fakeBinDir.create(recursive: true); final fakeCurl = File('${fakeBinDir.path}/curl'); await fakeCurl.writeAsString('''#!/bin/sh out_file="" while [ \$# -gt 0 ]; do if [ "\$1" = "-o" ]; then out_file="\$2" break fi shift done if [ -n "\$out_file" ]; then cp "${fakeTarGz.path}" "\$out_file" fi '''); await Process.run('chmod', ['+x', fakeCurl.path]); // 4. custom config path 지정 final customConfigDir = Directory('${tempDir.path}/custom_config'); await customConfigDir.create(recursive: true); final customConfigPath = '${customConfigDir.path}/my-config.yaml'; // PATH 환경변수에 fakeBinDir 등록, HOME을 tempHome으로 설정 final env = Map.from(Platform.environment); env['PATH'] = '${fakeBinDir.path}:${env['PATH']}'; env['HOME'] = tempHome.path; final result = await Process.run('bash', [ scriptFile.absolute.path, '--server-url', 'https://server.example.com', '--token', 'secret-token-123', '--release-base-url', 'https://example.com/release', '--config-path', customConfigPath, ], environment: env); expect( result.exitCode, 0, reason: 'Script failed with: ${result.stderr}', ); // 5. 검증 final configFile = File(customConfigPath); expect(await configFile.exists(), isTrue); final expectedPidFile = File( '${tempHome.path}/.oto/agent/oto-agent.pid', ); expect( await expectedPidFile.exists(), isTrue, reason: 'PID file should exist in standard agent state path', ); final unexpectedPidFile = File( '${customConfigDir.path}/oto-agent.pid', ); expect( await unexpectedPidFile.exists(), isFalse, reason: 'PID file should not exist in custom config path directory', ); final pidStr = await expectedPidFile.readAsString(); final pid = int.tryParse(pidStr.trim()); if (pid != null) { Process.killPid(pid); } } finally { await tempDir.delete(recursive: true); } }, ); test('should support --edge-url as alias compatibility', () async { final tempDir = await Directory.systemTemp.createTemp( 'oto_bootstrap_alias_test_', ); try { final tempHome = Directory('${tempDir.path}/home'); await tempHome.create(recursive: true); // 1. 가짜 OTO 실행 파일 생성 final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src'); await fakeOtoSource.create(recursive: true); final fakeOtoFile = File('${fakeOtoSource.path}/oto'); await fakeOtoFile.writeAsString('''#!/bin/sh if [ "\$1" = "--version" ]; then echo "fake-oto-version" exit 0 fi while true; do sleep 1; done '''); await Process.run('chmod', ['+x', fakeOtoFile.path]); // 2. 가짜 tar.gz 아카이브 생성 final fakeTarGz = File('${tempDir.path}/oto-linux-x64.tar.gz'); await Process.run('tar', [ '-czf', fakeTarGz.path, '-C', fakeOtoSource.path, 'oto', ]); // 3. 가짜 curl 스크립트 생성 final fakeBinDir = Directory('${tempDir.path}/bin'); await fakeBinDir.create(recursive: true); final fakeCurl = File('${fakeBinDir.path}/curl'); await fakeCurl.writeAsString('''#!/bin/sh out_file="" while [ \$# -gt 0 ]; do if [ "\$1" = "-o" ]; then out_file="\$2" break fi shift done if [ -n "\$out_file" ]; then cp "${fakeTarGz.path}" "\$out_file" fi '''); await Process.run('chmod', ['+x', fakeCurl.path]); final customConfigPath = '${tempDir.path}/alias-config.yaml'; final env = Map.from(Platform.environment); env['PATH'] = '${fakeBinDir.path}:${env['PATH']}'; env['HOME'] = tempHome.path; final result = await Process.run('bash', [ scriptFile.absolute.path, '--edge-url', 'https://edge.example.com', '--token', 'secret-token-123', '--release-base-url', 'https://example.com/release', '--config-path', customConfigPath, ], environment: env); expect( result.exitCode, 0, reason: 'Script failed with: \${result.stderr}', ); final configFile = File(customConfigPath); expect(await configFile.exists(), isTrue); final configContent = await configFile.readAsString(); expect(configContent, contains('server:')); expect(configContent, contains('url: "https://edge.example.com"')); final expectedPidFile = File( '${tempHome.path}/.oto/agent/oto-agent.pid', ); expect(await expectedPidFile.exists(), isTrue); final pidStr = await expectedPidFile.readAsString(); final pid = int.tryParse(pidStr.trim()); if (pid != null) { Process.killPid(pid); } } finally { await tempDir.delete(recursive: true); } }); }); }