create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $notificationAction = NotificationAction::factory() ->discord() ->for($project) ->create([ 'name' => 'Discord alerts', 'filters' => [ [ 'attribute' => 'status.to.label', 'operator' => 'equals', 'value' => 'Doing', ], ], 'channel_configuration' => [ 'allowed_mentions' => ['users', 'roles'], ], ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->get(route('projects.settings.notifications', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/settings/Notifications') ->where('project.id', $project->id) ->where('notificationActions.0.name', 'Discord alerts') ->where('notificationActions.0.channel', NotificationChannel::Discord->value) ->where('notificationActions.0.channel_configuration.allowed_mentions.0', 'users') ->where('notificationActions.0.channel_configuration.allowed_mentions.1', 'roles') ->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value) ->where('notificationActions.0.enabled', true) ->where('notificationActions.0.has_delivery_url', true) ->where('notificationActions.0.filters.0.attribute', 'status.to.label') ->where('notificationActions.0.filters.0.operator', 'equals') ->where('notificationActions.0.filters.0.value', 'Doing') ->where('notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString()) ->where('channelOptions.0.value', NotificationChannel::Discord->value) ->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value) ->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value) ->where('discordAllowedMentionOptions.0.value', 'everyone') ->where('discordAllowedMentionOptions.2.value', 'users') ->where('filterAttributeOptions.0.value', 'project.id') ->where('filterOperatorOptions.0.value', 'equals') ->where('templateVariables.0.value', '$url') ->where('templateVariables.6.value', '$actor_name') ->missing('notificationActions.0.delivery_url'), ); } /** * Ensure non-admin project users cannot open notification settings. */ public function test_non_admin_users_cannot_view_notification_settings(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->get(route('projects.settings.notifications', $project)) ->assertForbidden(); } /** * Ensure project admins can create notification actions from settings. */ public function test_project_admins_can_create_notification_actions(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->post(route('projects.settings.notifications.store', $project), [ 'name' => 'Discord alerts', 'event_type' => NotificationEventType::TicketStatusChanged->value, 'channel' => NotificationChannel::Discord->value, 'channel_configuration' => [ 'allowed_mentions' => ['users', 'roles'], ], 'enabled' => '1', 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', 'filters' => json_encode([ [ 'attribute' => 'status.to.label', 'operator' => 'equals', 'value' => 'Doing', ], ], JSON_THROW_ON_ERROR), 'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url', ]) ->assertRedirect(route('projects.settings.notifications', $project)) ->assertSessionHas('status', 'Discord alerts notification was added.'); $notificationAction = NotificationAction::query()->firstOrFail(); $this->assertSame($project->id, $notificationAction->project_id); $this->assertSame(NotificationChannel::Discord, $notificationAction->channel); $this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->event_type); $this->assertSame( ['allowed_mentions' => ['users', 'roles']], $notificationAction->channel_configuration, ); $this->assertTrue($notificationAction->enabled); $this->assertSame( 'https://discord.com/api/webhooks/123456/discord-alerts', $notificationAction->delivery_url, ); $this->assertSame([ [ 'attribute' => 'status.to.label', 'operator' => 'equals', 'value' => 'Doing', ], ], $notificationAction->filters); } /** * Ensure project admins can update notification actions without replacing stored secret URLs. */ public function test_project_admins_can_update_notification_actions_without_replacing_delivery_urls(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $notificationAction = NotificationAction::factory() ->discord() ->for($project) ->create([ 'delivery_url' => 'https://discord.com/api/webhooks/123456/original-secret', 'name' => 'Ops alerts', ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [ 'name' => 'Ops escalation alerts', 'event_type' => NotificationEventType::TicketStatusChanged->value, 'channel' => NotificationChannel::Discord->value, 'channel_configuration' => [ 'allowed_mentions' => ['users', 'everyone'], ], 'enabled' => '1', 'delivery_url' => '', 'filters' => json_encode([ [ 'attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2'], ], ], JSON_THROW_ON_ERROR), 'message_template' => '$ticket_key moved to $status_to: $url', ]) ->assertRedirect(route('projects.settings.notifications', $project)) ->assertSessionHas('status', 'Ops escalation alerts notification was updated.'); $notificationAction->refresh(); $this->assertSame('Ops escalation alerts', $notificationAction->name); $this->assertSame( 'https://discord.com/api/webhooks/123456/original-secret', $notificationAction->delivery_url, ); $this->assertSame([ [ 'attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2'], ], ], $notificationAction->filters); $this->assertSame( ['allowed_mentions' => ['users', 'everyone']], $notificationAction->channel_configuration, ); $this->assertSame([ [ 'attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2'], ], ], $notificationAction->filters); $this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template); } /** * Ensure project admins can disable and re-enable notification actions. */ public function test_project_admins_can_toggle_notification_actions(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $notificationAction = NotificationAction::factory() ->for($project) ->create([ 'enabled' => true, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [ 'name' => $notificationAction->name, 'event_type' => $notificationAction->event_type->value, 'channel' => $notificationAction->channel->value, 'enabled' => '0', 'delivery_url' => '', 'message_template' => '', ]) ->assertRedirect(route('projects.settings.notifications', $project)); $this->assertFalse($notificationAction->fresh()->enabled); $this->actingAs($user) ->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [ 'name' => $notificationAction->fresh()->name, 'event_type' => $notificationAction->fresh()->event_type->value, 'channel' => $notificationAction->fresh()->channel->value, 'enabled' => '1', 'delivery_url' => '', 'message_template' => '', ]) ->assertRedirect(route('projects.settings.notifications', $project)); $this->assertTrue($notificationAction->fresh()->enabled); } /** * Ensure project admins can replace stored delivery URLs when a new secret is submitted. */ public function test_project_admins_can_replace_delivery_urls(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $notificationAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'delivery_url' => 'https://hooks.example.test/kanbangit/original-secret', ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [ 'name' => 'Primary webhook', 'event_type' => NotificationEventType::TicketStatusChanged->value, 'channel' => NotificationChannel::Webhook->value, 'enabled' => '1', 'delivery_url' => 'https://hooks.example.test/kanbangit/replaced-secret', 'message_template' => '', ]) ->assertRedirect(route('projects.settings.notifications', $project)); $this->assertSame( 'https://hooks.example.test/kanbangit/replaced-secret', $notificationAction->fresh()->delivery_url, ); } /** * Ensure project admins can delete configured notification actions. */ public function test_project_admins_can_delete_notification_actions(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $notificationAction = NotificationAction::factory() ->for($project) ->create([ 'name' => 'Webhook alerts', ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->delete(route('projects.settings.notifications.destroy', [$project, $notificationAction])) ->assertRedirect(route('projects.settings.notifications', $project)) ->assertSessionHas('status', 'Webhook alerts notification was deleted.'); $this->assertModelMissing($notificationAction); } /** * Ensure validation errors are returned for notification action forms. */ public function test_notification_action_validation_errors_are_returned_for_relevant_fields(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->from(route('projects.settings.notifications', $project)) ->post(route('projects.settings.notifications.store', $project), [ 'name' => '', 'event_type' => 'invalid-event', 'channel' => NotificationChannel::Discord->value, 'enabled' => 'definitely-not-a-boolean', 'delivery_url' => 'not-a-url', 'filters' => json_encode([ [ 'attribute' => 'ticket.key', 'operator' => 'in', 'value' => [], ], ], JSON_THROW_ON_ERROR), 'channel_configuration' => [ 'allowed_mentions' => ['not-valid'], ], 'message_template' => '', ]) ->assertRedirect(route('projects.settings.notifications', $project)) ->assertSessionHasErrors([ 'name', 'event_type', 'enabled', 'delivery_url', 'filters.0.value', 'channel_configuration.allowed_mentions.0', 'channel_configuration.allowed_mentions.0', 'filters.0.value', 'message_template', ]); } /** * Ensure non-admin project users cannot mutate notification actions. */ public function test_non_admin_users_cannot_mutate_notification_actions(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $notificationAction = NotificationAction::factory()->for($project)->create(); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->post(route('projects.settings.notifications.store', $project), [ 'name' => 'Forbidden webhook', 'event_type' => NotificationEventType::TicketStatusChanged->value, 'channel' => NotificationChannel::Webhook->value, 'enabled' => '1', 'delivery_url' => 'https://hooks.example.test/kanbangit/forbidden-secret', 'message_template' => '', ]) ->assertForbidden(); $this->actingAs($user) ->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [ 'name' => 'Forbidden update', 'event_type' => $notificationAction->event_type->value, 'channel' => $notificationAction->channel->value, 'enabled' => '0', 'delivery_url' => '', 'message_template' => '', ]) ->assertForbidden(); $this->actingAs($user) ->delete(route('projects.settings.notifications.destroy', [$project, $notificationAction])) ->assertForbidden(); } /** * Ensure notification settings props never render stored secret URLs back to the browser. */ public function test_notification_settings_do_not_render_secret_delivery_urls(): void { $user = User::factory()->create(); $project = Project::factory()->create(); NotificationAction::factory() ->for($project) ->create([ 'delivery_url' => 'https://hooks.example.test/kanbangit/secret-token', ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->get(route('projects.settings.notifications', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/settings/Notifications') ->where('notificationActions.0.has_delivery_url', true) ->missing('notificationActions.0.delivery_url'), ); } }