From d7e94453f795275ead95fc018079619dde10c134 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 1 Jul 2026 11:39:36 +0100 Subject: [PATCH 1/3] 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 ef1a103d..f27d5018 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 bab70666..55a0ab9a 100644 --- a/src/Http/Controllers/Api/ComponentController.php +++ b/src/Http/Controllers/Api/ComponentController.php @@ -11,8 +11,10 @@ use Cachet\Enums\ComponentStatusEnum; 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\Response; use Illuminate\Routing\Controller; use Spatie\QueryBuilder\AllowedFilter; @@ -41,12 +43,12 @@ class ComponentController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index() { - $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), ]) ->allowedSorts(['name', 'order', 'id']) ->simplePaginate(request('per_page', 15)); @@ -54,6 +56,24 @@ public function index() 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 */ @@ -73,10 +93,9 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC */ 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 45724f9e..25c05406 100644 --- a/src/Http/Controllers/Api/ComponentGroupController.php +++ b/src/Http/Controllers/Api/ComponentGroupController.php @@ -28,7 +28,7 @@ class ComponentGroupController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index() { - $componentGroups = QueryBuilder::for(ComponentGroup::class) + $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) ->allowedIncludes(['components']) ->allowedSorts(['name', 'id']) ->simplePaginate(request('per_page', 15)); @@ -53,10 +53,9 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou */ public function show(ComponentGroup $componentGroup) { - - $componentQuery = QueryBuilder::for(ComponentGroup::class) + $componentQuery = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) ->allowedIncludes(['components']) - ->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 6879f40b..8832930e 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -40,7 +40,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()) + $incidents = QueryBuilder::for(Incident::query()->visible(auth()->check())) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters([ 'name', @@ -73,10 +73,9 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc */ 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 0f93be96..dc8a9d90 100644 --- a/src/Http/Controllers/Api/IncidentUpdateController.php +++ b/src/Http/Controllers/Api/IncidentUpdateController.php @@ -31,6 +31,8 @@ class IncidentUpdateController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Incident $incident) { + $this->ensureIncidentVisible($incident); + $query = Update::query() ->where('updateable_id', $incident->id) ->where('updateable_type', 'incident'); @@ -61,6 +63,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'), @@ -72,6 +76,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 bd1f6325..49ac566f 100644 --- a/src/Http/Controllers/Api/MetricController.php +++ b/src/Http/Controllers/Api/MetricController.php @@ -33,6 +33,7 @@ class MetricController extends Controller public function index() { $query = Metric::query() + ->visible(auth()->check()) ->when(! request('sort'), function (Builder $builder) { $builder->orderByDesc('created_at'); }); @@ -63,9 +64,9 @@ 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(['points']) - ->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 047aa36e..7fdf5a03 100644 --- a/src/Http/Controllers/Api/MetricPointController.php +++ b/src/Http/Controllers/Api/MetricPointController.php @@ -27,6 +27,8 @@ class MetricPointController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Metric $metric) { + $this->ensureMetricVisible($metric); + $query = MetricPoint::query() ->where('metric_id', $metric->id); @@ -57,6 +59,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']) @@ -67,6 +70,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 28cbe643..98ab5a45 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; @@ -433,3 +434,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 0daad0bd..d5418dab 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 411d6389..239b487d 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; @@ -440,3 +441,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 e3f7b79b..0490f27a 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 b2f8baf0..1f433150 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 758d974f..4e94a5ad 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 3b49c3ff7bb2244f50f7fb54a76d0fccebc4559d Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 8 Jul 2026 10:44:32 +0100 Subject: [PATCH 2/3] 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 | 53 +++++++++++------- .../Api/ComponentGroupController.php | 33 ++++++++++-- .../Controllers/Api/IncidentController.php | 40 ++++++++++---- .../Api/IncidentUpdateController.php | 4 +- src/Http/Controllers/Api/MetricController.php | 6 ++- .../Controllers/Api/MetricPointController.php | 4 +- .../Controllers/Api/ScheduleController.php | 32 ++++++++++- 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, 311 insertions(+), 41 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 55a0ab9a..a3eacdc5 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; @@ -12,27 +13,24 @@ 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\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Collection; 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', - ]; - /** * List Components */ @@ -44,7 +42,7 @@ class ComponentController extends Controller public function index() { $components = QueryBuilder::for($this->visibleComponents()) - ->allowedIncludes(self::ALLOWED_INCLUDES) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::exact('status'), @@ -56,22 +54,41 @@ public function index() 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()); + }), + ]; + } + /** * 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); + }); } /** @@ -93,8 +110,8 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC */ 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 25c05406..6c42f38a 100644 --- a/src/Http/Controllers/Api/ComponentGroupController.php +++ b/src/Http/Controllers/Api/ComponentGroupController.php @@ -5,22 +5,47 @@ 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\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\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Collection; +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(); + } + }), + ]; + } + /** * List Component Groups */ @@ -28,8 +53,8 @@ class ComponentGroupController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index() { - $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) - ->allowedIncludes(['components']) + $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedSorts(['name', 'id']) ->simplePaginate(request('per_page', 15)); @@ -53,8 +78,8 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou */ public function show(ComponentGroup $componentGroup) { - $componentQuery = QueryBuilder::for(ComponentGroup::query()->visible(auth()->check())) - ->allowedIncludes(['components']) + $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 8832930e..437871ba 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -5,33 +5,51 @@ 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\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 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', - ]; + protected function allowedIncludes(): array + { + return [ + 'components', + AllowedInclude::callback('components.group', function (BelongsTo $query): void { + /** @var BelongsTo $query */ + $query->visible($this->isAuthenticated()); + }), + 'updates', + 'user', + ]; + } /** * List Incidents @@ -40,8 +58,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()->visible(auth()->check())) - ->allowedIncludes(self::ALLOWED_INCLUDES) + $incidents = QueryBuilder::for(Incident::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::exact('status'), @@ -73,8 +91,8 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc */ 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 dc8a9d90..9bf7db00 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; @@ -22,6 +23,7 @@ #[Group('Incident Updates', weight: 4)] class IncidentUpdateController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; /** @@ -82,7 +84,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 49ac566f..70c90b03 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; @@ -21,6 +22,7 @@ #[Group('Metrics', weight: 6)] class MetricController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; /** @@ -33,7 +35,7 @@ class MetricController extends Controller public function index() { $query = Metric::query() - ->visible(auth()->check()) + ->visible($this->isAuthenticated()) ->when(! request('sort'), function (Builder $builder) { $builder->orderByDesc('created_at'); }); @@ -64,7 +66,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(['points']) ->findOrFail($metric->id); diff --git a/src/Http/Controllers/Api/MetricPointController.php b/src/Http/Controllers/Api/MetricPointController.php index 7fdf5a03..4173829c 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; @@ -18,6 +19,7 @@ #[Group('Metric Points', weight: 7)] class MetricPointController extends Controller { + use ChecksApiAuthentication; use GuardsApiAbilities; /** @@ -76,7 +78,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 04ede690..502760cd 100644 --- a/src/Http/Controllers/Api/ScheduleController.php +++ b/src/Http/Controllers/Api/ScheduleController.php @@ -5,25 +5,53 @@ 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; use Cachet\Enums\ScheduleStatusEnum; 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\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Collection; 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', + ]; + } + /** * List Schedules */ @@ -34,7 +62,7 @@ class ScheduleController extends Controller public function index() { $schedules = QueryBuilder::for(Schedule::class) - ->allowedIncludes(['components', 'components.group', 'updates', 'user']) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters(['name', AllowedFilter::custom('status', new ScheduleStatusFilter)]) ->allowedSorts(['name', 'id', 'scheduled_at', 'completed_at']) ->simplePaginate(request('per_page', 15)); @@ -60,7 +88,7 @@ public function store(CreateScheduleRequestData $data, CreateSchedule $createSch public function show(Schedule $schedule) { $scheduleQuery = QueryBuilder::for(Schedule::class) - ->allowedIncludes(['components', 'components.group', 'updates', 'user']) + ->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 98ab5a45..92890340 100644 --- a/tests/Feature/Api/ComponentGroupTest.php +++ b/tests/Feature/Api/ComponentGroupTest.php @@ -477,3 +477,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 d5418dab..9d89ab70 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(); @@ -435,7 +438,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(); @@ -445,10 +460,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 239b487d..9190f886 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -493,3 +493,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 1f433150..78137d32 100644 --- a/tests/Feature/Api/MetricPointTest.php +++ b/tests/Feature/Api/MetricPointTest.php @@ -150,3 +150,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 4e94a5ad..48cf500f 100644 --- a/tests/Feature/Api/MetricTest.php +++ b/tests/Feature/Api/MetricTest.php @@ -322,3 +322,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 0f864c81..a6b96038 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 26350054422ce5a945f6d362074a555a361135ad Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 8 Jul 2026 10:53:13 +0100 Subject: [PATCH 3/3] 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 9190f886..86947870 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -506,7 +506,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 a6b96038..7ef63a32 100644 --- a/tests/Feature/Api/ScheduleTest.php +++ b/tests/Feature/Api/ScheduleTest.php @@ -438,5 +438,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(); });