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/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 bab70666..a3eacdc5 100644 --- a/src/Http/Controllers/Api/ComponentController.php +++ b/src/Http/Controllers/Api/ComponentController.php @@ -5,32 +5,32 @@ 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; use Cachet\Enums\ComponentStatusEnum; 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 */ @@ -41,12 +41,12 @@ class ComponentController extends Controller #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index() { - $components = QueryBuilder::for(Component::class) - ->allowedIncludes(self::ALLOWED_INCLUDES) + $components = QueryBuilder::for($this->visibleComponents()) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::exact('status'), - AllowedFilter::exact('enabled'), + AllowedFilter::exact('enabled')->default(true), ]) ->allowedSorts(['name', 'order', 'id']) ->simplePaginate(request('per_page', 15)); @@ -54,6 +54,43 @@ 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 and disabled components + * are hidden from guests, matching the status page. + * + * @return Builder + */ + protected function visibleComponents(): Builder + { + $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); + }); + } + /** * Create Component */ @@ -73,10 +110,9 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC */ public function show(Component $component) { - - $componentQuery = QueryBuilder::for(Component::class) - ->allowedIncludes(self::ALLOWED_INCLUDES) - ->find($component->id); + $componentQuery = QueryBuilder::for($this->visibleComponents()) + ->allowedIncludes($this->allowedIncludes()) + ->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..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::class) - ->allowedIncludes(['components']) + $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedSorts(['name', 'id']) ->simplePaginate(request('per_page', 15)); @@ -53,10 +78,9 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou */ public function show(ComponentGroup $componentGroup) { - - $componentQuery = QueryBuilder::for(ComponentGroup::class) - ->allowedIncludes(['components']) - ->find($componentGroup->id); + $componentQuery = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) + ->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..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()) - ->allowedIncludes(self::ALLOWED_INCLUDES) + $incidents = QueryBuilder::for(Incident::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::exact('status'), @@ -73,10 +91,9 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc */ public function show(Incident $incident) { - - $incidentQuery = QueryBuilder::for(Incident::class) - ->allowedIncludes(self::ALLOWED_INCLUDES) - ->find($incident->id); + $incidentQuery = QueryBuilder::for(Incident::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) + ->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..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; /** @@ -31,6 +33,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 +65,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 +78,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($this->isAuthenticated())->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..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,6 +35,7 @@ class MetricController extends Controller public function index() { $query = Metric::query() + ->visible($this->isAuthenticated()) ->when(! request('sort'), function (Builder $builder) { $builder->orderByDesc('created_at'); }); @@ -63,9 +66,9 @@ public function store(CreateMetricRequestData $data, CreateMetric $createMetricA */ public function show(Metric $metric) { - $metricQuery = QueryBuilder::for(Metric::class) + $metricQuery = QueryBuilder::for(Metric::query()->visible($this->isAuthenticated())) ->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..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; /** @@ -27,6 +29,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 +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']) @@ -67,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($this->isAuthenticated())->whereKey($metric->getKey())->exists(), + Response::HTTP_NOT_FOUND, + ); + } + /** * Delete Metric Point */ 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 28cbe643..92890340 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,84 @@ '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); +}); + +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 0daad0bd..9d89ab70 100644 --- a/tests/Feature/Api/ComponentTest.php +++ b/tests/Feature/Api/ComponentTest.php @@ -1,8 +1,10 @@ create()); + Component::factory(20)->disabled()->create(); $component = Component::factory()->enabled()->create(); @@ -382,3 +386,119 @@ 'id' => $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('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(); + + $response = getJson('/status/api/components?per_page=50&filter[enabled]=false'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +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 411d6389..86947870 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,85 @@ $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); +}); + +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('id', (string) $component->id))->not->toBeNull() + ->and($included->firstWhere('type', 'componentGroups'))->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/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..78137d32 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'); +}); + +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 758d974f..48cf500f 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); +}); + +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..7ef63a32 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('id', (string) $component->id))->not->toBeNull() + ->and($included->firstWhere('type', 'componentGroups'))->toBeNull(); +});