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/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 dbdb5ee7..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; @@ -12,29 +13,27 @@ use Cachet\Filters\MetaFilter; 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 */ @@ -47,12 +46,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) - ->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), AllowedFilter::custom('meta', new MetaFilter), ]) ->allowedSorts(['name', 'order', 'id']) @@ -61,6 +60,44 @@ 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 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 */ @@ -81,10 +118,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) - ->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 bb449bb4..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::class) - ->allowedIncludes(['components', 'meta']) + $componentGroups = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ AllowedFilter::custom('meta', new MetaFilter), ]) @@ -63,10 +89,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) - ->allowedIncludes(['components', 'meta']) - ->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 547212d9..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')) - ->allowedIncludes(self::ALLOWED_INCLUDES) + $incidents = QueryBuilder::for(Incident::query()->with('updates')->visible($this->isAuthenticated())) + ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', AllowedFilter::exact('status'), @@ -80,10 +98,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) - ->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 ccfac6e9..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; /** @@ -33,6 +35,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 +69,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 +82,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 3d116b52..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,6 +38,7 @@ class MetricController extends Controller public function index(Request $request) { $query = Metric::query() + ->visible($this->isAuthenticated()) ->when(! $request->input('sort'), function (Builder $builder) { $builder->orderByDesc('created_at'); }); @@ -68,11 +71,11 @@ 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([ 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..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; /** @@ -29,6 +31,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 +63,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 +74,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 8fac6fa9..3f0253e5 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,8 +99,8 @@ public function store(CreateScheduleRequestData $data, CreateSchedule $createSch public function show(Schedule $schedule) { $scheduleQuery = QueryBuilder::for(Schedule::class) - ->allowedIncludes(['components', 'components.group', 'updates', 'user', 'meta']) - ->find($schedule->id); + ->allowedIncludes($this->allowedIncludes()) + ->findOrFail($schedule->id); return ScheduleResource::make($scheduleQuery) ->response() diff --git a/tests/Feature/Api/ComponentGroupTest.php b/tests/Feature/Api/ComponentGroupTest.php index db3f2c34..89928c6d 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,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 478c00b5..e128b99a 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(); @@ -488,3 +492,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 d8f052fb..9b4332a9 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,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 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..93081a3a 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 34bbc91f..088bee0b 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 17c0be6d..7bd5cf44 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(); +});