'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 authHeaders(string $token): array { return [ 'Authorization' => 'Bearer '.$token, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ]; } describe('GET /api/v1/notifications/*', function () { test('returns email notification settings for the current team', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->getJson('/api/v1/notifications/email'); $response->assertSuccessful(); $response->assertJsonFragment([ 'team_id' => $this->team->id, 'smtp_enabled' => false, ]); $response->assertJsonStructure([ 'team_id', 'smtp_enabled', 'deployment_failure_email_notifications', 'use_instance_email_settings', ]); }); test('returns settings for every notification channel', function (string $channel) { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->getJson("/api/v1/notifications/{$channel}"); $response->assertSuccessful(); $response->assertJsonPath('team_id', $this->team->id); })->with([ 'email', 'discord', 'slack', 'telegram', 'pushover', 'webhook', ]); test('hides encrypted secrets without read:sensitive ability', function () { $this->team->discordNotificationSettings->update([ 'discord_webhook_url' => 'https://discord.com/api/webhooks/hidden/secret', 'discord_enabled' => true, ]); $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; $response = $this->withHeaders(authHeaders($readToken)) ->getJson('/api/v1/notifications/discord'); $response->assertSuccessful(); $response->assertJsonMissingPath('discord_webhook_url'); expect($response->getContent())->not->toContain('hidden/secret'); }); test('includes encrypted secrets with read:sensitive ability for admins', function () { $this->team->discordNotificationSettings->update([ 'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token', 'discord_enabled' => true, ]); $sensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; $response = $this->withHeaders(authHeaders($sensitiveToken)) ->getJson('/api/v1/notifications/discord'); $response->assertSuccessful(); $response->assertJsonFragment([ 'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token', ]); }); test('member with read token can view settings but not secrets', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); session(['currentTeam' => $this->team]); $this->team->emailNotificationSettings->update([ 'smtp_password' => 'super-secret-password', 'smtp_enabled' => true, ]); $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; $response = $this->withHeaders(authHeaders($memberToken)) ->getJson('/api/v1/notifications/email'); $response->assertSuccessful(); $response->assertJsonPath('smtp_enabled', true); $response->assertJsonMissingPath('smtp_password'); expect($response->getContent())->not->toContain('super-secret-password'); }); test('member cannot use read:sensitive token ability', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); session(['currentTeam' => $this->team]); $memberToken = $member->createToken('member-sensitive', ['read', 'read:sensitive'])->plainTextToken; $this->withHeaders(authHeaders($memberToken)) ->getJson('/api/v1/notifications/email') ->assertForbidden(); }); test('rejects unauthenticated requests', function () { $this->getJson('/api/v1/notifications/email') ->assertStatus(401); }); test('firstOrCreate restores missing channel settings', function () { DiscordNotificationSettings::query()->where('team_id', $this->team->id)->delete(); expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeFalse(); $response = $this->withHeaders(authHeaders($this->bearerToken)) ->getJson('/api/v1/notifications/discord'); $response->assertSuccessful(); $response->assertJsonPath('team_id', $this->team->id); expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeTrue(); }); }); describe('PATCH /api/v1/notifications/*', function () { test('updates email notification settings', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/email', [ 'smtp_enabled' => true, 'smtp_from_address' => 'alerts@example.com', 'smtp_host' => 'smtp.example.com', 'smtp_port' => 587, 'smtp_encryption' => 'starttls', 'deployment_failure_email_notifications' => false, ]); $response->assertSuccessful(); $response->assertJsonPath('smtp_enabled', true); $response->assertJsonPath('deployment_failure_email_notifications', false); $settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first(); expect($settings->smtp_enabled)->toBeTrue() ->and($settings->smtp_from_address)->toBe('alerts@example.com') ->and($settings->smtp_host)->toBe('smtp.example.com') ->and($settings->smtp_port)->toBe(587) ->and($settings->deployment_failure_email_notifications)->toBeFalse(); }); test('updates discord notification settings', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/discord', [ 'discord_enabled' => true, 'discord_webhook_url' => 'https://discord.com/api/webhooks/123/abc', 'discord_ping_enabled' => false, 'deployment_success_discord_notifications' => true, ]); $response->assertSuccessful(); $response->assertJsonPath('discord_enabled', true); $response->assertJsonPath('discord_ping_enabled', false); $settings = DiscordNotificationSettings::query()->where('team_id', $this->team->id)->first(); expect($settings->discord_enabled)->toBeTrue() ->and($settings->discord_webhook_url)->toBe('https://discord.com/api/webhooks/123/abc') ->and($settings->discord_ping_enabled)->toBeFalse() ->and($settings->deployment_success_discord_notifications)->toBeTrue(); }); test('updates slack, telegram, pushover, and webhook channels', function (string $channel, array $payload, string $modelClass, string $enabledField) { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson("/api/v1/notifications/{$channel}", $payload); $response->assertSuccessful(); $response->assertJsonPath($enabledField, true); $settings = $modelClass::query()->where('team_id', $this->team->id)->first(); expect($settings->{$enabledField})->toBeTrue(); })->with([ 'slack' => [ 'slack', [ 'slack_enabled' => true, 'slack_webhook_url' => 'https://hooks.slack.com/services/T00/B00/xxx', 'deployment_failure_slack_notifications' => true, ], SlackNotificationSettings::class, 'slack_enabled', ], 'telegram' => [ 'telegram', [ 'telegram_enabled' => true, 'telegram_token' => '123456:ABC-DEF', 'telegram_chat_id' => '-100123', 'deployment_failure_telegram_notifications' => true, ], TelegramNotificationSettings::class, 'telegram_enabled', ], 'pushover' => [ 'pushover', [ 'pushover_enabled' => true, 'pushover_user_key' => 'user-key', 'pushover_api_token' => 'api-token', 'deployment_failure_pushover_notifications' => true, ], PushoverNotificationSettings::class, 'pushover_enabled', ], 'webhook' => [ 'webhook', [ 'webhook_enabled' => true, 'webhook_url' => 'https://example.com/hooks/coolify', 'deployment_failure_webhook_notifications' => true, ], WebhookNotificationSettings::class, 'webhook_enabled', ], ]); test('rejects unknown fields with 422', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/email', [ 'smtp_enabled' => true, 'not_a_real_field' => 'nope', ]); $response->assertStatus(422); $response->assertJsonPath('errors.not_a_real_field.0', 'This field is not allowed.'); }); test('rejects team_id mass assignment attempts', function () { $otherTeam = Team::factory()->create(); $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/discord', [ 'discord_enabled' => true, 'team_id' => $otherTeam->id, ]); $response->assertStatus(422); $response->assertJsonPath('errors.team_id.0', 'This field is not allowed.'); }); test('requires write ability', function () { $readToken = $this->user->createToken('read-only', ['read'])->plainTextToken; $response = $this->withHeaders(authHeaders($readToken)) ->patchJson('/api/v1/notifications/email', [ 'smtp_enabled' => true, ]); $response->assertForbidden(); }); test('forbids members from updating notification settings', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); session(['currentTeam' => $this->team]); $memberToken = $member->createToken('member-write', ['read', 'write'])->plainTextToken; $response = $this->withHeaders(authHeaders($memberToken)) ->patchJson('/api/v1/notifications/email', [ 'smtp_enabled' => true, ]); $response->assertForbidden(); }); test('rejects empty json body', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/email', []); $response->assertStatus(400); }); test('does not leak updates across teams', function () { $otherTeam = Team::factory()->create(); $otherUser = User::factory()->create(); $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); session(['currentTeam' => $otherTeam]); $otherToken = $otherUser->createToken('other-token', ['*'])->plainTextToken; $this->withHeaders(authHeaders($otherToken)) ->patchJson('/api/v1/notifications/email', [ 'smtp_enabled' => true, 'smtp_from_address' => 'other@example.com', ]) ->assertSuccessful(); $thisTeamSettings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first(); $otherSettings = EmailNotificationSettings::query()->where('team_id', $otherTeam->id)->first(); expect($thisTeamSettings->smtp_enabled)->toBeFalse() ->and($otherSettings->smtp_enabled)->toBeTrue() ->and($otherSettings->smtp_from_address)->toBe('other@example.com'); }); });