diff --git a/server/src/Console/Commands/InstallerJobStatus.php b/server/src/Console/Commands/InstallerJobStatus.php new file mode 100644 index 0000000..69ea7b2 --- /dev/null +++ b/server/src/Console/Commands/InstallerJobStatus.php @@ -0,0 +1,86 @@ +argument('id')) ? $this->argument('id') : ''); + $job = $id !== '' ? $jobs->find($id) : null; + + if ($job === null) { + $message = sprintf('Installer job %s was not found.', $id); + if ($this->option('json')) { + $this->line((string) json_encode(['error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $this->error($message); + } + + return self::FAILURE; + } + + if ($this->option('json')) { + $this->line((string) json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $this->describe($job); + } + + return self::SUCCESS; + } + + /** + * @param array $job + */ + protected function describe(array $job): void + { + $extension = is_array($job['extension'] ?? null) ? $job['extension'] : []; + $status = static::text($job['status'] ?? null, 'unknown'); + + $this->line(sprintf('Job %s: %s of %s', static::text($job['id'] ?? null), static::text($job['action'] ?? null, 'install'), static::text($extension['name'] ?? $extension['id'] ?? null, 'extension'))); + $this->line(sprintf('Status: %s', $status)); + if ($status === 'failed') { + $this->line(sprintf('Stage: %s (failed)', static::text($job['failed_stage'] ?? $job['stage'] ?? null, 'unknown'))); + } elseif (static::text($job['stage'] ?? null) !== '') { + $this->line(sprintf('Stage: %s', static::text($job['stage']))); + } + $this->line(sprintf('Progress: %d%%', is_numeric($job['progress'] ?? null) ? (int) $job['progress'] : 0)); + if ($status === 'queued' && is_int($job['position'] ?? null)) { + $this->line(sprintf('Position: %d job(s) ahead', $job['position'])); + } + if (static::text($job['message'] ?? null) !== '') { + $this->line(sprintf('Message: %s', static::text($job['message']))); + } + + $log = is_array($job['log'] ?? null) ? array_slice($job['log'], -self::LOG_TAIL) : []; + if ($log !== []) { + $this->line('Log:'); + foreach ($log as $line) { + $this->line(' ' . static::text($line)); + } + } + } + + protected static function text(mixed $value, string $default = ''): string + { + return is_scalar($value) && (string) $value !== '' ? (string) $value : $default; + } +} diff --git a/server/src/Console/Commands/RegisterInstance.php b/server/src/Console/Commands/RegisterInstance.php new file mode 100644 index 0000000..6b99274 --- /dev/null +++ b/server/src/Console/Commands/RegisterInstance.php @@ -0,0 +1,115 @@ +code(); + if ($code === null) { + return $this->refuse('Pass the registration code with exactly one of --code or --code-stdin.'); + } + + try { + $summary = $registration::register( + $code, + $this->stringOption('service-url'), + $this->stringOption('name'), + $this->stringOption('host'), + $this->stringOption('console-version') + ); + } catch (RegistryException $e) { + return $this->refuse($e->getMessage()); + } + + if ($this->option('json')) { + $this->line((string) json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $organization = is_array($summary['organization'] ?? null) ? $summary['organization'] : []; + $rotated = ($summary['rotated'] ?? false) === true; + $this->info(sprintf('%s %s (%s) with %s.', $rotated ? 'Re-registered' : 'Registered', static::text($summary['name'] ?? null, 'this instance'), static::text($summary['id'] ?? null, 'unknown id'), static::text($organization['name'] ?? $organization['slug'] ?? null, 'the registry organization'))); + if ($rotated) { + $this->line('Same instance on the service: its installs and licence seats are kept, and the previous token no longer works.'); + } + $this->line(sprintf('Registry Service: %s', static::text($summary['service_url'] ?? null))); + $this->line(sprintf('Instance token: %s (stored encrypted)', static::text($summary['token_hint'] ?? null, 'stored'))); + } + + return self::SUCCESS; + } + + /** + * The code from --code or stdin, trimmed; null unless exactly one of them was given. + */ + protected function code(): ?string + { + $option = $this->stringOption('code'); + $stdin = (bool) $this->option('code-stdin'); + if (($option !== null) === $stdin) { + return null; + } + + if ($option !== null) { + return $option; + } + + $stream = $this->input instanceof StreamableInputInterface ? $this->input->getStream() : null; + $stream ??= fopen('php://stdin', 'r'); + $code = is_resource($stream) ? stream_get_contents($stream) : false; + + return is_string($code) ? trim($code) : ''; + } + + protected function stringOption(string $name): ?string + { + $value = $this->option($name); + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + protected static function text(mixed $value, string $default = ''): string + { + return is_scalar($value) && (string) $value !== '' ? (string) $value : $default; + } + + protected function refuse(string $message): int + { + if ($this->option('json')) { + $this->line((string) json_encode(['error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $this->error($message); + } + + return self::FAILURE; + } +} diff --git a/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php b/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php index b6fe696..5b1328c 100644 --- a/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php +++ b/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php @@ -516,38 +516,20 @@ public function testConfig(AdminRequest $request) */ public function registerInstance(AdminRequest $request) { - $code = trim((string) $request->input('code', '')); - if ($code === '') { - return response()->error('Enter the registration code from your registry organization.', 422); - } - - $config = RegistryConfig::current(); - $serviceUrl = trim((string) $request->input('service_url', '')); - $serviceUrl = $serviceUrl !== '' ? RegistryConfig::normalizeUrl($serviceUrl) : $config->serviceUrl(); - if (!$serviceUrl) { - return response()->error('Set the Registry Service URL before registering this instance.', 422); - } - - $payload = InstanceRegistration::payload( - $code, - (string) ($request->input('name') ?: config('app.name', 'Fleetbase')), - $request->input('host') ?: config('app.url'), - $config->instanceKind(), - $request->input('console_version') ?: InstanceRegistration::detectConsoleVersion(config('fleetbase.console.path')), - InstanceRegistration::detectApiVersion(), - InstanceRegistration::previousToken($config, $serviceUrl) - ); - try { - $registration = (new RegistryClient($serviceUrl, null, RegistryConfig::V2, $config))->registerInstance($payload); + InstanceRegistration::register( + (string) $request->input('code', ''), + (string) $request->input('service_url', ''), + (string) $request->input('name', ''), + (string) $request->input('host', ''), + (string) $request->input('console_version', '') + ); } catch (RegistryServiceException $e) { return response()->error($e->getMessage(), $e->status() >= 400 && $e->status() < 500 ? 422 : 502); } catch (RegistryException $e) { return response()->error($e->getMessage(), 503); } - InstanceRegistration::store($registration, $serviceUrl); - return response()->json($this->configPayload(RegistryConfig::current())); } diff --git a/server/src/Providers/RegistryBridgeServiceProvider.php b/server/src/Providers/RegistryBridgeServiceProvider.php index 98d713d..d91c014 100644 --- a/server/src/Providers/RegistryBridgeServiceProvider.php +++ b/server/src/Providers/RegistryBridgeServiceProvider.php @@ -70,6 +70,8 @@ class RegistryBridgeServiceProvider extends CoreServiceProvider \Fleetbase\RegistryBridge\Console\Commands\InstallExtension::class, \Fleetbase\RegistryBridge\Console\Commands\UninstallExtension::class, \Fleetbase\RegistryBridge\Console\Commands\UpdateExtension::class, + \Fleetbase\RegistryBridge\Console\Commands\InstallerJobStatus::class, + \Fleetbase\RegistryBridge\Console\Commands\RegisterInstance::class, ]; /** diff --git a/server/src/Support/InstanceRegistration.php b/server/src/Support/InstanceRegistration.php index cdcbdc8..d5ec35c 100644 --- a/server/src/Support/InstanceRegistration.php +++ b/server/src/Support/InstanceRegistration.php @@ -4,6 +4,8 @@ use Composer\InstalledVersions; use Fleetbase\Models\Setting; +use Fleetbase\RegistryBridge\Exceptions\RegistryServiceException; +use Fleetbase\RegistryBridge\Exceptions\RegistryUnavailableException; use Illuminate\Contracts\Encryption\StringEncrypter; use Illuminate\Support\Arr; @@ -25,6 +27,46 @@ class InstanceRegistration public const SETTING_INSTANCE = 'registry-bridge.registry.instance'; + /** + * Exchange a registration code for an instance token and store it (the admin endpoint and `registry:register`). + * + * The service URL defaults to the configured one; name, host and console version default to the app name, + * the app URL and the console's package.json. An instance already registered with that service sends its + * current token, so the service rotates it instead of creating a second instance. + * + * @return array the stored instance summary (never the token) + * + * @throws RegistryServiceException with status 422 when the code or service URL is missing, or the service's status when it refuses the code + * @throws RegistryUnavailableException when the service cannot be reached + */ + public static function register(string $code, ?string $serviceUrl = null, ?string $name = null, ?string $host = null, ?string $consoleVersion = null): array + { + $code = trim($code); + if ($code === '') { + throw new RegistryServiceException('Enter the registration code from your registry organization.', 422); + } + + $config = RegistryConfig::current(); + $serviceUrl = $serviceUrl !== null && trim($serviceUrl) !== '' ? RegistryConfig::normalizeUrl(trim($serviceUrl)) : $config->serviceUrl(); + if (!$serviceUrl) { + throw new RegistryServiceException('Set the Registry Service URL before registering this instance.', 422); + } + + $payload = static::payload( + $code, + $name ?: (static::configString('app.name') ?? 'Fleetbase'), + $host ?: static::configString('app.url'), + $config->instanceKind(), + $consoleVersion ?: static::detectConsoleVersion(static::configString('fleetbase.console.path')), + static::detectApiVersion(), + static::previousToken($config, $serviceUrl) + ); + + $registration = (new RegistryClient($serviceUrl, null, RegistryConfig::V2, $config))->registerInstance($payload); + + return static::store($registration, $serviceUrl); + } + /** * Body for `POST /v1/instances/register`. * @@ -135,9 +177,9 @@ public static function store(array $registration, string $serviceUrl): array { $summary = static::instanceSummary($registration['instance'], $serviceUrl, $registration['token'], date(DATE_ATOM), ($registration['rotated'] ?? false) === true); - Setting::configure(static::SETTING_SERVICE_URL, $serviceUrl); - Setting::configure(static::SETTING_INSTANCE_TOKEN, static::encryptToken($registration['token'], app('encrypter'))); - Setting::configure(static::SETTING_INSTANCE, $summary); + static::configure(static::SETTING_SERVICE_URL, $serviceUrl); + static::configure(static::SETTING_INSTANCE_TOKEN, static::encryptToken($registration['token'], app('encrypter'))); + static::configure(static::SETTING_INSTANCE, $summary); config([ 'registry-bridge.registry.service_url' => $serviceUrl, @@ -151,6 +193,21 @@ public static function store(array $registration, string $serviceUrl): array return $summary; } + /** + * Write one setting (overridden in tests, which have no database). + */ + protected static function configure(string $key, mixed $value): void + { + Setting::configure($key, $value); + } + + protected static function configString(string $key): ?string + { + $value = config($key); + + return is_string($value) && $value !== '' ? $value : null; + } + /** * Forget the instance token and registration details (the service URL is kept). */ diff --git a/server/tests/Installer/InstallerCommandsTest.php b/server/tests/Installer/InstallerCommandsTest.php index 367c61a..3018ffd 100644 --- a/server/tests/Installer/InstallerCommandsTest.php +++ b/server/tests/Installer/InstallerCommandsTest.php @@ -1,5 +1,6 @@ container->instance(InstallerJobStore::class, $this->store); $this->container->instance(InstallerService::class, new InstallerService($queue, $this->store, $this->sandbox->releases, $this->sandbox->recorder, $this->sandbox->installer())); $this->artisan = function (array $input): array { $app = new Artisan($this->container, new Events($this->container), 'test'); $app->setAutoExit(false); - foreach ([InstallExtension::class, UninstallExtension::class, UpdateExtension::class] as $class) { + foreach ([InstallExtension::class, UninstallExtension::class, UpdateExtension::class, InstallerJobStatus::class] as $class) { $command = new $class(); $command->setLaravel($this->container); $app->add($command); @@ -124,3 +126,51 @@ public function runningUnitTests(): bool expect($uninstall)->toMatchArray(['action' => 'uninstall', 'company' => 'company-1', 'version' => null]); expect($update)->toMatchArray(['action' => 'update', 'version' => '1.2.0']); }); + +test('registry:job --json prints the job record with its queue position', function () { + ($this->artisan)(['command' => 'registry:install', 'extension' => 'extension_widgets']); + $first = $this->bus->dispatched[0]->jobId; + $this->store->update($first, ['status' => 'running', 'stage' => 'composer', 'progress' => 40, 'message' => 'Installing Composer package', 'log' => ['Resolving acme/widgets-api', 'Installing acme/widgets-api (1.0.0)']]); + $this->sandbox->releases->add(new Fleetbase\RegistryBridge\Support\Installer\Release(new Fleetbase\RegistryBridge\Support\Installer\ExtensionRef('ext-uuid-0', 'extension_zero', 'Zero'), '1.0.0', 'acme/zero', '1.0.0')); + ($this->artisan)(['command' => 'registry:install', 'extension' => 'extension_zero']); + $second = $this->bus->dispatched[1]->jobId; + + [$code, $output] = ($this->artisan)(['command' => 'registry:job', 'id' => $first, '--json' => true]); + [, $queuedOutput] = ($this->artisan)(['command' => 'registry:job', 'id' => $second, '--json' => true]); + + $job = json_decode($output, true); + expect($code)->toBe(0); + expect($job)->toBe($this->store->find($first)); + expect($job)->toMatchArray(['id' => $first, 'action' => 'install', 'status' => 'running', 'stage' => 'composer', 'progress' => 40, 'message' => 'Installing Composer package', 'position' => 0]); + expect($job['log'])->toBe(['Resolving acme/widgets-api', 'Installing acme/widgets-api (1.0.0)']); + expect(json_decode($queuedOutput, true))->toMatchArray(['id' => $second, 'status' => 'queued', 'position' => 1]); + expect($output)->toContain('"stage": "composer"'); +}); + +test('registry:job prints a summary with the stage and log tail', function () { + ($this->artisan)(['command' => 'registry:install', 'extension' => 'extension_widgets']); + $id = $this->bus->dispatched[0]->jobId; + $this->store->update($id, ['status' => 'failed', 'stage' => 'composer', 'failed_stage' => 'composer', 'progress' => 40, 'message' => 'composer require failed', 'log' => array_map(fn ($n) => 'line ' . $n, range(1, 12))]); + + [$code, $output] = ($this->artisan)(['command' => 'registry:job', 'id' => $id]); + + expect($code)->toBe(0); + expect($output)->toContain('Job ' . $id . ': install of Widgets') + ->toContain('Status: failed') + ->toContain('Stage: composer (failed)') + ->toContain('Progress: 40%') + ->toContain('Message: composer require failed') + ->toContain('line 3') + ->toContain('line 12') + ->not->toContain('line 2' . PHP_EOL); +}); + +test('registry:job exits non-zero for an unknown job, as JSON when asked', function () { + [$code, $output] = ($this->artisan)(['command' => 'registry:job', 'id' => 'missing-job', '--json' => true]); + [$textCode, $text] = ($this->artisan)(['command' => 'registry:job', 'id' => 'missing-job']); + + expect($code)->toBe(1); + expect(json_decode($output, true))->toBe(['error' => 'Installer job missing-job was not found.']); + expect($textCode)->toBe(1); + expect($text)->toContain('Installer job missing-job was not found.'); +}); diff --git a/server/tests/Support/RecordingInstanceRegistration.php b/server/tests/Support/RecordingInstanceRegistration.php new file mode 100644 index 0000000..6d83a66 --- /dev/null +++ b/server/tests/Support/RecordingInstanceRegistration.php @@ -0,0 +1,19 @@ + */ + public static array $settings = []; + + protected static function configure(string $key, mixed $value): void + { + static::$settings[$key] = $value; + } +} diff --git a/server/tests/Unit/RegisterInstanceCommandTest.php b/server/tests/Unit/RegisterInstanceCommandTest.php new file mode 100644 index 0000000..a99c47b --- /dev/null +++ b/server/tests/Unit/RegisterInstanceCommandTest.php @@ -0,0 +1,194 @@ +service = new RegistryServiceFake(); + RecordingInstanceRegistration::$settings = []; + + // Commands ask the application whether they run under tests (prompts setup). + $this->container = new class() extends Container { + public function runningUnitTests(): bool + { + return true; + } + }; + $this->container->instance('cache', $this->service->cache); + $this->container->instance(Factory::class, $this->service->http); + $this->container->instance('log', new Psr\Log\NullLogger()); + $this->container->instance('config', new Config([ + 'app' => ['name' => 'Acme Fleet', 'url' => 'https://fleet.example.test'], + 'registry-bridge' => ['registry' => ['instance_kind' => 'self_hosted']], + ])); + $this->container->instance('encrypter', new class() implements StringEncrypter { + public function encryptString($value) + { + return 'enc:' . base64_encode(strrev($value)); + } + + public function decryptString($payload) + { + return strrev((string) base64_decode(substr($payload, 4))); + } + }); + $this->container->instance(InstanceRegistration::class, new RecordingInstanceRegistration()); + Container::setInstance($this->container); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($this->container); + + $this->artisan = function (array $input, ?string $stdin = null): array { + $app = new Artisan($this->container, new Events($this->container), 'test'); + $app->setAutoExit(false); + $command = new RegisterInstance(); + $command->setLaravel($this->container); + $app->add($command); + + $input = new ArrayInput(['command' => 'registry:register', ...$input]); + if ($stdin !== null) { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $stdin); + rewind($stream); + $input->setStream($stream); + } + $output = new BufferedOutput(); + $code = $app->run($input, $output); + + return [$code, $output->fetch()]; + }; +}); + +afterEach(function () { + Container::setInstance(null); +}); + +test('registry:register --json stores the token encrypted and prints the summary without it', function () { + $this->service->on('POST', 'v1/instances/register', 'instances-register'); + + [$code, $output] = ($this->artisan)(['--code' => 'w9qa-sc8r-37jt-9pa8', '--service-url' => RegistryServiceFake::SERVICE_URL, '--json' => true]); + + $summary = json_decode($output, true); + $settings = RecordingInstanceRegistration::$settings; + $request = $this->service->requestsTo('POST', 'v1/instances/register')[0]; + expect($code)->toBe(0) + ->and($output)->not->toContain(RegistryServiceFake::INSTANCE_TOKEN) + ->and(array_keys($summary))->toBe(['id', 'name', 'host', 'kind', 'status', 'organization', 'service_url', 'token_hint', 'registered_at', 'rotated']) + ->and($summary['rotated'])->toBeFalse() + ->and($summary)->toMatchArray(['id' => 'inst_rsmdtgevid', 'service_url' => RegistryServiceFake::SERVICE_URL, 'token_hint' => 'flbi_…0000']) + ->and($summary['organization'])->toBe(['id' => 'org_qqymqcyvgv', 'slug' => 'registry-demo-buyer', 'name' => 'Registry Demo Buyer']) + ->and($request->data())->toMatchArray(['code' => 'W9QA-SC8R-37JT-9PA8', 'name' => 'Acme Fleet', 'host' => 'https://fleet.example.test', 'kind' => 'self_hosted']) + ->and($settings[InstanceRegistration::SETTING_SERVICE_URL])->toBe(RegistryServiceFake::SERVICE_URL) + ->and($settings[InstanceRegistration::SETTING_INSTANCE_TOKEN])->not->toContain(RegistryServiceFake::INSTANCE_TOKEN) + ->and(InstanceRegistration::decryptToken($settings[InstanceRegistration::SETTING_INSTANCE_TOKEN], app('encrypter')))->toBe(RegistryServiceFake::INSTANCE_TOKEN) + ->and($settings[InstanceRegistration::SETTING_INSTANCE])->toBe($summary) + ->and(config('registry-bridge.registry.instance_token'))->toBe(RegistryServiceFake::INSTANCE_TOKEN); +}); + +test('registry:register reads the code from stdin and takes name and host overrides', function () { + $this->service->on('POST', 'v1/instances/register', 'instances-register'); + + [$code, $output] = ($this->artisan)(['--code-stdin' => true, '--service-url' => 'registry.example.test/', '--name' => 'Depot 7', '--host' => 'https://depot7.example.test', '--console-version' => 'v0.7.70'], " w9qa-sc8r-37jt-9pa8\n"); + + $request = $this->service->requestsTo('POST', 'v1/instances/register')[0]; + expect($code)->toBe(0) + ->and($request->url())->toBe(RegistryServiceFake::SERVICE_URL . '/v1/instances/register') + ->and($request->data())->toMatchArray(['code' => 'W9QA-SC8R-37JT-9PA8', 'name' => 'Depot 7', 'host' => 'https://depot7.example.test', 'console_version' => '0.7.70']) + ->and($output)->toContain('Registered Acme Fleet (self-hosted) (inst_rsmdtgevid) with Registry Demo Buyer.') + ->and($output)->not->toContain('Re-registered') + ->and($output)->toContain('flbi_…0000') + ->and($output)->not->toContain(RegistryServiceFake::INSTANCE_TOKEN); +}); + +test('a refused code exits non-zero with the service message and stores nothing', function () { + $this->service->on('POST', 'v1/instances/register', 'instances-register-invalid-code'); + + [$code, $output] = ($this->artisan)(['--code-stdin' => true, '--service-url' => RegistryServiceFake::SERVICE_URL, '--json' => true], 'NOPE-NOPE'); + + expect($code)->toBe(1) + ->and(json_decode($output, true))->toBe(['error' => 'The registration code is invalid, expired or already used.']) + ->and(RecordingInstanceRegistration::$settings)->toBe([]); +}); + +test('registration needs a service URL', function () { + [$code, $output] = ($this->artisan)(['--code' => 'W9QA-SC8R', '--json' => true]); + + expect($code)->toBe(1) + ->and(json_decode($output, true))->toBe(['error' => 'Set the Registry Service URL before registering this instance.']) + ->and($this->service->requests)->toBe([]); +}); + +test('the configured service URL is used when none is passed', function () { + config(['registry-bridge.registry.service_url' => RegistryServiceFake::SERVICE_URL]); + $this->service->on('POST', 'v1/instances/register', 'instances-register'); + + [$code] = ($this->artisan)(['--code' => 'W9QA-SC8R', '--json' => true]); + + expect($code)->toBe(0) + ->and(RecordingInstanceRegistration::$settings[InstanceRegistration::SETTING_SERVICE_URL])->toBe(RegistryServiceFake::SERVICE_URL); +}); + +test('registering again sends the current token to the service that issued it, and to no other', function () { + config([ + 'registry-bridge.registry.service_url' => RegistryServiceFake::SERVICE_URL, + 'registry-bridge.registry.instance_token' => 'flbi_previous0000000000000000000000000000000', + ]); + $this->service->on('POST', 'v1/instances/register', 'instances-register', 'instances-register'); + + [$same] = ($this->artisan)(['--code' => 'W9QA-SC8R', '--json' => true]); + [$other] = ($this->artisan)(['--code' => 'W9QA-SC8R', '--service-url' => 'https://other-registry.example.test', '--json' => true]); + + $requests = $this->service->requestsTo('POST', 'v1/instances/register'); + expect([$same, $other])->toBe([0, 0]) + ->and($requests[0]->data()['previous_token'] ?? null)->toBe('flbi_previous0000000000000000000000000000000') + ->and($requests[1]->data())->not->toHaveKey('previous_token'); +}); + +test('a rotated registration says the same instance kept its installs and seats', function () { + config([ + 'registry-bridge.registry.service_url' => RegistryServiceFake::SERVICE_URL, + 'registry-bridge.registry.instance_token' => RegistryServiceFake::INSTANCE_TOKEN, + ]); + $this->service->on('POST', 'v1/instances/register', 'instances-register-rotated'); + + [$code, $output] = ($this->artisan)(['--code' => 'W9QA-SC8R-37JT-9PA8']); + + $stored = RecordingInstanceRegistration::$settings[InstanceRegistration::SETTING_INSTANCE]; + expect($code)->toBe(0) + ->and($output)->toContain('Re-registered Acme Fleet (self-hosted) (inst_rsmdtgevid) with Registry Demo Buyer.') + ->and($output)->toContain('installs and licence seats are kept') + ->and($output)->not->toContain(RegistryServiceFake::INSTANCE_TOKEN) + ->and($stored['rotated'])->toBeTrue() + ->and(InstanceRegistration::decryptToken(RecordingInstanceRegistration::$settings[InstanceRegistration::SETTING_INSTANCE_TOKEN], app('encrypter')))->toBe('flbi_buyer00000000000000000000000000000rotated') + ->and($this->service->requestsTo('POST', 'v1/instances/register')[0]->data()['previous_token'])->toBe(RegistryServiceFake::INSTANCE_TOKEN); +}); + +test('exactly one of --code and --code-stdin is required, and an empty stdin code is refused', function () { + [$neither, $neitherOutput] = ($this->artisan)(['--service-url' => RegistryServiceFake::SERVICE_URL, '--json' => true]); + [$both] = ($this->artisan)(['--code' => 'A', '--code-stdin' => true, '--service-url' => RegistryServiceFake::SERVICE_URL, '--json' => true], 'B'); + [$empty, $emptyOutput] = ($this->artisan)(['--code-stdin' => true, '--service-url' => RegistryServiceFake::SERVICE_URL], "\n"); + + expect([$neither, $both, $empty])->toBe([1, 1, 1]) + ->and(json_decode($neitherOutput, true))->toBe(['error' => 'Pass the registration code with exactly one of --code or --code-stdin.']) + ->and($emptyOutput)->toContain('Enter the registration code from your registry organization.') + ->and($this->service->requests)->toBe([]); +}); + +test('an unreachable service exits non-zero with a connectivity message', function () { + $this->service->http->fake(fn () => throw new Illuminate\Http\Client\ConnectionException('Could not resolve host')); + + [$code, $output] = ($this->artisan)(['--code' => 'W9QA-SC8R', '--service-url' => RegistryServiceFake::SERVICE_URL, '--json' => true]); + + expect($code)->toBe(1) + ->and(json_decode($output, true)['error'])->toStartWith('Unable to reach the extensions registry at ' . RegistryServiceFake::SERVICE_URL); +});