$team]); $token = $user->createToken('sensitive-test', $abilities); DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ 'team_id' => $team->id, ]); return $token->plainTextToken; } function makeTeamUser(): array { $team = Team::factory()->create(); $user = User::factory()->create(); $team->members()->attach($user->id, ['role' => 'owner']); session(['currentTeam' => $team]); return [$team, $user]; } beforeEach(function () { InstanceSettings::query()->delete(); $settings = new InstanceSettings; $settings->id = 0; $settings->save(); [$this->team, $this->user] = makeTeamUser(); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->server->settings->forceFill([ 'sentinel_token' => encrypt('super-secret-sentinel-token'), 'sentinel_custom_url' => 'https://sentinel.internal', 'logdrain_axiom_api_key' => encrypt('axiom-key-secret'), 'logdrain_newrelic_license_key' => encrypt('newrelic-key-secret'), 'logdrain_custom_config' => 'custom-config-data', 'logdrain_custom_config_parser' => 'custom-parser-data', ])->saveQuietly(); }); describe('GET /api/v1/servers sensitive field gating', function () { test('read token does not leak sentinel or logdrain fields', function () { $token = makeApiToken($this->user, $this->team, ['read']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/servers'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->not->toContain('sentinel_token'); expect($body)->not->toContain('sentinel_custom_url'); expect($body)->not->toContain('logdrain_axiom_api_key'); expect($body)->not->toContain('logdrain_newrelic_license_key'); expect($body)->not->toContain('logdrain_custom_config'); }); test('read sensitive token sees sentinel and logdrain fields', function () { $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/servers'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->toContain('sentinel_token'); expect($body)->toContain('sentinel_custom_url'); expect($body)->toContain('logdrain_axiom_api_key'); }); test('root token sees sentinel and logdrain fields', function () { $token = makeApiToken($this->user, $this->team, ['root']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/servers'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->toContain('sentinel_token'); }); }); describe('GET /api/v1/applications nested-relation scrubbing', function () { beforeEach(function () { $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); $destination = $this->server->standaloneDockers()->firstOrFail(); $this->application = Application::create([ 'name' => 'sensitive-test-app', 'git_repository' => 'https://github.com/test/test', 'git_branch' => 'main', 'build_pack' => 'nixpacks', 'ports_exposes' => '3000', 'environment_id' => $this->environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]); }); test('read token does not leak sentinel_token via destination.server.settings', function () { $token = makeApiToken($this->user, $this->team, ['read']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/applications'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->not->toContain('"sentinel_token":'); expect($body)->not->toContain('"sentinel_custom_url":'); expect($body)->not->toContain('"logdrain_axiom_api_key":'); }); test('read-sensitive token sees nested sentinel_token via destination.server.settings', function () { $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/applications'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->toContain('"sentinel_token":'); expect($body)->toContain('"sentinel_custom_url":'); }); }); describe('GET /api/v1/databases sensitive field gating', function () { beforeEach(function () { $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); $destination = $this->server->standaloneDockers()->firstOrFail(); $this->database = StandalonePostgresql::create([ 'name' => 'sensitive-db', 'description' => 'test', 'postgres_user' => 'postgres', 'postgres_password' => encrypt('super-secret-db-password'), 'postgres_db' => 'app', 'image' => 'postgres:16-alpine', 'environment_id' => $this->environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]); }); test('read token does not leak postgres_password or db urls', function () { $token = makeApiToken($this->user, $this->team, ['read']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/databases'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->not->toContain('postgres_password'); expect($body)->not->toContain('internal_db_url'); expect($body)->not->toContain('external_db_url'); }); test('read sensitive token sees postgres_password and nested sentinel_token', function () { $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/databases'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->toContain('"postgres_password":'); expect($body)->toContain('"internal_db_url":'); expect($body)->toContain('"sentinel_token":'); }); test('project database list can eager load nested destination server settings', function () { $databases = $this->project->databases(['destination.server.settings']); $database = $databases->firstWhere('id', $this->database->id); expect($database)->not->toBeNull() ->and($database->relationLoaded('destination'))->toBeTrue() ->and($database->destination->relationLoaded('server'))->toBeTrue() ->and($database->destination->server->relationLoaded('settings'))->toBeTrue(); }); }); describe('GET /api/v1/services sensitive field gating', function () { beforeEach(function () { $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); $destination = $this->server->standaloneDockers()->firstOrFail(); $this->service = Service::factory()->create([ 'server_id' => $this->server->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), 'environment_id' => $this->environment->id, ]); }); test('read token does not leak service or nested server sensitive fields', function () { $token = makeApiToken($this->user, $this->team, ['read']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/services'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->not->toContain('"docker_compose_raw":') ->and($body)->not->toContain('"sentinel_token":') ->and($body)->not->toContain('"sentinel_custom_url":'); }); test('read sensitive token sees service and nested server sensitive fields', function () { $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/services'); $response->assertStatus(200); $body = $response->getContent(); expect($body)->toContain('"docker_compose_raw":') ->and($body)->toContain('"sentinel_token":') ->and($body)->toContain('"sentinel_custom_url":'); }); test('read sensitive service list eager loads nested server settings once', function () { $secondServer = Server::factory()->create(['team_id' => $this->team->id]); $secondDestination = $secondServer->standaloneDockers()->firstOrFail(); Service::factory()->create([ 'server_id' => $secondServer->id, 'destination_id' => $secondDestination->id, 'destination_type' => $secondDestination->getMorphClass(), 'environment_id' => $this->environment->id, ]); $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $serverSettingsQueries = collect(); DB::listen(function ($query) use ($serverSettingsQueries) { if (str_contains($query->sql, 'from "server_settings"')) { $serverSettingsQueries->push($query->sql); } }); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, ])->getJson('/api/v1/services'); $response->assertStatus(200); expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue(); }); });