team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $project->id]); $destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->database = create_standalone_sqlite($this->environment->id, $destination, ['sqlite_databases' => 'app.db,jobs.db']); }); function sqliteApiHeaders(): array { InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); return ['Authorization' => 'Bearer '.test()->user->createToken('test-token', ['*'])->plainTextToken]; } it('creates the data volume and restores into the first configured file', function () { expect($this->database->image)->toBe('peakimages/sqlite:3.54.1-v0.2.0') ->and($this->database->databaseFilePath())->toBe('/var/lib/sqlite/app.db') ->and($this->database->persistentStorages()->first()->mount_path)->toBe('/var/lib/sqlite'); }); it('only accepts plain comma-separated file names', function (string $value, bool $valid) { expect((bool) preg_match(StandaloneSqlite::DATABASES_PATTERN, $value))->toBe($valid); })->with([ 'single file' => ['app.db', true], 'several files' => ['app.db,jobs.sqlite', true], 'path traversal' => ['../escape.db', false], 'hidden file' => ['.app.db', false], 'shell metacharacters' => ['app.db;id', false], 'trailing comma' => ['app.db,', false], ]); it('generates a compose file that passes the database files to the image', function () { $commands = []; $executor = Mockery::mock(DatabaseStartCommandExecutor::class); $executor->shouldReceive('execute')->once()->withArgs(function (array $captured) use (&$commands) { $commands = $captured; return true; })->andReturn(new Activity); app()->instance(DatabaseStartCommandExecutor::class, $executor); (new StartSqlite)->handle($this->database, new Activity); preg_match("/^echo '([^']+)' \| base64 -d/", collect($commands)->first(fn (string $command) => str_contains($command, 'docker-compose.yml')), $matches); $service = Yaml::parse(base64_decode($matches[1]))['services'][$this->database->uuid]; expect($service)->not->toHaveKey('command') ->and($service['environment'])->toContain('SQLITE_DATABASES=app.db,jobs.db', 'COOLIFY_RESOURCE_UUID='.$this->database->uuid) ->and($service['healthcheck']['test'])->toBe(['CMD', 'healthcheck.sh']) ->and($service['volumes'])->toBe(['sqlite-data-'.$this->database->uuid.':/var/lib/sqlite']); }); it('uses the server log drain driver when log draining is enabled', function () { $commands = []; $executor = Mockery::mock(DatabaseStartCommandExecutor::class); $executor->shouldReceive('execute')->once()->withArgs(function (array $captured) use (&$commands) { $commands = $captured; return true; })->andReturn(new Activity); app()->instance(DatabaseStartCommandExecutor::class, $executor); $this->server->settings->update([ 'is_logdrain_cloudwatch_enabled' => true, 'logdrain_cloudwatch_region' => 'us-east-1', 'logdrain_cloudwatch_group' => 'coolify', ]); $this->database->update(['is_log_drain_enabled' => true]); Server::flushIdentityMap(); (new StartSqlite)->handle($this->database->fresh(), new Activity); preg_match("/^echo '([^']+)' \| base64 -d/", collect($commands)->first(fn (string $command) => str_contains($command, 'docker-compose.yml')), $matches); $service = Yaml::parse(base64_decode($matches[1]))['services'][$this->database->uuid]; expect($service['logging']['driver'])->toBe('awslogs') ->and($service['logging']['options']['awslogs-group'])->toBe('coolify'); }); it('restores a gzipped backup with .restore into the first database file', function () { $command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false); expect($command) ->toStartWith("backup='/tmp/restore_1'\n") ->toContain('stream() { if is_gzip; then gunzip -c "$backup"; else cat "$backup"; fi; }') ->toEndWith("stream > \"\$backup.db\" || fail 'The backup cannot be read. Nothing was changed.'\n[ \"\$(head -c 15 \"\$backup.db\")\" = 'SQLite format 3' ] || { rm -f \"\$backup.db\"; fail 'The backup is not a SQLite database. Nothing was changed.'; }\nsqlite3 -bail '/var/lib/sqlite/app.db' '.timeout 10000' \".restore \$backup.db\"; status=\$?; rm -f \"\$backup.db\"; exit \$status"); }); it('lists its own database files and resolves only those as restore targets', function () { expect($this->database->databaseFiles())->toBe(['app.db', 'jobs.db']) ->and($this->database->databaseFilePath('jobs.db'))->toBe('/var/lib/sqlite/jobs.db') ->and($this->database->databaseFilePath())->toBe('/var/lib/sqlite/app.db'); }); it('rejects restore targets that are not one of its database files', function (string $file) { expect(fn () => $this->database->databaseFilePath($file))->toThrow(InvalidArgumentException::class); })->with([ 'unknown file' => ['cache.db'], 'path traversal' => ['../app.db'], 'absolute path' => ['/etc/passwd'], 'empty' => [''], ]); it('defaults the restore target to the file named in the backup, else the first file', function (?string $backupName, string $expected) { expect($this->database->defaultRestoreFile($backupName))->toBe($expected); })->with([ 'coolify backup of the second file' => ['sqlite-backup-jobs.db-1700000000.gz', 'jobs.db'], 'server path of the second file' => ['/backups/jobs.db.gz', 'jobs.db'], 'plain second file' => ['jobs.db', 'jobs.db'], 'coolify backup of the first file' => ['sqlite-backup-app.db-1700000000.gz', 'app.db'], 'unrelated name' => ['backup.gz', 'app.db'], 'no name' => [null, 'app.db'], ]); it('restores into the selected database file instead of the first one', function () { $command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false, sqliteDatabase: 'jobs.db'); expect($command)->toContain("sqlite3 -bail '/var/lib/sqlite/jobs.db'") ->not->toContain('/var/lib/sqlite/app.db') ->toContain("[ \"\$(head -c 15 \"\$backup.db\")\" = 'SQLite format 3' ]"); }); it('normalises the file list when saved from the general page', function () { Livewire::actingAs($this->user) ->test(General::class, ['database' => $this->database]) ->set('sqliteDatabases', 'app.db, cache.db') ->call('submit') ->assertHasNoErrors(); expect($this->database->refresh()->sqlite_databases)->toBe('app.db,cache.db'); }); it('rejects space separated file names instead of merging them', function () { $original = $this->database->sqlite_databases; Livewire::actingAs($this->user) ->test(General::class, ['database' => $this->database]) ->set('sqliteDatabases', 'app.db cache.db') ->call('submit') ->assertNotDispatched('success'); expect($this->database->refresh()->sqlite_databases)->toBe($original); }); it('creates a sqlite database through the API without public access fields', function () { $headers = sqliteApiHeaders(); $payload = [ 'server_uuid' => $this->server->uuid, 'project_uuid' => $this->environment->project->uuid, 'environment_uuid' => $this->environment->uuid, 'sqlite_databases' => 'main.db', ]; $response = $this->withHeaders($headers)->postJson('/api/v1/databases/sqlite', $payload)->assertStatus(201); expect(StandaloneSqlite::where('uuid', $response->json('uuid'))->value('sqlite_databases'))->toBe('main.db'); $this->withHeaders($headers) ->postJson('/api/v1/databases/sqlite', $payload + ['is_public' => true, 'public_port' => 5432]) ->assertStatus(422); }); it('updates the database files through the API and rejects unsafe names', function () { $headers = sqliteApiHeaders(); $this->withHeaders($headers) ->patchJson("/api/v1/databases/{$this->database->uuid}", ['sqlite_databases' => 'jobs.sqlite']) ->assertStatus(200); expect($this->database->refresh()->sqlite_databases)->toBe('jobs.sqlite'); $this->withHeaders($headers) ->patchJson("/api/v1/databases/{$this->database->uuid}", ['sqlite_databases' => '../escape.db']) ->assertStatus(422); });