diff --git a/src/Actions/Update/NotifyIncidentUpdateSubscribers.php b/src/Actions/Update/NotifyIncidentUpdateSubscribers.php index 2abd9e88..198d373d 100644 --- a/src/Actions/Update/NotifyIncidentUpdateSubscribers.php +++ b/src/Actions/Update/NotifyIncidentUpdateSubscribers.php @@ -27,6 +27,10 @@ public function handle(Update $update): void return; } + if (! $incident->isPublished()) { + return; + } + if (! $this->mailSettings->allow_subscribers) { return; } diff --git a/src/Actions/Update/NotifyScheduleUpdateSubscribers.php b/src/Actions/Update/NotifyScheduleUpdateSubscribers.php index 6b79560a..e843da96 100644 --- a/src/Actions/Update/NotifyScheduleUpdateSubscribers.php +++ b/src/Actions/Update/NotifyScheduleUpdateSubscribers.php @@ -26,6 +26,10 @@ public function handle(Update $update): void return; } + if (! $schedule->isPublished()) { + return; + } + if (! $this->mailSettings->allow_subscribers) { return; } diff --git a/src/Http/Controllers/Api/ComponentController.php b/src/Http/Controllers/Api/ComponentController.php index a88c4270..371d0dea 100644 --- a/src/Http/Controllers/Api/ComponentController.php +++ b/src/Http/Controllers/Api/ComponentController.php @@ -16,7 +16,6 @@ use Cachet\Filters\TagsFilter; use Cachet\Http\Resources\Component as ComponentResource; use Cachet\Models\Component; -use Cachet\Models\ComponentGroup; use Cachet\Models\Incident; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; @@ -94,15 +93,9 @@ protected function allowedIncludes(): array */ protected function visibleComponents(): Builder { - $visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id'); - return Component::query() ->with(['unresolvedIncidents', 'activeMaintenance']) - ->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled()) - ->where(function ($query) use ($visibleGroups): void { - $query->whereNull('component_group_id') - ->orWhereIn('component_group_id', $visibleGroups); - }); + ->visibleTo($this->isAuthenticated()); } /** diff --git a/src/Http/Controllers/Api/IncidentController.php b/src/Http/Controllers/Api/IncidentController.php index 6e8fe1d1..07b5fdf3 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -14,12 +14,11 @@ use Cachet\Filters\TagsFilter; use Cachet\Http\Resources\Incident as IncidentResource; use Cachet\Models\Component; -use Cachet\Models\ComponentGroup; use Cachet\Models\Incident; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; @@ -46,11 +45,16 @@ class IncidentController extends Controller protected function allowedIncludes(): array { return [ - 'components', - AllowedInclude::callback('components.group', function (BelongsTo $query): void { - /** @var BelongsTo $query */ - $query->visible($this->isAuthenticated()); + AllowedInclude::callback('components', function (BelongsToMany $query): void { + /** @var BelongsToMany $query */ + $query->visibleTo($this->isAuthenticated()); }), + AllowedInclude::callback('components.group', function (BelongsToMany $query): void { + /** @var BelongsToMany $query */ + $query + ->visibleTo($this->isAuthenticated()) + ->with(['group' => fn ($query) => $query->visible($this->isAuthenticated())]); + }, 'components'), 'updates', 'user', 'meta', diff --git a/src/Http/Controllers/Api/ScheduleController.php b/src/Http/Controllers/Api/ScheduleController.php index 2ab9aa86..ba3cfb45 100644 --- a/src/Http/Controllers/Api/ScheduleController.php +++ b/src/Http/Controllers/Api/ScheduleController.php @@ -16,12 +16,11 @@ use Cachet\Filters\TagsFilter; use Cachet\Http\Resources\Schedule as ScheduleResource; use Cachet\Models\Component; -use Cachet\Models\ComponentGroup; use Cachet\Models\Schedule; use Cachet\QueryBuilders\ScheduleBuilder; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; @@ -48,11 +47,16 @@ class ScheduleController extends Controller protected function allowedIncludes(): array { return [ - 'components', - AllowedInclude::callback('components.group', function (BelongsTo $query): void { - /** @var BelongsTo $query */ - $query->visible($this->isAuthenticated()); + AllowedInclude::callback('components', function (BelongsToMany $query): void { + /** @var BelongsToMany $query */ + $query->visibleTo($this->isAuthenticated()); }), + AllowedInclude::callback('components.group', function (BelongsToMany $query): void { + /** @var BelongsToMany $query */ + $query + ->visibleTo($this->isAuthenticated()) + ->with(['group' => fn ($query) => $query->visible($this->isAuthenticated())]); + }, 'components'), 'updates', 'user', 'meta', diff --git a/src/Mcp/Concerns/ScopesComponentVisibility.php b/src/Mcp/Concerns/ScopesComponentVisibility.php index 07e45dd6..198bf4cf 100644 --- a/src/Mcp/Concerns/ScopesComponentVisibility.php +++ b/src/Mcp/Concerns/ScopesComponentVisibility.php @@ -4,7 +4,6 @@ use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Models\Component; -use Cachet\Models\ComponentGroup; use Illuminate\Database\Eloquent\Builder; trait ScopesComponentVisibility @@ -22,14 +21,8 @@ trait ScopesComponentVisibility */ protected function visibleComponents(): Builder { - $visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id'); - return Component::query() ->with(['unresolvedIncidents', 'activeMaintenance']) - ->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled()) - ->where(function ($query) use ($visibleGroups): void { - $query->whereNull('component_group_id') - ->orWhereIn('component_group_id', $visibleGroups); - }); + ->visibleTo($this->isAuthenticated()); } } diff --git a/src/Mcp/Tools/Incidents/GetIncident.php b/src/Mcp/Tools/Incidents/GetIncident.php index 3332f07e..52cb6ede 100644 --- a/src/Mcp/Tools/Incidents/GetIncident.php +++ b/src/Mcp/Tools/Incidents/GetIncident.php @@ -38,7 +38,10 @@ public function handle(Request $request): Response|ResponseFactory { $incident = Incident::query() ->viewableBy($this->isAuthenticated(), $this->tokenCan('incidents.manage')) - ->with(['components', 'updates']) + ->with([ + 'components' => fn ($query) => $query->visibleTo($this->isAuthenticated()), + 'updates', + ]) ->find($id = $request->integer('id')); if ($incident === null) { diff --git a/src/Mcp/Tools/Schedules/GetSchedule.php b/src/Mcp/Tools/Schedules/GetSchedule.php index 7da350c0..231a2149 100644 --- a/src/Mcp/Tools/Schedules/GetSchedule.php +++ b/src/Mcp/Tools/Schedules/GetSchedule.php @@ -2,6 +2,8 @@ namespace Cachet\Mcp\Tools\Schedules; +use Cachet\Concerns\ChecksApiAuthentication; +use Cachet\Mcp\Concerns\GuardsMcpAbilities; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\Schedule; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -14,6 +16,8 @@ #[IsReadOnly] class GetSchedule extends Tool { + use ChecksApiAuthentication; + use GuardsMcpAbilities; use PresentsResources; protected string $name = 'get_schedule'; @@ -32,7 +36,13 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response|ResponseFactory { - $schedule = Schedule::query()->with(['components', 'updates'])->find($id = $request->integer('id')); + $schedule = Schedule::query() + ->when(! $this->tokenCan('schedules.manage'), fn ($query) => $query->published()) + ->with([ + 'components' => fn ($query) => $query->visibleTo($this->isAuthenticated()), + 'updates', + ]) + ->find($id = $request->integer('id')); if ($schedule === null) { return Response::error("Schedule [{$id}] not found."); diff --git a/src/Mcp/Tools/Schedules/ListSchedules.php b/src/Mcp/Tools/Schedules/ListSchedules.php index 9d040d6d..61fa3193 100644 --- a/src/Mcp/Tools/Schedules/ListSchedules.php +++ b/src/Mcp/Tools/Schedules/ListSchedules.php @@ -2,6 +2,7 @@ namespace Cachet\Mcp\Tools\Schedules; +use Cachet\Mcp\Concerns\GuardsMcpAbilities; use Cachet\Mcp\Concerns\InteractsWithPagination; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\Schedule; @@ -15,6 +16,7 @@ #[IsReadOnly] class ListSchedules extends Tool { + use GuardsMcpAbilities; use InteractsWithPagination; use PresentsResources; @@ -39,6 +41,7 @@ public function handle(Request $request): ResponseFactory { $schedules = Schedule::query() ->with('tags') + ->when(! $this->tokenCan('schedules.manage'), fn ($query) => $query->published()) ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) ->when($request->filled('tags'), fn ($query) => $query->withAnyTags($request->array('tags'))) ->orderByDesc('scheduled_at') diff --git a/src/Models/Component.php b/src/Models/Component.php index c57d4d64..4db5b51c 100644 --- a/src/Models/Component.php +++ b/src/Models/Component.php @@ -54,6 +54,7 @@ * @method static Builder|static enabled() * @method static Builder|static outage() * @method static Builder|static status(ComponentStatusEnum $status) + * @method static Builder|static visibleTo(bool $authenticated) * @method static ComponentFactory factory($count = null, $state = []) */ class Component extends Model implements Metable @@ -242,6 +243,21 @@ public function scopeOutage(Builder $query): void $query->whereIn('status', ComponentStatusEnum::outage()); } + /** + * Scope components to those visible to the current caller. + */ + public function scopeVisibleTo(Builder $query, bool $authenticated): void + { + $visibleGroups = ComponentGroup::query()->visible($authenticated)->select('id'); + + $query + ->unless($authenticated, fn (Builder $query) => $query->where('enabled', true)) + ->where(function (Builder $query) use ($visibleGroups): void { + $query->whereNull('component_group_id') + ->orWhereIn('component_group_id', $visibleGroups); + }); + } + /** * Get the latest unresolved incident affecting the component. * diff --git a/tests/Feature/Api/IncidentTest.php b/tests/Feature/Api/IncidentTest.php index 2c7191d1..c523b8bb 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -590,7 +590,7 @@ $response->assertJsonPath('data.attributes.id', $incident->id); }); -it('does not reveal component groups hidden from guests through incident includes', function () { +it('does not reveal components in hidden groups through incident includes', function () { $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); $component = Component::factory()->for($hiddenGroup, 'group')->create(); $incident = Incident::factory()->create(); @@ -602,7 +602,7 @@ $included = collect($response->json('included')); - expect($included->firstWhere('id', (string) $component->id))->not->toBeNull() + expect($included->firstWhere('id', (string) $component->id))->toBeNull() ->and($included->firstWhere('type', 'componentGroups'))->toBeNull(); }); diff --git a/tests/Feature/Api/ScheduleTest.php b/tests/Feature/Api/ScheduleTest.php index 1675b331..1c7bcae5 100644 --- a/tests/Feature/Api/ScheduleTest.php +++ b/tests/Feature/Api/ScheduleTest.php @@ -563,7 +563,7 @@ ]); }); -it('does not reveal component groups hidden from guests through schedule includes', function () { +it('does not reveal components in hidden groups through schedule includes', function () { $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); $component = Component::factory()->for($hiddenGroup, 'group')->create(); $schedule = Schedule::factory()->create(); @@ -575,6 +575,6 @@ $included = collect($response->json('included')); - expect($included->firstWhere('id', (string) $component->id))->not->toBeNull() + expect($included->firstWhere('id', (string) $component->id))->toBeNull() ->and($included->firstWhere('type', 'componentGroups'))->toBeNull(); }); diff --git a/tests/Feature/Mcp/Tools/IncidentToolsTest.php b/tests/Feature/Mcp/Tools/IncidentToolsTest.php index 2f8d60df..10e1a54b 100644 --- a/tests/Feature/Mcp/Tools/IncidentToolsTest.php +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -18,6 +18,7 @@ use Cachet\Mcp\Tools\IncidentUpdates\EditIncidentUpdate; use Cachet\Mcp\Tools\IncidentUpdates\RecordIncidentUpdate; use Cachet\Models\Component; +use Cachet\Models\ComponentGroup; use Cachet\Models\Incident; use Cachet\Models\IncidentTemplate; use Illuminate\Testing\Fluent\AssertableJson; @@ -305,6 +306,17 @@ ->assertSee('Internal Incident'); }); +it('does not reveal components in hidden groups through incidents', function () { + $group = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $component = Component::factory()->for($group, 'group')->create(['name' => 'Hidden Component']); + $incident = Incident::factory()->create(); + $incident->components()->attach($component, ['component_status' => ComponentStatusEnum::major_outage]); + + CachetServer::tool(GetIncident::class, ['id' => $incident->id]) + ->assertOk() + ->assertDontSee('Hidden Component'); +}); + it('overlays the displayed component status while an incident is unresolved', function () { Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); diff --git a/tests/Feature/Mcp/Tools/ScheduleToolsTest.php b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php index 7802ff7b..5153d58f 100644 --- a/tests/Feature/Mcp/Tools/ScheduleToolsTest.php +++ b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php @@ -1,5 +1,7 @@ assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc()); }); +it('does not list unpublished schedules for guests', function () { + Schedule::factory()->create(['name' => 'Published Maintenance']); + Schedule::factory()->create([ + 'name' => 'Embargoed Maintenance', + 'published_at' => now()->addDay(), + ]); + + CachetServer::tool(ListSchedules::class) + ->assertOk() + ->assertSee('Published Maintenance') + ->assertDontSee('Embargoed Maintenance'); +}); + it('gets a schedule by id', function () { $schedule = Schedule::factory()->create(['name' => 'Database Upgrade']); @@ -33,6 +50,37 @@ ->assertSee('Database Upgrade'); }); +it('does not reveal an unpublished schedule to guests by id', function () { + $schedule = Schedule::factory()->create(['published_at' => now()->addDay()]); + + CachetServer::tool(GetSchedule::class, ['id' => $schedule->id]) + ->assertHasErrors(["Schedule [{$schedule->id}] not found."]); +}); + +it('reveals unpublished schedules to callers that can manage schedules', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + $schedule = Schedule::factory()->create([ + 'name' => 'Embargoed Maintenance', + 'published_at' => now()->addDay(), + ]); + + CachetServer::tool(GetSchedule::class, ['id' => $schedule->id]) + ->assertOk() + ->assertSee('Embargoed Maintenance'); +}); + +it('does not reveal components in hidden groups through schedules', function () { + $group = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $component = Component::factory()->for($group, 'group')->create(['name' => 'Hidden Component']); + $schedule = Schedule::factory()->create(); + $schedule->components()->attach($component, ['component_status' => ComponentStatusEnum::under_maintenance]); + + CachetServer::tool(GetSchedule::class, ['id' => $schedule->id]) + ->assertOk() + ->assertDontSee('Hidden Component'); +}); + it('returns an error for an unknown schedule', function () { CachetServer::tool(GetSchedule::class, ['id' => 999]) ->assertHasErrors(['Schedule [999] not found.']); diff --git a/tests/Feature/Publishing/SchedulePublishingTest.php b/tests/Feature/Publishing/SchedulePublishingTest.php index f5e80982..dde63d38 100644 --- a/tests/Feature/Publishing/SchedulePublishingTest.php +++ b/tests/Feature/Publishing/SchedulePublishingTest.php @@ -93,6 +93,8 @@ Schedule::factory()->create(['name' => 'Published now']); Schedule::factory()->scheduled()->create(['name' => 'Prescheduled']); + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + CachetServer::tool(ListSchedules::class) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc()); @@ -101,6 +103,8 @@ it('exposes a single unpublished schedule to the mcp server', function () { $schedule = Schedule::factory()->scheduled()->create(['name' => 'Prescheduled']); + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + CachetServer::tool(GetSchedule::class, ['id' => $schedule->id]) ->assertOk() ->assertSee('Prescheduled'); diff --git a/tests/Unit/Actions/Update/NotifyIncidentUpdateSubscribersTest.php b/tests/Unit/Actions/Update/NotifyIncidentUpdateSubscribersTest.php index e2f60f4f..b53609ab 100644 --- a/tests/Unit/Actions/Update/NotifyIncidentUpdateSubscribersTest.php +++ b/tests/Unit/Actions/Update/NotifyIncidentUpdateSubscribersTest.php @@ -69,6 +69,23 @@ Notification::assertNothingSent(); }); +it('does not notify subscribers about unpublished incident updates', function () { + Subscriber::factory()->verified()->create(['global' => true]); + + $incident = Incident::factory()->create([ + 'notifications' => true, + 'visible' => ResourceVisibilityEnum::guest, + 'published_at' => now()->addDay(), + ]); + + app(CreateUpdate::class)->handle($incident, CreateIncidentUpdateRequestData::from([ + 'message' => 'Embargoed incident details.', + 'status' => IncidentStatusEnum::identified, + ])); + + Notification::assertNothingSent(); +}); + it('notifies subscribers about updates to notifiable schedules', function () { $subscriber = Subscriber::factory()->verified()->create(['global' => true]); @@ -92,3 +109,18 @@ Notification::assertNothingSent(); }); + +it('does not notify subscribers about unpublished schedule updates', function () { + Subscriber::factory()->verified()->create(['global' => true]); + + $schedule = Schedule::factory()->create([ + 'notifications' => true, + 'published_at' => now()->addDay(), + ]); + + app(CreateUpdate::class)->handle($schedule, CreateScheduleUpdateRequestData::from([ + 'message' => 'Embargoed maintenance details.', + ])); + + Notification::assertNothingSent(); +});