create(); $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); $admin->roles()->attach($role); return $admin; } function withFakeBin(array $scripts, callable $callback): void { $dir = storage_path('app/test-bin-' . Str::random(6)); if (!is_dir($dir)) { mkdir($dir, 0777, true); } foreach ($scripts as $name => $body) { $path = $dir . DIRECTORY_SEPARATOR . $name; file_put_contents($path, $body); chmod($path, 0755); } $originalPath = getenv('PATH') ?: ''; putenv("PATH={$dir}"); $_ENV['PATH'] = $dir; $_SERVER['PATH'] = $dir; try { $callback(); } finally { putenv("PATH={$originalPath}"); $_ENV['PATH'] = $originalPath; $_SERVER['PATH'] = $originalPath; if (is_dir($dir)) { $items = scandir($dir); if (is_array($items)) { foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $path = $dir . DIRECTORY_SEPARATOR . $item; if (is_file($path)) { unlink($path); } } } rmdir($dir); } } } it('uses token auth header and tarball template', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); putenv('GITEA_TGZ_URL_TEMPLATE=https://git.example.test/tarball/{{TAG}}-{{VERSION}}.tgz'); putenv('GITEA_TOKEN=secrettoken'); $tarballUrl = 'https://git.example.test/tarball/v1.2.3-1.2.3.tgz'; Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => '', ], 200), $tarballUrl => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('copyDirectory')->andReturnTrue(); $artisanPath = base_path('artisan'); $originalArtisan = file_get_contents($artisanPath); file_put_contents($artisanPath, "#!/usr/bin/env php\n "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n", ], function () use ($artisanPath, $originalArtisan): void { try { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertOk(); $response->assertJsonFragment(['tag' => 'v1.2.3']); } finally { file_put_contents($artisanPath, $originalArtisan); } }); Http::assertSent(function ($request) use ($tarballUrl) { if ($request->url() === $tarballUrl) { return true; } return $request->hasHeader('Authorization', 'token secrettoken'); }); }); it('returns update failed on unexpected exception', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake(function () { throw new RuntimeException('boom'); }); Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'Update failed.']); }); it('handles release check failures', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([], 500), ]); Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'Release check failed: 500']); }); it('handles missing tag in release response', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => '', ], 200), ]); Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'Release tag not found.']); }); it('handles missing tarball url', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); putenv('GITEA_TGZ_URL_TEMPLATE='); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => '', ], 200), ]); Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'No tarball URL available.']); }); it('handles tarball download failure', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('fail', 500), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'Download failed: 500']); }); it('handles extract failure', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); withFakeBin([ 'tar' => "#!/bin/sh\nexit 1\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'Failed to extract archive.']); }); }); it('handles missing extracted folder', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn([]); withFakeBin([ 'tar' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'No extracted folder found.']); }); }); it('handles rsync failure when available', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); withFakeBin([ 'tar' => "#!/bin/sh\nexit 0\n", 'rsync' => "#!/bin/sh\nexit 1\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'rsync failed.']); }); }); it('handles composer install failure after copyDirectory', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('copyDirectory')->andReturnTrue(); withFakeBin([ 'tar' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 1\n", 'npm' => "#!/bin/sh\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'Composer install failed.']); }); }); it('handles npm install failure', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('copyDirectory')->andReturnTrue(); withFakeBin([ 'tar' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nif [ \"$1\" = \"install\" ]; then exit 1; fi\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'npm install failed.']); }); }); it('handles npm build failure', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('copyDirectory')->andReturnTrue(); withFakeBin([ 'tar' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nif [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then exit 1; fi\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'npm run build failed.']); }); }); it('handles migration failure', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('copyDirectory')->andReturnTrue(); putenv('SYSTEM_UPDATE_PHP_BINARY=/nope'); $_ENV['SYSTEM_UPDATE_PHP_BINARY'] = '/nope'; $_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = '/nope'; withFakeBin([ 'tar' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertStatus(500); $response->assertJsonFragment(['message' => 'Migrations failed.']); }); }); it('handles fallback copyDirectory update success', function (): void { putenv('GITEA_OWNER=acme'); putenv('GITEA_REPO=speedbb'); putenv('GITEA_API_BASE=https://git.example.test/api/v1'); putenv('SYSTEM_UPDATE_PHP_BINARY=php'); $_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php'; $_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php'; Http::fake([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'tag_name' => 'v1.2.3', 'tarball_url' => 'https://git.example.test/archive.tgz', ], 200), 'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200), ]); File::shouldReceive('ensureDirectoryExists')->andReturnTrue(); File::shouldReceive('put')->andReturnTrue(); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('copyDirectory')->andReturnTrue(); putenv('SYSTEM_UPDATE_PHP_BINARY=php'); $_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php'; $_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php'; withFakeBin([ 'tar' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n", 'php' => "#!/bin/sh\nexit 0\n", ], function (): void { Sanctum::actingAs(makeAdminForSystemUpdate()); $response = $this->postJson('/api/system/update'); $response->assertOk(); $response->assertJsonFragment(['message' => 'Update finished.']); $response->assertJsonStructure(['used_rsync']); }); });