'file', 'cache.default' => 'array', 'session.driver' => 'array', ]); InstanceSettings::query()->whereKey(0)->delete(); $settings = new InstanceSettings(['is_api_enabled' => true]); $settings->id = 0; $settings->save(); Once::flush(); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; }); function createS3StorageForTeam(Team $team, array $overrides = []): S3Storage { return S3Storage::create(array_merge([ 'team_id' => $team->id, 'name' => 'Team S3 Storage', 'description' => 'Test storage', 'region' => 'us-east-1', 'key' => 'test-access-key', 'secret' => 'test-secret-key', 'bucket' => 'test-bucket', 'endpoint' => 'https://s3.amazonaws.com', 'is_usable' => false, ], $overrides)); } function validS3StoragePayload(array $overrides = []): array { return array_merge([ 'name' => 'My S3 Storage', 'description' => 'Backup storage', 'endpoint' => 'https://s3.amazonaws.com', 'bucket' => 'coolify-backups', 'region' => 'us-east-1', 'key' => 'AKIAEXAMPLEKEY', 'secret' => 'example-secret-value', ], $overrides); } describe('GET /api/v1/s3-storages', function () { test('lists all s3 storages for the team', function () { createS3StorageForTeam($this->team, ['name' => 'Storage One', 'bucket' => 'bucket-one']); createS3StorageForTeam($this->team, ['name' => 'Storage Two', 'bucket' => 'bucket-two']); createS3StorageForTeam($this->team, ['name' => 'Storage Three', 'bucket' => 'bucket-three']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/s3-storages'); $response->assertStatus(200); $response->assertJsonCount(3); $response->assertJsonStructure([ '*' => ['uuid', 'name', 'description', 'endpoint', 'bucket', 'region', 'is_usable', 'team_id', 'created_at', 'updated_at'], ]); }); test('does not include storages from other teams', function () { createS3StorageForTeam($this->team); $otherTeam = Team::factory()->create(); createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage', 'bucket' => 'other-bucket']); createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage 2', 'bucket' => 'other-bucket-2']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/s3-storages'); $response->assertStatus(200); $response->assertJsonCount(1); }); test('rejects request without authentication', function () { $response = $this->getJson('/api/v1/s3-storages'); $response->assertStatus(401); }); test('read token does not include key and secret values', function () { createS3StorageForTeam($this->team, [ 'key' => 'hidden-access-key', 'secret' => 'hidden-secret-key', ]); $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$readToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/s3-storages'); $response->assertSuccessful(); expect($response->getContent())->not->toContain('hidden-access-key'); expect($response->getContent())->not->toContain('hidden-secret-key'); expect($response->getContent())->not->toContain('"key":'); expect($response->getContent())->not->toContain('"secret":'); }); test('read sensitive token includes key and secret values', function () { createS3StorageForTeam($this->team, [ 'key' => 'visible-access-key', 'secret' => 'visible-secret-key', ]); $readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$readSensitiveToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/s3-storages'); $response->assertSuccessful(); $response->assertJsonFragment([ 'key' => 'visible-access-key', 'secret' => 'visible-secret-key', ]); }); test('root token includes key and secret values', function () { createS3StorageForTeam($this->team, [ 'key' => 'root-access-key', 'secret' => 'root-secret-key', ]); $rootToken = $this->user->createToken('root-token', ['root'])->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$rootToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/s3-storages'); $response->assertSuccessful(); $response->assertJsonFragment([ 'key' => 'root-access-key', 'secret' => 'root-secret-key', ]); }); }); describe('GET /api/v1/s3-storages/{uuid}', function () { test('gets s3 storage by UUID', function () { $storage = createS3StorageForTeam($this->team, [ 'name' => 'Primary Backup', 'bucket' => 'primary-backup', ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); $response->assertStatus(200); $response->assertJsonFragment([ 'name' => 'Primary Backup', 'bucket' => 'primary-backup', 'region' => 'us-east-1', ]); }); test('returns 404 for non-existent storage', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/s3-storages/non-existent-uuid'); $response->assertStatus(404); }); test('cannot access storage from another team', function () { $otherTeam = Team::factory()->create(); $storage = createS3StorageForTeam($otherTeam); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); $response->assertStatus(404); }); test('read token does not include key and secret by UUID', function () { $storage = createS3StorageForTeam($this->team, [ 'key' => 'hidden-detail-key', 'secret' => 'hidden-detail-secret', ]); $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$readToken, 'Content-Type' => 'application/json', ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); $response->assertSuccessful(); expect($response->getContent())->not->toContain('hidden-detail-key'); expect($response->getContent())->not->toContain('hidden-detail-secret'); }); test('read sensitive token includes key and secret by UUID', function () { $storage = createS3StorageForTeam($this->team, [ 'key' => 'visible-detail-key', 'secret' => 'visible-detail-secret', ]); $readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$readSensitiveToken, 'Content-Type' => 'application/json', ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); $response->assertSuccessful(); $response->assertJsonFragment([ 'key' => 'visible-detail-key', 'secret' => 'visible-detail-secret', ]); }); }); describe('POST /api/v1/s3-storages', function () { test('creates an s3 storage', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/s3-storages', validS3StoragePayload()); $response->assertStatus(201); $response->assertJsonStructure(['uuid']); $this->assertDatabaseHas('s3_storages', [ 'team_id' => $this->team->id, 'name' => 'My S3 Storage', 'bucket' => 'coolify-backups', 'region' => 'us-east-1', ]); }); test('stores the given URL style, or detects it from the endpoint', function () { $headers = ['Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json']; $this->withHeaders($headers)->postJson('/api/v1/s3-storages', validS3StoragePayload(['name' => 'Default']))->assertStatus(201); $this->withHeaders($headers)->postJson('/api/v1/s3-storages', validS3StoragePayload([ 'name' => 'Alibaba', 'use_path_style_endpoint' => false, ]))->assertStatus(201); $this->withHeaders($headers)->postJson('/api/v1/s3-storages', validS3StoragePayload([ 'name' => 'Detected', 'endpoint' => 'https://s3.oss-ap-southeast-1.aliyuncs.com', ]))->assertStatus(201); expect(S3Storage::where('name', 'Default')->sole()->use_path_style_endpoint)->toBeTrue() ->and(S3Storage::where('name', 'Alibaba')->sole()->use_path_style_endpoint)->toBeFalse() ->and(S3Storage::where('name', 'Detected')->sole()->use_path_style_endpoint)->toBeFalse(); }); test('stores the TLS verification setting, which checks the certificate by default', function () { $headers = ['Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json']; $this->withHeaders($headers)->postJson('/api/v1/s3-storages', validS3StoragePayload(['name' => 'Default']))->assertStatus(201); $this->withHeaders($headers)->postJson('/api/v1/s3-storages', validS3StoragePayload([ 'name' => 'Self-signed', 'skip_tls_verification' => true, ]))->assertStatus(201); expect(S3Storage::where('name', 'Default')->sole()->skip_tls_verification)->toBeFalse() ->and(S3Storage::where('name', 'Self-signed')->sole()->skip_tls_verification)->toBeTrue(); }); test('validates name is required', function () { $payload = validS3StoragePayload(); unset($payload['name']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/s3-storages', $payload); $response->assertStatus(422); $response->assertJsonValidationErrors(['name']); }); test('validates key is required', function () { $payload = validS3StoragePayload(); unset($payload['key']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/s3-storages', $payload); $response->assertStatus(422); $response->assertJsonValidationErrors(['key']); }); test('validates secret is required', function () { $payload = validS3StoragePayload(); unset($payload['secret']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/s3-storages', $payload); $response->assertStatus(422); $response->assertJsonValidationErrors(['secret']); }); test('validates bucket format', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/s3-storages', validS3StoragePayload([ 'bucket' => 'Invalid_Bucket', ])); $response->assertStatus(422); $response->assertJsonValidationErrors(['bucket']); }); test('rejects unsafe endpoints', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/s3-storages', validS3StoragePayload([ 'endpoint' => 'http://127.0.0.1:9000', ])); $response->assertStatus(422); $response->assertJsonValidationErrors(['endpoint']); }); test('rejects extra fields not in allowed list', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/s3-storages', validS3StoragePayload([ 'invalid_field' => 'invalid_value', ])); $response->assertStatus(422); }); }); describe('PATCH /api/v1/s3-storages/{uuid}', function () { test('updates s3 storage name', function () { $storage = createS3StorageForTeam($this->team, ['name' => 'Old Name']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ 'name' => 'New Name', ]); $response->assertStatus(200); $this->assertDatabaseHas('s3_storages', [ 'uuid' => $storage->uuid, 'name' => 'New Name', ]); }); test('updates multiple fields', function () { $storage = createS3StorageForTeam($this->team); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ 'name' => 'Updated Storage', 'region' => 'eu-west-1', 'bucket' => 'updated-bucket', ]); $response->assertStatus(200); $this->assertDatabaseHas('s3_storages', [ 'uuid' => $storage->uuid, 'name' => 'Updated Storage', 'region' => 'eu-west-1', 'bucket' => 'updated-bucket', ]); }); test('updates the path-style setting', function () { $storage = createS3StorageForTeam($this->team); $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ 'use_path_style_endpoint' => false, ])->assertStatus(200); expect($storage->fresh()->use_path_style_endpoint)->toBeFalse(); }); test('updates the TLS verification setting', function () { $storage = createS3StorageForTeam($this->team); expect($storage->fresh()->skip_tls_verification)->toBeFalse(); $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ 'skip_tls_verification' => true, ])->assertStatus(200); expect($storage->fresh()->skip_tls_verification)->toBeTrue(); }); test('rejects empty body', function () { $storage = createS3StorageForTeam($this->team); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", []); $response->assertStatus(400); $response->assertJson([ 'message' => 'Invalid request.', 'error' => 'Invalid JSON.', ]); }); test('cannot update storage from another team', function () { $otherTeam = Team::factory()->create(); $storage = createS3StorageForTeam($otherTeam); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ 'name' => 'New Name', ]); $response->assertStatus(404); }); test('rejects extra fields on update', function () { $storage = createS3StorageForTeam($this->team); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ 'name' => 'New Name', 'team_id' => 999, ]); $response->assertStatus(422); }); }); describe('DELETE /api/v1/s3-storages/{uuid}', function () { test('deletes s3 storage', function () { $storage = createS3StorageForTeam($this->team); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->deleteJson("/api/v1/s3-storages/{$storage->uuid}"); $response->assertStatus(200); $response->assertJson(['message' => 'S3 storage deleted.']); $this->assertDatabaseMissing('s3_storages', [ 'uuid' => $storage->uuid, ]); }); test('cannot delete storage from another team', function () { $otherTeam = Team::factory()->create(); $storage = createS3StorageForTeam($otherTeam); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->deleteJson("/api/v1/s3-storages/{$storage->uuid}"); $response->assertStatus(404); }); test('returns 404 for non-existent storage', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->deleteJson('/api/v1/s3-storages/non-existent-uuid'); $response->assertStatus(404); }); }); describe('POST /api/v1/s3-storages/{uuid}/validate', function () { test('validates a working s3 storage connection', function () { $storage = createS3StorageForTeam($this->team); $disk = Mockery::mock(FilesystemAdapter::class); $disk->expects('files')->once()->andReturn([]); Storage::expects('build')->once()->andReturn($disk); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate"); $response->assertStatus(200); $response->assertJson([ 'valid' => true, 'message' => 'S3 storage connection is valid.', ]); expect($storage->fresh()->is_usable)->toBeTrue(); }); test('detects an invalid s3 storage connection', function () { $storage = createS3StorageForTeam($this->team, ['is_usable' => true]); $disk = Mockery::mock(FilesystemAdapter::class); $disk->expects('files') ->once() ->andThrow(new RuntimeException('Access Denied')); Storage::expects('build')->once()->andReturn($disk); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate"); $response->assertStatus(200); $response->assertJson([ 'valid' => false, 'message' => 'Access Denied', ]); expect($storage->fresh()->is_usable)->toBeFalse(); }); test('cannot validate storage from another team', function () { $otherTeam = Team::factory()->create(); $storage = createS3StorageForTeam($otherTeam); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate"); $response->assertStatus(404); }); test('writes an audit log entry when validating storage', function () { $storage = createS3StorageForTeam($this->team, ['name' => 'Audit Storage']); $disk = Mockery::mock(FilesystemAdapter::class); $disk->expects('files')->once()->andReturn([]); Storage::expects('build')->once()->andReturn($disk); $auditChannel = Mockery::mock(); $auditChannel->shouldReceive('info') ->once() ->with('api.s3_storage.validated', Mockery::on(function (array $context) use ($storage) { return $context['s3_storage_uuid'] === $storage->uuid && $context['s3_storage_name'] === 'Audit Storage' && $context['valid'] === true; })); Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate") ->assertOk(); }); }); test('rejects an array storage type with 422', function () { $response = $this->withToken($this->bearerToken) ->postJson('/api/v1/s3-storages', validS3StoragePayload(['type' => ['sftp']])); $response->assertStatus(422); $this->assertDatabaseCount('s3_storages', 0); }); test('SFTP updates preserve the saved password for blank secrets', function ($secret) { $storage = createS3StorageForTeam($this->team, [ 'type' => BackupStorageType::Sftp, 'endpoint' => 'sftp://backup.example.com/backups', 'secret' => 'saved-password', ]); $this->withToken($this->bearerToken) ->patchJson('/api/v1/s3-storages/'.$storage->uuid, ['secret' => $secret]) ->assertOk(); expect($storage->fresh()->secret)->toBe('saved-password'); })->with([null, '', ' ']); function sftpStoragePayload(array $overrides = []): array { return array_merge([ 'type' => 'sftp', 'name' => 'SFTP Storage', 'endpoint' => 'sftp://1.1.1.1/backups', 'key' => 'backup', 'secret' => 'example-password', ], $overrides); } test('rejects a username or password in the storage endpoint', function (array $payload) { $this->withToken($this->bearerToken) ->postJson('/api/v1/s3-storages', $payload) ->assertStatus(422) ->assertJsonPath('errors.endpoint.0', 'The endpoint must not contain a username or password. Enter the credentials in their own fields.'); $this->assertDatabaseCount('s3_storages', 0); })->with([ 'sftp user and password' => [fn () => sftpStoragePayload(['endpoint' => 'sftp://user:secret@1.1.1.1/backups'])], 'sftp user only' => [fn () => sftpStoragePayload(['endpoint' => 'sftp://user@1.1.1.1'])], 'webdav user and password' => [fn () => sftpStoragePayload(['type' => 'webdav', 'endpoint' => 'https://user:secret@1.1.1.1/remote.php'])], 's3 user and password' => [fn () => validS3StoragePayload(['endpoint' => 'https://user:secret@s3.amazonaws.com'])], ]); test('updates a saved storage whose endpoint already has a username and password', function () { $storage = createS3StorageForTeam($this->team, ['endpoint' => 'https://user:secret@s3.amazonaws.com']); $this->withToken($this->bearerToken) ->patchJson('/api/v1/s3-storages/'.$storage->uuid, ['name' => 'Renamed']) ->assertOk(); expect($storage->fresh()->name)->toBe('Renamed'); }); test('rejects control characters in the storage endpoint path', function (string $endpoint) { $this->withToken($this->bearerToken) ->postJson('/api/v1/s3-storages', sftpStoragePayload(['endpoint' => $endpoint])) ->assertStatus(422) ->assertJsonValidationErrors(['endpoint']); $this->assertDatabaseCount('s3_storages', 0); })->with([ 'encoded line feed' => 'sftp://1.1.1.1/a%0Ab', 'encoded null byte' => 'sftp://1.1.1.1/a%00b', 'encoded delete' => 'sftp://1.1.1.1/a%7Fb', ]); test('the base path of a storage never contains control characters', function () { $storage = new S3Storage; $storage->setRawAttributes(['type' => 'sftp', 'endpoint' => 'sftp://1.1.1.1/a%0Ab']); expect(fn () => $storage->basePath())->toThrow(RuntimeException::class); }); test('rejects a git-related private key of the team for an SFTP storage', function () { $gitKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'is_git_related' => true]); $this->withToken($this->bearerToken) ->postJson('/api/v1/s3-storages', sftpStoragePayload(['secret' => null, 'private_key_uuid' => $gitKey->uuid])) ->assertStatus(422) ->assertJsonValidationErrors(['private_key_uuid']); $this->assertDatabaseCount('s3_storages', 0); }); test('an SFTP storage does not log in with a git-related private key', function () { $gitKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'is_git_related' => true]); $storage = createS3StorageForTeam($this->team, [ 'type' => BackupStorageType::Sftp, 'endpoint' => 'sftp://1.1.1.1/backups', 'private_key_id' => $gitKey->id, 'secret' => '', ]); expect(fn () => $storage->sftpPrivateKey())->toThrow(RuntimeException::class); }); test('lists SFTP storages without a query per storage', function () { $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; $listQueryCount = function () use ($readToken): int { DB::flushQueryLog(); DB::enableQueryLog(); $this->withToken($readToken)->getJson('/api/v1/s3-storages')->assertOk(); DB::disableQueryLog(); return count(DB::getQueryLog()); }; $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $createSftpStorage = fn () => createS3StorageForTeam($this->team, [ 'type' => BackupStorageType::Sftp, 'endpoint' => 'sftp://1.1.1.1/backups', 'private_key_id' => $privateKey->id, 'secret' => '', ]); $createSftpStorage(); $listQueryCount(); $queriesWithOneStorage = $listQueryCount(); $createSftpStorage(); $createSftpStorage(); $listQueryCount(); expect($listQueryCount())->toBe($queriesWithOneStorage); });