From 5b2dd562fefa20f8437fe7605fffb766f13124dd Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 1 Jul 2026 11:39:36 +0100 Subject: [PATCH 1/4] Scope public API endpoints to caller visibility (GHSA-6ghm-wf22-pvx5) The public JSON API `index`/`show` endpoints never applied the `HasVisibility` scopes that the web UI and RSS feed use, so anonymous callers could read incidents, metrics and component groups marked `authenticated`-only or `hidden`. - Scope Incident, Metric and ComponentGroup index/show to the caller's visibility, returning 404 for out-of-scope records on show. - Scope Component index/show by the visibility of its group (components inherit visibility from their group; ungrouped stay public, matching the status page). Hide disabled components by default with an opt-in `enabled` filter; show 404s disabled components. - Gate the nested IncidentUpdate and MetricPoint controllers on their parent's visibility. - Fix ComponentGroupFactory using the wrong enum for `visible`, which defaulted groups to `authenticated` instead of `guest`. - Add API visibility regression tests across all affected endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) --- database/factories/ComponentGroupFactory.php | 4 +- .../Controllers/Api/ComponentController.php | 29 ++++++-- .../Api/ComponentGroupController.php | 7 +- .../Controllers/Api/IncidentController.php | 7 +- .../Api/IncidentUpdateController.php | 15 ++++ src/Http/Controllers/Api/MetricController.php | 5 +- .../Controllers/Api/MetricPointController.php | 14 ++++ tests/Feature/Api/ComponentGroupTest.php | 44 ++++++++++++ tests/Feature/Api/ComponentTest.php | 70 +++++++++++++++++++ tests/Feature/Api/IncidentTest.php | 53 ++++++++++++++ tests/Feature/Api/IncidentUpdateTest.php | 29 ++++++++ tests/Feature/Api/MetricPointTest.php | 20 ++++++ tests/Feature/Api/MetricTest.php | 44 ++++++++++++ 13 files changed, 324 insertions(+), 17 deletions(-) diff --git a/database/factories/ComponentGroupFactory.php b/database/factories/ComponentGroupFactory.php index 2e1325c6..8391620e 100644 --- a/database/factories/ComponentGroupFactory.php +++ b/database/factories/ComponentGroupFactory.php @@ -2,9 +2,9 @@ namespace Cachet\Database\Factories; -use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceOrderColumnEnum; use Cachet\Enums\ResourceOrderDirectionEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\ComponentGroup; use Illuminate\Database\Eloquent\Factories\Factory; @@ -27,7 +27,7 @@ public function definition(): array 'order' => 0, 'order_column' => ResourceOrderColumnEnum::Manual, 'order_direction' => null, - 'visible' => ComponentGroupVisibilityEnum::expanded->value, + 'visible' => ResourceVisibilityEnum::guest->value, ]; } diff --git a/src/Http/Controllers/Api/ComponentController.php b/src/Http/Controllers/Api/ComponentController.php index dbdb5ee7..859be4d2 100644 --- a/src/Http/Controllers/Api/ComponentController.php +++ b/src/Http/Controllers/Api/ComponentController.php @@ -12,8 +12,10 @@ use Cachet\Filters\MetaFilter; use Cachet\Http\Resources\Component as ComponentResource; use Cachet\Models\Component; +use Cachet\Models\ComponentGroup; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; @@ -47,12 +49,12 @@ class ComponentController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) { - $components = QueryBuilder::for(Component::class) + $components = QueryBuilder::for($this->visibleComponents()) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters([ 'name', AllowedFilter::exact('status'), - AllowedFilter::exact('enabled'), + AllowedFilter::exact('enabled')->default(true), AllowedFilter::custom('meta', new MetaFilter), ]) ->allowedSorts(['name', 'order', 'id']) @@ -61,6 +63,24 @@ public function index(Request $request) return ComponentResource::collection($components); } + /** + * Base query scoping components to those visible to the current caller. + * + * Components have no visibility of their own; they inherit it from their + * group. Ungrouped components are always public, matching the status page. + * + * @return Builder + */ + protected function visibleComponents(): Builder + { + $visibleGroups = ComponentGroup::query()->visible(auth()->check())->select('id'); + + return Component::query()->where(function ($query) use ($visibleGroups): void { + $query->whereNull('component_group_id') + ->orWhereIn('component_group_id', $visibleGroups); + }); + } + /** * Create Component */ @@ -81,10 +101,9 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC #[QueryParameter('include', 'Include related data (group, incidents, meta).', example: 'meta')] public function show(Component $component) { - - $componentQuery = QueryBuilder::for(Component::class) + $componentQuery = QueryBuilder::for($this->visibleComponents()->enabled()) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->find($component->id); + ->findOrFail($component->id); return ComponentResource::make($componentQuery) ->response() diff --git a/src/Http/Controllers/Api/ComponentGroupController.php b/src/Http/Controllers/Api/ComponentGroupController.php index bb449bb4..a32f2438 100644 --- a/src/Http/Controllers/Api/ComponentGroupController.php +++ b/src/Http/Controllers/Api/ComponentGroupController.php @@ -34,7 +34,7 @@ class ComponentGroupController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) { - $componentGroups = QueryBuilder::for(ComponentGroup::class) + $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) ->allowedIncludes(['components', 'meta']) ->allowedFilters([ AllowedFilter::custom('meta', new MetaFilter), @@ -63,10 +63,9 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou #[QueryParameter('include', 'Include related data (components, meta).', example: 'meta')] public function show(ComponentGroup $componentGroup) { - - $componentQuery = QueryBuilder::for(ComponentGroup::class) + $componentQuery = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) ->allowedIncludes(['components', 'meta']) - ->find($componentGroup->id); + ->findOrFail($componentGroup->id); return ComponentGroupResource::make($componentQuery) ->response() diff --git a/src/Http/Controllers/Api/IncidentController.php b/src/Http/Controllers/Api/IncidentController.php index 547212d9..48b421f5 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -45,7 +45,7 @@ class IncidentController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) { - $incidents = QueryBuilder::for(Incident::query()->with('updates')) + $incidents = QueryBuilder::for(Incident::query()->with('updates')->visible(auth()->check())) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters([ 'name', @@ -80,10 +80,9 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc #[QueryParameter('include', 'Include related data (components, components.group, updates, user, meta).', example: 'meta')] public function show(Incident $incident) { - - $incidentQuery = QueryBuilder::for(Incident::class) + $incidentQuery = QueryBuilder::for(Incident::query()->visible(auth()->check())) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->find($incident->id); + ->findOrFail($incident->id); return IncidentResource::make($incidentQuery) ->response() diff --git a/src/Http/Controllers/Api/IncidentUpdateController.php b/src/Http/Controllers/Api/IncidentUpdateController.php index ccfac6e9..70af04a2 100644 --- a/src/Http/Controllers/Api/IncidentUpdateController.php +++ b/src/Http/Controllers/Api/IncidentUpdateController.php @@ -33,6 +33,8 @@ class IncidentUpdateController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request, Incident $incident) { + $this->ensureIncidentVisible($incident); + $query = Update::query() ->where('updateable_id', $incident->id) ->where('updateable_type', 'incident'); @@ -65,6 +67,8 @@ public function store(CreateIncidentUpdateRequestData $data, Incident $incident, */ public function show(Incident $incident, Update $update) { + $this->ensureIncidentVisible($incident); + $updateQuery = QueryBuilder::for(Update::class) ->allowedIncludes([ AllowedInclude::relationship('incident', 'updateable'), @@ -76,6 +80,17 @@ public function show(Incident $incident, Update $update) ->setStatusCode(Response::HTTP_OK); } + /** + * Abort with a 404 when the parent incident is not visible to the caller. + */ + protected function ensureIncidentVisible(Incident $incident): void + { + abort_unless( + Incident::query()->visible(auth()->check())->whereKey($incident->getKey())->exists(), + Response::HTTP_NOT_FOUND, + ); + } + /** * Update Incident Update */ diff --git a/src/Http/Controllers/Api/MetricController.php b/src/Http/Controllers/Api/MetricController.php index 3d116b52..094bd3b6 100644 --- a/src/Http/Controllers/Api/MetricController.php +++ b/src/Http/Controllers/Api/MetricController.php @@ -36,6 +36,7 @@ class MetricController extends Controller public function index(Request $request) { $query = Metric::query() + ->visible(auth()->check()) ->when(! $request->input('sort'), function (Builder $builder) { $builder->orderByDesc('created_at'); }); @@ -68,11 +69,11 @@ public function store(CreateMetricRequestData $data, CreateMetric $createMetricA */ public function show(Metric $metric) { - $metricQuery = QueryBuilder::for(Metric::class) + $metricQuery = QueryBuilder::for(Metric::query()->visible(auth()->check())) ->allowedIncludes([ AllowedInclude::relationship('points', 'metricPoints'), ]) - ->find($metric->id); + ->findOrFail($metric->id); return MetricResource::make($metricQuery) ->response() diff --git a/src/Http/Controllers/Api/MetricPointController.php b/src/Http/Controllers/Api/MetricPointController.php index 4f27a779..0e40a6a4 100644 --- a/src/Http/Controllers/Api/MetricPointController.php +++ b/src/Http/Controllers/Api/MetricPointController.php @@ -29,6 +29,8 @@ class MetricPointController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request, Metric $metric) { + $this->ensureMetricVisible($metric); + $query = MetricPoint::query() ->where('metric_id', $metric->id); @@ -59,6 +61,7 @@ public function store(CreateMetricPointRequestData $data, Metric $metric, Create */ public function show(Metric $metric, MetricPoint $metricPoint) { + $this->ensureMetricVisible($metric); $metricPointQuery = QueryBuilder::for(MetricPoint::class) ->allowedIncludes(['metric']) @@ -69,6 +72,17 @@ public function show(Metric $metric, MetricPoint $metricPoint) ->setStatusCode(Response::HTTP_OK); } + /** + * Abort with a 404 when the parent metric is not visible to the caller. + */ + protected function ensureMetricVisible(Metric $metric): void + { + abort_unless( + Metric::query()->visible(auth()->check())->whereKey($metric->getKey())->exists(), + Response::HTTP_NOT_FOUND, + ); + } + /** * Delete Metric Point */ diff --git a/tests/Feature/Api/ComponentGroupTest.php b/tests/Feature/Api/ComponentGroupTest.php index db3f2c34..72782ba6 100644 --- a/tests/Feature/Api/ComponentGroupTest.php +++ b/tests/Feature/Api/ComponentGroupTest.php @@ -3,6 +3,7 @@ use Cachet\Enums\ComponentGroupVisibilityEnum; use Cachet\Enums\ResourceOrderColumnEnum; use Cachet\Enums\ResourceOrderDirectionEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; use Laravel\Sanctum\Sanctum; @@ -484,3 +485,46 @@ 'name' => 'Renamed Group', ]); }); + +it('does not list component groups hidden from guests', function () { + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/component-groups'); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); +}); + +it('lists authenticated component groups to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/component-groups'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a hidden component group to guests', function () { + $componentGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/component-groups/'.$componentGroup->id); + + $response->assertNotFound(); +}); + +it('shows an authenticated component group to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $componentGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/component-groups/'.$componentGroup->id); + + $response->assertOk(); + $response->assertJsonPath('data.attributes.id', $componentGroup->id); +}); diff --git a/tests/Feature/Api/ComponentTest.php b/tests/Feature/Api/ComponentTest.php index 478c00b5..cdbf7c59 100644 --- a/tests/Feature/Api/ComponentTest.php +++ b/tests/Feature/Api/ComponentTest.php @@ -1,6 +1,7 @@ $component->id, ]); }); + +it('does not list components belonging to groups hidden from guests', function () { + $guestGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + $authGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + Component::factory()->create(['component_group_id' => $guestGroup->id]); + Component::factory()->create(['component_group_id' => $authGroup->id]); + Component::factory()->create(['component_group_id' => $hiddenGroup->id]); + Component::factory()->create(['component_group_id' => null]); + + $response = getJson('/status/api/components?per_page=50'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('lists components in authenticated groups to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + $authGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + Component::factory()->create(['component_group_id' => $authGroup->id]); + Component::factory()->create(['component_group_id' => $hiddenGroup->id]); + Component::factory()->create(['component_group_id' => null]); + + $response = getJson('/status/api/components?per_page=50'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a component in a hidden group to guests', function () { + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $component = Component::factory()->create(['component_group_id' => $hiddenGroup->id]); + + $response = getJson('/status/api/components/'.$component->id); + + $response->assertNotFound(); +}); + +it('does not list disabled components by default', function () { + Component::factory(3)->enabled()->create(); + Component::factory(2)->disabled()->create(); + + $response = getJson('/status/api/components?per_page=50'); + + $response->assertOk(); + $response->assertJsonCount(3, 'data'); +}); + +it('lists disabled components when explicitly filtered', function () { + Component::factory(3)->enabled()->create(); + Component::factory(2)->disabled()->create(); + + $response = getJson('/status/api/components?per_page=50&filter[enabled]=false'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a disabled component by default', function () { + $component = Component::factory()->disabled()->create(); + + $response = getJson('/status/api/components/'.$component->id); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/IncidentTest.php b/tests/Feature/Api/IncidentTest.php index d8f052fb..3190eb4b 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -2,6 +2,7 @@ use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; use Cachet\Models\Incident; @@ -510,3 +511,55 @@ $response->assertNoContent(); }); + +it('does not list incidents hidden from guests', function () { + Incident::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/incidents'); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.attributes.visible', ResourceVisibilityEnum::guest->value); +}); + +it('lists authenticated incidents to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + Incident::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/incidents'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a hidden incident to guests', function () { + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/incidents/'.$incident->id); + + $response->assertNotFound(); +}); + +it('does not show an authenticated incident to guests', function () { + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/incidents/'.$incident->id); + + $response->assertNotFound(); +}); + +it('shows an authenticated incident to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/incidents/'.$incident->id); + + $response->assertOk(); + $response->assertJsonPath('data.attributes.id', $incident->id); +}); diff --git a/tests/Feature/Api/IncidentUpdateTest.php b/tests/Feature/Api/IncidentUpdateTest.php index 0961f917..25f63ad4 100644 --- a/tests/Feature/Api/IncidentUpdateTest.php +++ b/tests/Feature/Api/IncidentUpdateTest.php @@ -1,6 +1,7 @@ $incidentUpdate->updateable_id, ]); }); + +it('does not list updates of an incident hidden from guests', function () { + $incident = Incident::factory()->hasUpdates(2)->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson("/status/api/incidents/{$incident->id}/updates"); + + $response->assertNotFound(); +}); + +it('does not show an update of an incident hidden from guests', function () { + $incident = Incident::factory()->hasUpdates(1)->create(['visible' => ResourceVisibilityEnum::hidden]); + $update = $incident->updates()->first(); + + $response = getJson("/status/api/incidents/{$incident->id}/updates/{$update->id}"); + + $response->assertNotFound(); +}); + +it('lists updates of an authenticated incident to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $incident = Incident::factory()->hasUpdates(2)->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson("/status/api/incidents/{$incident->id}/updates"); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/MetricPointTest.php b/tests/Feature/Api/MetricPointTest.php index b04d8df2..4edc7104 100644 --- a/tests/Feature/Api/MetricPointTest.php +++ b/tests/Feature/Api/MetricPointTest.php @@ -1,5 +1,6 @@ $metricPoint->metric_id, ]); }); + +it('does not list points of a metric hidden from guests', function () { + $metric = Metric::factory()->hasMetricPoints(2)->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics/'.$metric->id.'/points'); + + $response->assertNotFound(); +}); + +it('lists points of an authenticated metric to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $metric = Metric::factory()->hasMetricPoints(2)->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/metrics/'.$metric->id.'/points'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/MetricTest.php b/tests/Feature/Api/MetricTest.php index 34bbc91f..27de7275 100644 --- a/tests/Feature/Api/MetricTest.php +++ b/tests/Feature/Api/MetricTest.php @@ -1,6 +1,7 @@ $metric->id, ]); }); + +it('does not list metrics hidden from guests', function () { + Metric::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics'); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); +}); + +it('lists authenticated metrics to authenticated users but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + Metric::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('does not show a hidden metric to guests', function () { + $metric = Metric::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics/'.$metric->id); + + $response->assertNotFound(); +}); + +it('shows an authenticated metric to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $metric = Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + $response = getJson('/status/api/metrics/'.$metric->id); + + $response->assertOk(); + $response->assertJsonPath('data.attributes.id', $metric->id); +}); From 91f446fe30336eca2f687576079ef1e326e42bfe Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 8 Jul 2026 10:44:32 +0100 Subject: [PATCH 2/4] Resolve API callers through the Sanctum guard and scope relationship includes The visibility scoping resolved callers via the default guard, which never sees bearer tokens on the unauthenticated read routes, so authenticated API consumers were served guest-scoped data. Includes could also bypass the scoping: components?include=incidents revealed hidden incidents, and incidents/schedules?include=components.group revealed hidden groups. Disabled components are now consistently hidden from guests and readable by authenticated callers. Co-Authored-By: Claude Fable 5 --- src/Concerns/ChecksApiAuthentication.php | 20 +++++++ .../Controllers/Api/ComponentController.php | 55 ++++++++++++------- .../Api/ComponentGroupController.php | 34 ++++++++++-- .../Controllers/Api/IncidentController.php | 42 ++++++++++---- .../Api/IncidentUpdateController.php | 4 +- src/Http/Controllers/Api/MetricController.php | 6 +- .../Controllers/Api/MetricPointController.php | 4 +- .../Controllers/Api/ScheduleController.php | 33 ++++++++++- tests/Feature/Api/ComponentGroupTest.php | 38 +++++++++++++ tests/Feature/Api/ComponentTest.php | 54 +++++++++++++++++- tests/Feature/Api/IncidentTest.php | 29 ++++++++++ tests/Feature/Api/MetricPointTest.php | 9 +++ tests/Feature/Api/MetricTest.php | 14 +++++ tests/Feature/Api/ScheduleTest.php | 16 ++++++ 14 files changed, 315 insertions(+), 43 deletions(-) create mode 100644 src/Concerns/ChecksApiAuthentication.php diff --git a/src/Concerns/ChecksApiAuthentication.php b/src/Concerns/ChecksApiAuthentication.php new file mode 100644 index 00000000..862ae8ed --- /dev/null +++ b/src/Concerns/ChecksApiAuthentication.php @@ -0,0 +1,20 @@ +check(); + } +} diff --git a/src/Http/Controllers/Api/ComponentController.php b/src/Http/Controllers/Api/ComponentController.php index 859be4d2..03d872a2 100644 --- a/src/Http/Controllers/Api/ComponentController.php +++ b/src/Http/Controllers/Api/ComponentController.php @@ -5,6 +5,7 @@ use Cachet\Actions\Component\CreateComponent; use Cachet\Actions\Component\DeleteComponent; use Cachet\Actions\Component\UpdateComponent; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Concerns\GuardsApiAbilities; use Cachet\Data\Requests\Component\CreateComponentRequestData; use Cachet\Data\Requests\Component\UpdateComponentRequestData; @@ -13,30 +14,26 @@ 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; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Collection; use Illuminate\Support\Number; use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\AllowedInclude; use Spatie\QueryBuilder\QueryBuilder; #[Group('Components', weight: 1)] class ComponentController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; - /** - * The list of allowed includes. - */ - public const ALLOWED_INCLUDES = [ - 'group', - 'incidents', - 'meta', - ]; - /** * List Components */ @@ -50,7 +47,7 @@ class ComponentController extends Controller public function index(Request $request) { $components = QueryBuilder::for($this->visibleComponents()) - ->allowedIncludes(self::ALLOWED_INCLUDES) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::exact('status'), @@ -63,22 +60,42 @@ public function index(Request $request) return ComponentResource::collection($components); } + /** + * The list of allowed includes, scoped to the current caller. + * + * @return array> + */ + protected function allowedIncludes(): array + { + return [ + 'group', + AllowedInclude::callback('incidents', function (BelongsToMany $query): void { + /** @var BelongsToMany $query */ + $query->visible($this->isAuthenticated()); + }), + 'meta', + ]; + } + /** * Base query scoping components to those visible to the current caller. * * Components have no visibility of their own; they inherit it from their - * group. Ungrouped components are always public, matching the status page. + * group. Ungrouped components are always public and disabled components + * are hidden from guests, matching the status page. * * @return Builder */ protected function visibleComponents(): Builder { - $visibleGroups = ComponentGroup::query()->visible(auth()->check())->select('id'); - - return Component::query()->where(function ($query) use ($visibleGroups): void { - $query->whereNull('component_group_id') - ->orWhereIn('component_group_id', $visibleGroups); - }); + $visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id'); + + return Component::query() + ->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled()) + ->where(function ($query) use ($visibleGroups): void { + $query->whereNull('component_group_id') + ->orWhereIn('component_group_id', $visibleGroups); + }); } /** @@ -101,8 +118,8 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC #[QueryParameter('include', 'Include related data (group, incidents, meta).', example: 'meta')] public function show(Component $component) { - $componentQuery = QueryBuilder::for($this->visibleComponents()->enabled()) - ->allowedIncludes(self::ALLOWED_INCLUDES) + $componentQuery = QueryBuilder::for($this->visibleComponents()) + ->allowedIncludes($this->allowedIncludes()) ->findOrFail($component->id); return ComponentResource::make($componentQuery) diff --git a/src/Http/Controllers/Api/ComponentGroupController.php b/src/Http/Controllers/Api/ComponentGroupController.php index a32f2438..8a7f32d0 100644 --- a/src/Http/Controllers/Api/ComponentGroupController.php +++ b/src/Http/Controllers/Api/ComponentGroupController.php @@ -5,26 +5,52 @@ use Cachet\Actions\ComponentGroup\CreateComponentGroup; use Cachet\Actions\ComponentGroup\DeleteComponentGroup; use Cachet\Actions\ComponentGroup\UpdateComponentGroup; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Concerns\GuardsApiAbilities; use Cachet\Data\Requests\ComponentGroup\CreateComponentGroupRequestData; use Cachet\Data\Requests\ComponentGroup\UpdateComponentGroupRequestData; use Cachet\Filters\MetaFilter; use Cachet\Http\Resources\ComponentGroup as ComponentGroupResource; +use Cachet\Models\Component; use Cachet\Models\ComponentGroup; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Collection; use Illuminate\Support\Number; use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\AllowedInclude; use Spatie\QueryBuilder\QueryBuilder; #[Group('Component Groups', weight: 2)] class ComponentGroupController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; + /** + * The list of allowed includes, scoped to the current caller. + * + * Disabled components are hidden from guests, matching the status page. + * + * @return array> + */ + protected function allowedIncludes(): array + { + return [ + AllowedInclude::callback('components', function (HasMany $query): void { + /** @var HasMany $query */ + if (! $this->isAuthenticated()) { + $query->enabled(); + } + }), + 'meta', + ]; + } + /** * List Component Groups */ @@ -34,8 +60,8 @@ class ComponentGroupController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) { - $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) - ->allowedIncludes(['components', 'meta']) + $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ AllowedFilter::custom('meta', new MetaFilter), ]) @@ -63,8 +89,8 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou #[QueryParameter('include', 'Include related data (components, meta).', example: 'meta')] public function show(ComponentGroup $componentGroup) { - $componentQuery = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) - ->allowedIncludes(['components', 'meta']) + $componentQuery = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->findOrFail($componentGroup->id); return ComponentGroupResource::make($componentQuery) diff --git a/src/Http/Controllers/Api/IncidentController.php b/src/Http/Controllers/Api/IncidentController.php index 48b421f5..9bd1c1d2 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -5,36 +5,54 @@ use Cachet\Actions\Incident\CreateIncident; use Cachet\Actions\Incident\DeleteIncident; use Cachet\Actions\Incident\UpdateIncident; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Concerns\GuardsApiAbilities; use Cachet\Data\Requests\Incident\CreateIncidentRequestData; use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; use Cachet\Filters\MetaFilter; 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\Relations\BelongsTo; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Collection; use Illuminate\Support\Number; use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\AllowedInclude; use Spatie\QueryBuilder\QueryBuilder; #[Group('Incidents', weight: 3)] class IncidentController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; /** - * The list of allowed includes. + * The list of allowed includes, scoped to the current caller. + * + * Component groups hidden from the caller are excluded from the nested + * include so a visible incident cannot reveal them. + * + * @return array> */ - public const ALLOWED_INCLUDES = [ - 'components', - 'components.group', - 'updates', - 'user', - 'meta', - ]; + protected function allowedIncludes(): array + { + return [ + 'components', + AllowedInclude::callback('components.group', function (BelongsTo $query): void { + /** @var BelongsTo $query */ + $query->visible($this->isAuthenticated()); + }), + 'updates', + 'user', + 'meta', + ]; + } /** * List Incidents @@ -45,8 +63,8 @@ class IncidentController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) { - $incidents = QueryBuilder::for(Incident::query()->with('updates')->visible(auth()->check())) - ->allowedIncludes(self::ALLOWED_INCLUDES) + $incidents = QueryBuilder::for(Incident::query()->with('updates')->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::exact('status'), @@ -80,8 +98,8 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc #[QueryParameter('include', 'Include related data (components, components.group, updates, user, meta).', example: 'meta')] public function show(Incident $incident) { - $incidentQuery = QueryBuilder::for(Incident::query()->visible(auth()->check())) - ->allowedIncludes(self::ALLOWED_INCLUDES) + $incidentQuery = QueryBuilder::for(Incident::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->findOrFail($incident->id); return IncidentResource::make($incidentQuery) diff --git a/src/Http/Controllers/Api/IncidentUpdateController.php b/src/Http/Controllers/Api/IncidentUpdateController.php index 70af04a2..024fb48b 100644 --- a/src/Http/Controllers/Api/IncidentUpdateController.php +++ b/src/Http/Controllers/Api/IncidentUpdateController.php @@ -5,6 +5,7 @@ use Cachet\Actions\Update\CreateUpdate; use Cachet\Actions\Update\DeleteUpdate; use Cachet\Actions\Update\EditUpdate; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Concerns\GuardsApiAbilities; use Cachet\Data\Requests\IncidentUpdate\CreateIncidentUpdateRequestData; use Cachet\Data\Requests\IncidentUpdate\EditIncidentUpdateRequestData; @@ -24,6 +25,7 @@ #[Group('Incident Updates', weight: 4)] class IncidentUpdateController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; /** @@ -86,7 +88,7 @@ public function show(Incident $incident, Update $update) protected function ensureIncidentVisible(Incident $incident): void { abort_unless( - Incident::query()->visible(auth()->check())->whereKey($incident->getKey())->exists(), + Incident::query()->visible($this->isAuthenticated())->whereKey($incident->getKey())->exists(), Response::HTTP_NOT_FOUND, ); } diff --git a/src/Http/Controllers/Api/MetricController.php b/src/Http/Controllers/Api/MetricController.php index 094bd3b6..647de29c 100644 --- a/src/Http/Controllers/Api/MetricController.php +++ b/src/Http/Controllers/Api/MetricController.php @@ -5,6 +5,7 @@ use Cachet\Actions\Metric\CreateMetric; use Cachet\Actions\Metric\DeleteMetric; use Cachet\Actions\Metric\UpdateMetric; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Concerns\GuardsApiAbilities; use Cachet\Data\Requests\Metric\CreateMetricRequestData; use Cachet\Data\Requests\Metric\UpdateMetricRequestData; @@ -24,6 +25,7 @@ #[Group('Metrics', weight: 6)] class MetricController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; /** @@ -36,7 +38,7 @@ class MetricController extends Controller public function index(Request $request) { $query = Metric::query() - ->visible(auth()->check()) + ->visible($this->isAuthenticated()) ->when(! $request->input('sort'), function (Builder $builder) { $builder->orderByDesc('created_at'); }); @@ -69,7 +71,7 @@ public function store(CreateMetricRequestData $data, CreateMetric $createMetricA */ public function show(Metric $metric) { - $metricQuery = QueryBuilder::for(Metric::query()->visible(auth()->check())) + $metricQuery = QueryBuilder::for(Metric::query()->visible($this->isAuthenticated())) ->allowedIncludes([ AllowedInclude::relationship('points', 'metricPoints'), ]) diff --git a/src/Http/Controllers/Api/MetricPointController.php b/src/Http/Controllers/Api/MetricPointController.php index 0e40a6a4..f3ce3965 100644 --- a/src/Http/Controllers/Api/MetricPointController.php +++ b/src/Http/Controllers/Api/MetricPointController.php @@ -4,6 +4,7 @@ use Cachet\Actions\Metric\CreateMetricPoint; use Cachet\Actions\Metric\DeleteMetricPoint; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Concerns\GuardsApiAbilities; use Cachet\Data\Requests\Metric\CreateMetricPointRequestData; use Cachet\Http\Resources\MetricPoint as MetricPointResource; @@ -20,6 +21,7 @@ #[Group('Metric Points', weight: 7)] class MetricPointController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; /** @@ -78,7 +80,7 @@ public function show(Metric $metric, MetricPoint $metricPoint) protected function ensureMetricVisible(Metric $metric): void { abort_unless( - Metric::query()->visible(auth()->check())->whereKey($metric->getKey())->exists(), + Metric::query()->visible($this->isAuthenticated())->whereKey($metric->getKey())->exists(), Response::HTTP_NOT_FOUND, ); } diff --git a/src/Http/Controllers/Api/ScheduleController.php b/src/Http/Controllers/Api/ScheduleController.php index 8fac6fa9..9e13eb2b 100644 --- a/src/Http/Controllers/Api/ScheduleController.php +++ b/src/Http/Controllers/Api/ScheduleController.php @@ -5,6 +5,7 @@ use Cachet\Actions\Schedule\CreateSchedule; use Cachet\Actions\Schedule\DeleteSchedule; use Cachet\Actions\Schedule\UpdateSchedule; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Concerns\GuardsApiAbilities; use Cachet\Data\Requests\Schedule\CreateScheduleRequestData; use Cachet\Data\Requests\Schedule\UpdateScheduleRequestData; @@ -12,21 +13,49 @@ use Cachet\Filters\MetaFilter; use Cachet\Filters\ScheduleStatusFilter; use Cachet\Http\Resources\Schedule as ScheduleResource; +use Cachet\Models\Component; +use Cachet\Models\ComponentGroup; use Cachet\Models\Schedule; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Collection; use Illuminate\Support\Number; use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\AllowedInclude; use Spatie\QueryBuilder\QueryBuilder; #[Group('Schedules', weight: 8)] class ScheduleController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; + /** + * The list of allowed includes, scoped to the current caller. + * + * Component groups hidden from the caller are excluded from the nested + * include so a schedule cannot reveal them. + * + * @return array> + */ + protected function allowedIncludes(): array + { + return [ + 'components', + AllowedInclude::callback('components.group', function (BelongsTo $query): void { + /** @var BelongsTo $query */ + $query->visible($this->isAuthenticated()); + }), + 'updates', + 'user', + 'meta', + ]; + } + /** * List Schedules */ @@ -39,7 +68,7 @@ class ScheduleController extends Controller public function index(Request $request) { $schedules = QueryBuilder::for(Schedule::class) - ->allowedIncludes(['components', 'components.group', 'updates', 'user', 'meta']) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::custom('status', new ScheduleStatusFilter), @@ -70,7 +99,7 @@ public function store(CreateScheduleRequestData $data, CreateSchedule $createSch public function show(Schedule $schedule) { $scheduleQuery = QueryBuilder::for(Schedule::class) - ->allowedIncludes(['components', 'components.group', 'updates', 'user', 'meta']) + ->allowedIncludes($this->allowedIncludes()) ->find($schedule->id); return ScheduleResource::make($scheduleQuery) diff --git a/tests/Feature/Api/ComponentGroupTest.php b/tests/Feature/Api/ComponentGroupTest.php index 72782ba6..89928c6d 100644 --- a/tests/Feature/Api/ComponentGroupTest.php +++ b/tests/Feature/Api/ComponentGroupTest.php @@ -528,3 +528,41 @@ $response->assertOk(); $response->assertJsonPath('data.attributes.id', $componentGroup->id); }); + +it('does not include disabled components in a group to guests', function () { + $group = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Component::factory()->enabled()->create(['component_group_id' => $group->id]); + Component::factory()->disabled()->create(['component_group_id' => $group->id]); + + $response = getJson('/status/api/component-groups/'.$group->id.'?include=components'); + + $response->assertOk(); + $response->assertJsonCount(1, 'included'); +}); + +it('includes disabled components in a group to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $group = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Component::factory()->enabled()->create(['component_group_id' => $group->id]); + Component::factory()->disabled()->create(['component_group_id' => $group->id]); + + $response = getJson('/status/api/component-groups/'.$group->id.'?include=components'); + + $response->assertOk(); + $response->assertJsonCount(2, 'included'); +}); + +it('lists authenticated component groups to callers presenting a bearer token', function () { + $user = User::factory()->create(); + $token = $user->createToken('api')->plainTextToken; + + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/component-groups', ['Authorization' => 'Bearer '.$token]); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/ComponentTest.php b/tests/Feature/Api/ComponentTest.php index cdbf7c59..e128b99a 100644 --- a/tests/Feature/Api/ComponentTest.php +++ b/tests/Feature/Api/ComponentTest.php @@ -4,6 +4,7 @@ use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; +use Cachet\Models\Incident; use Laravel\Sanctum\Sanctum; use Workbench\App\User; @@ -140,6 +141,8 @@ }); it('can filter components by disabled', function () { + Sanctum::actingAs(User::factory()->create()); + Component::factory(20)->disabled()->create(); $component = Component::factory()->enabled()->create(); @@ -541,7 +544,19 @@ $response->assertJsonCount(3, 'data'); }); -it('lists disabled components when explicitly filtered', function () { +it('does not list disabled components to guests even when explicitly filtered', function () { + Component::factory(3)->enabled()->create(); + Component::factory(2)->disabled()->create(); + + $response = getJson('/status/api/components?per_page=50&filter[enabled]=false'); + + $response->assertOk(); + $response->assertJsonCount(0, 'data'); +}); + +it('lists disabled components to authenticated users when explicitly filtered', function () { + Sanctum::actingAs(User::factory()->create()); + Component::factory(3)->enabled()->create(); Component::factory(2)->disabled()->create(); @@ -551,10 +566,45 @@ $response->assertJsonCount(2, 'data'); }); -it('does not show a disabled component by default', function () { +it('does not show a disabled component to guests', function () { $component = Component::factory()->disabled()->create(); $response = getJson('/status/api/components/'.$component->id); $response->assertNotFound(); }); + +it('shows a disabled component to authenticated users', function () { + Sanctum::actingAs(User::factory()->create()); + + $component = Component::factory()->disabled()->create(); + + $response = getJson('/status/api/components/'.$component->id); + + $response->assertOk(); + $response->assertJsonPath('data.attributes.id', $component->id); +}); + +it('does not include incidents hidden from guests on visible components', function () { + $component = Component::factory()->enabled()->create(); + $component->incidents()->attach(Incident::factory()->create(['visible' => ResourceVisibilityEnum::guest])); + $component->incidents()->attach(Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden])); + + $response = getJson('/status/api/components/'.$component->id.'?include=incidents'); + + $response->assertOk(); + $response->assertJsonCount(1, 'included'); +}); + +it('lists components in authenticated groups to callers presenting a bearer token', function () { + $user = User::factory()->create(); + $token = $user->createToken('api')->plainTextToken; + + $authGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Component::factory()->create(['component_group_id' => $authGroup->id]); + + $response = getJson('/status/api/components', ['Authorization' => 'Bearer '.$token]); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); +}); diff --git a/tests/Feature/Api/IncidentTest.php b/tests/Feature/Api/IncidentTest.php index 3190eb4b..7f2bf377 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -563,3 +563,32 @@ $response->assertOk(); $response->assertJsonPath('data.attributes.id', $incident->id); }); + +it('does not reveal component groups hidden from guests through incident includes', function () { + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $component = Component::factory()->for($hiddenGroup, 'group')->create(); + $incident = Incident::factory()->create(); + $incident->components()->attach($component, ['component_status' => ComponentStatusEnum::partial_outage->value]); + + $response = getJson('/status/api/incidents/'.$incident->id.'?include=components.group'); + + $response->assertOk(); + + $included = collect($response->json('included')); + + expect($included->firstWhere('attributes.name', $hiddenGroup->name))->toBeNull(); +}); + +it('lists authenticated incidents to callers presenting a bearer token', function () { + $user = User::factory()->create(); + $token = $user->createToken('api')->plainTextToken; + + Incident::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Incident::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/incidents', ['Authorization' => 'Bearer '.$token]); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/MetricPointTest.php b/tests/Feature/Api/MetricPointTest.php index 4edc7104..93081a3a 100644 --- a/tests/Feature/Api/MetricPointTest.php +++ b/tests/Feature/Api/MetricPointTest.php @@ -185,3 +185,12 @@ $response->assertOk(); $response->assertJsonCount(2, 'data'); }); + +it('does not show a point of a metric hidden from guests', function () { + $metric = Metric::factory()->hasMetricPoints(1)->create(['visible' => ResourceVisibilityEnum::hidden]); + $point = $metric->metricPoints()->first(); + + $response = getJson('/status/api/metrics/'.$metric->id.'/points/'.$point->id); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/MetricTest.php b/tests/Feature/Api/MetricTest.php index 27de7275..088bee0b 100644 --- a/tests/Feature/Api/MetricTest.php +++ b/tests/Feature/Api/MetricTest.php @@ -373,3 +373,17 @@ $response->assertOk(); $response->assertJsonPath('data.attributes.id', $metric->id); }); + +it('lists authenticated metrics to callers presenting a bearer token', function () { + $user = User::factory()->create(); + $token = $user->createToken('api')->plainTextToken; + + Metric::factory()->create(['visible' => ResourceVisibilityEnum::guest]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Metric::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + + $response = getJson('/status/api/metrics', ['Authorization' => 'Bearer '.$token]); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/ScheduleTest.php b/tests/Feature/Api/ScheduleTest.php index 17c0be6d..bc5c2d2d 100644 --- a/tests/Feature/Api/ScheduleTest.php +++ b/tests/Feature/Api/ScheduleTest.php @@ -1,6 +1,7 @@ $schedule->id, ]); }); + +it('does not reveal component groups hidden from guests through schedule includes', function () { + $hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $component = Component::factory()->for($hiddenGroup, 'group')->create(); + $schedule = Schedule::factory()->create(); + $schedule->components()->attach($component, ['component_status' => ComponentStatusEnum::under_maintenance->value]); + + $response = getJson('/status/api/schedules/'.$schedule->id.'?include=components.group'); + + $response->assertOk(); + + $included = collect($response->json('included')); + + expect($included->firstWhere('attributes.name', $hiddenGroup->name))->toBeNull(); +}); From b6b9e907b8bdd5816dd08b0e00f05ba8978ba2d5 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 8 Jul 2026 10:53:13 +0100 Subject: [PATCH 3/4] Assert hidden group includes by resource type to avoid faker name collisions The include-leak tests matched included resources by name, which fails when faker generates a component name equal to the hidden group's name. Co-Authored-By: Claude Fable 5 --- tests/Feature/Api/IncidentTest.php | 3 ++- tests/Feature/Api/ScheduleTest.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Api/IncidentTest.php b/tests/Feature/Api/IncidentTest.php index 7f2bf377..9b4332a9 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -576,7 +576,8 @@ $included = collect($response->json('included')); - expect($included->firstWhere('attributes.name', $hiddenGroup->name))->toBeNull(); + expect($included->firstWhere('id', (string) $component->id))->not->toBeNull() + ->and($included->firstWhere('type', 'componentGroups'))->toBeNull(); }); it('lists authenticated incidents to callers presenting a bearer token', function () { diff --git a/tests/Feature/Api/ScheduleTest.php b/tests/Feature/Api/ScheduleTest.php index bc5c2d2d..7bd5cf44 100644 --- a/tests/Feature/Api/ScheduleTest.php +++ b/tests/Feature/Api/ScheduleTest.php @@ -495,5 +495,6 @@ $included = collect($response->json('included')); - expect($included->firstWhere('attributes.name', $hiddenGroup->name))->toBeNull(); + expect($included->firstWhere('id', (string) $component->id))->not->toBeNull() + ->and($included->firstWhere('type', 'componentGroups'))->toBeNull(); }); From cf908da52ed4a38ebe711c4f22589331825a9a5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:06:02 +0000 Subject: [PATCH 4/4] Use findOrFail when resolving schedules in ScheduleController::show Route model binding already 404s missing schedules, but every other API show endpoint resolves its record through findOrFail so the QueryBuilder lookup can never silently return null. Bring schedules in line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Jne3ZV53NDLUnhL4pDtgAJ --- src/Http/Controllers/Api/ScheduleController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/Api/ScheduleController.php b/src/Http/Controllers/Api/ScheduleController.php index 9e13eb2b..3f0253e5 100644 --- a/src/Http/Controllers/Api/ScheduleController.php +++ b/src/Http/Controllers/Api/ScheduleController.php @@ -100,7 +100,7 @@ public function show(Schedule $schedule) { $scheduleQuery = QueryBuilder::for(Schedule::class) ->allowedIncludes($this->allowedIncludes()) - ->find($schedule->id); + ->findOrFail($schedule->id); return ScheduleResource::make($scheduleQuery) ->response()