Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions database/factories/ComponentGroupFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
];
}

Expand Down
29 changes: 24 additions & 5 deletions src/Http/Controllers/Api/ComponentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,19 +43,37 @@ 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));

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<Component>
*/
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
*/
Expand All @@ -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()
Expand Down
7 changes: 3 additions & 4 deletions src/Http/Controllers/Api/ComponentGroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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()
Expand Down
7 changes: 3 additions & 4 deletions src/Http/Controllers/Api/IncidentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions src/Http/Controllers/Api/IncidentUpdateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'),
Expand All @@ -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
*/
Expand Down
5 changes: 3 additions & 2 deletions src/Http/Controllers/Api/MetricController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions src/Http/Controllers/Api/MetricPointController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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'])
Expand All @@ -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
*/
Expand Down
44 changes: 44 additions & 0 deletions tests/Feature/Api/ComponentGroupTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
70 changes: 70 additions & 0 deletions tests/Feature/Api/ComponentTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Cachet\Enums\ComponentStatusEnum;
use Cachet\Enums\ResourceVisibilityEnum;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Laravel\Sanctum\Sanctum;
Expand Down Expand Up @@ -382,3 +383,72 @@
'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('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();
});
Loading
Loading