Skip to content
Open
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: 4 additions & 0 deletions src/Actions/Update/NotifyIncidentUpdateSubscribers.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public function handle(Update $update): void
return;
}

if (! $incident->isPublished()) {
return;
}

if (! $this->mailSettings->allow_subscribers) {
return;
}
Expand Down
4 changes: 4 additions & 0 deletions src/Actions/Update/NotifyScheduleUpdateSubscribers.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public function handle(Update $update): void
return;
}

if (! $schedule->isPublished()) {
return;
}

if (! $this->mailSettings->allow_subscribers) {
return;
}
Expand Down
9 changes: 1 addition & 8 deletions src/Http/Controllers/Api/ComponentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use Cachet\Filters\TagsFilter;
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;
Expand Down Expand Up @@ -94,15 +93,9 @@ protected function allowedIncludes(): array
*/
protected function visibleComponents(): Builder
{
$visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id');

return Component::query()
->with(['unresolvedIncidents', 'activeMaintenance'])
->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled())
->where(function ($query) use ($visibleGroups): void {
$query->whereNull('component_group_id')
->orWhereIn('component_group_id', $visibleGroups);
});
->visibleTo($this->isAuthenticated());
}

/**
Expand Down
16 changes: 10 additions & 6 deletions src/Http/Controllers/Api/IncidentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@
use Cachet\Filters\TagsFilter;
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\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
Expand All @@ -46,11 +45,16 @@ class IncidentController extends Controller
protected function allowedIncludes(): array
{
return [
'components',
AllowedInclude::callback('components.group', function (BelongsTo $query): void {
/** @var BelongsTo<ComponentGroup, Component> $query */
$query->visible($this->isAuthenticated());
AllowedInclude::callback('components', function (BelongsToMany $query): void {
/** @var BelongsToMany<Component, Incident> $query */
$query->visibleTo($this->isAuthenticated());
}),
AllowedInclude::callback('components.group', function (BelongsToMany $query): void {
/** @var BelongsToMany<Component, Incident> $query */
$query
->visibleTo($this->isAuthenticated())
->with(['group' => fn ($query) => $query->visible($this->isAuthenticated())]);
}, 'components'),
'updates',
'user',
'meta',
Expand Down
16 changes: 10 additions & 6 deletions src/Http/Controllers/Api/ScheduleController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@
use Cachet\Filters\TagsFilter;
use Cachet\Http\Resources\Schedule as ScheduleResource;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Cachet\Models\Schedule;
use Cachet\QueryBuilders\ScheduleBuilder;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\QueryParameter;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
Expand All @@ -48,11 +47,16 @@ class ScheduleController extends Controller
protected function allowedIncludes(): array
{
return [
'components',
AllowedInclude::callback('components.group', function (BelongsTo $query): void {
/** @var BelongsTo<ComponentGroup, Component> $query */
$query->visible($this->isAuthenticated());
AllowedInclude::callback('components', function (BelongsToMany $query): void {
/** @var BelongsToMany<Component, Schedule> $query */
$query->visibleTo($this->isAuthenticated());
}),
AllowedInclude::callback('components.group', function (BelongsToMany $query): void {
/** @var BelongsToMany<Component, Schedule> $query */
$query
->visibleTo($this->isAuthenticated())
->with(['group' => fn ($query) => $query->visible($this->isAuthenticated())]);
}, 'components'),
'updates',
'user',
'meta',
Expand Down
9 changes: 1 addition & 8 deletions src/Mcp/Concerns/ScopesComponentVisibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Cachet\Concerns\ChecksApiAuthentication;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Illuminate\Database\Eloquent\Builder;

trait ScopesComponentVisibility
Expand All @@ -22,14 +21,8 @@ trait ScopesComponentVisibility
*/
protected function visibleComponents(): Builder
{
$visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id');

return Component::query()
->with(['unresolvedIncidents', 'activeMaintenance'])
->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled())
->where(function ($query) use ($visibleGroups): void {
$query->whereNull('component_group_id')
->orWhereIn('component_group_id', $visibleGroups);
});
->visibleTo($this->isAuthenticated());
}
}
5 changes: 4 additions & 1 deletion src/Mcp/Tools/Incidents/GetIncident.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ public function handle(Request $request): Response|ResponseFactory
{
$incident = Incident::query()
->viewableBy($this->isAuthenticated(), $this->tokenCan('incidents.manage'))
->with(['components', 'updates'])
->with([
'components' => fn ($query) => $query->visibleTo($this->isAuthenticated()),
'updates',
])
->find($id = $request->integer('id'));

if ($incident === null) {
Expand Down
12 changes: 11 additions & 1 deletion src/Mcp/Tools/Schedules/GetSchedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Cachet\Mcp\Tools\Schedules;

use Cachet\Concerns\ChecksApiAuthentication;
use Cachet\Mcp\Concerns\GuardsMcpAbilities;
use Cachet\Mcp\Concerns\PresentsResources;
use Cachet\Models\Schedule;
use Illuminate\Contracts\JsonSchema\JsonSchema;
Expand All @@ -14,6 +16,8 @@
#[IsReadOnly]
class GetSchedule extends Tool
{
use ChecksApiAuthentication;
use GuardsMcpAbilities;
use PresentsResources;

protected string $name = 'get_schedule';
Expand All @@ -32,7 +36,13 @@ public function schema(JsonSchema $schema): array

public function handle(Request $request): Response|ResponseFactory
{
$schedule = Schedule::query()->with(['components', 'updates'])->find($id = $request->integer('id'));
$schedule = Schedule::query()
->when(! $this->tokenCan('schedules.manage'), fn ($query) => $query->published())
->with([
'components' => fn ($query) => $query->visibleTo($this->isAuthenticated()),
'updates',
])
->find($id = $request->integer('id'));

if ($schedule === null) {
return Response::error("Schedule [{$id}] not found.");
Expand Down
3 changes: 3 additions & 0 deletions src/Mcp/Tools/Schedules/ListSchedules.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Cachet\Mcp\Tools\Schedules;

use Cachet\Mcp\Concerns\GuardsMcpAbilities;
use Cachet\Mcp\Concerns\InteractsWithPagination;
use Cachet\Mcp\Concerns\PresentsResources;
use Cachet\Models\Schedule;
Expand All @@ -15,6 +16,7 @@
#[IsReadOnly]
class ListSchedules extends Tool
{
use GuardsMcpAbilities;
use InteractsWithPagination;
use PresentsResources;

Expand All @@ -39,6 +41,7 @@ public function handle(Request $request): ResponseFactory
{
$schedules = Schedule::query()
->with('tags')
->when(! $this->tokenCan('schedules.manage'), fn ($query) => $query->published())
->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%'))
->when($request->filled('tags'), fn ($query) => $query->withAnyTags($request->array('tags')))
->orderByDesc('scheduled_at')
Expand Down
16 changes: 16 additions & 0 deletions src/Models/Component.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
* @method static Builder<static>|static enabled()
* @method static Builder<static>|static outage()
* @method static Builder<static>|static status(ComponentStatusEnum $status)
* @method static Builder<static>|static visibleTo(bool $authenticated)
* @method static ComponentFactory factory($count = null, $state = [])
*/
class Component extends Model implements Metable
Expand Down Expand Up @@ -242,6 +243,21 @@ public function scopeOutage(Builder $query): void
$query->whereIn('status', ComponentStatusEnum::outage());
}

/**
* Scope components to those visible to the current caller.
*/
public function scopeVisibleTo(Builder $query, bool $authenticated): void
{
$visibleGroups = ComponentGroup::query()->visible($authenticated)->select('id');

$query
->unless($authenticated, fn (Builder $query) => $query->where('enabled', true))
->where(function (Builder $query) use ($visibleGroups): void {
$query->whereNull('component_group_id')
->orWhereIn('component_group_id', $visibleGroups);
});
}

/**
* Get the latest unresolved incident affecting the component.
*
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/Api/IncidentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@
$response->assertJsonPath('data.attributes.id', $incident->id);
});

it('does not reveal component groups hidden from guests through incident includes', function () {
it('does not reveal components in hidden groups through incident includes', function () {
$hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]);
$component = Component::factory()->for($hiddenGroup, 'group')->create();
$incident = Incident::factory()->create();
Expand All @@ -602,7 +602,7 @@

$included = collect($response->json('included'));

expect($included->firstWhere('id', (string) $component->id))->not->toBeNull()
expect($included->firstWhere('id', (string) $component->id))->toBeNull()
->and($included->firstWhere('type', 'componentGroups'))->toBeNull();
});

Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/Api/ScheduleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@
]);
});

it('does not reveal component groups hidden from guests through schedule includes', function () {
it('does not reveal components in hidden groups through schedule includes', function () {
$hiddenGroup = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]);
$component = Component::factory()->for($hiddenGroup, 'group')->create();
$schedule = Schedule::factory()->create();
Expand All @@ -575,6 +575,6 @@

$included = collect($response->json('included'));

expect($included->firstWhere('id', (string) $component->id))->not->toBeNull()
expect($included->firstWhere('id', (string) $component->id))->toBeNull()
->and($included->firstWhere('type', 'componentGroups'))->toBeNull();
});
12 changes: 12 additions & 0 deletions tests/Feature/Mcp/Tools/IncidentToolsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Cachet\Mcp\Tools\IncidentUpdates\EditIncidentUpdate;
use Cachet\Mcp\Tools\IncidentUpdates\RecordIncidentUpdate;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Cachet\Models\Incident;
use Cachet\Models\IncidentTemplate;
use Illuminate\Testing\Fluent\AssertableJson;
Expand Down Expand Up @@ -305,6 +306,17 @@
->assertSee('Internal Incident');
});

it('does not reveal components in hidden groups through incidents', function () {
$group = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]);
$component = Component::factory()->for($group, 'group')->create(['name' => 'Hidden Component']);
$incident = Incident::factory()->create();
$incident->components()->attach($component, ['component_status' => ComponentStatusEnum::major_outage]);

CachetServer::tool(GetIncident::class, ['id' => $incident->id])
->assertOk()
->assertDontSee('Hidden Component');
});

it('overlays the displayed component status while an incident is unresolved', function () {
Sanctum::actingAs(User::factory()->create(), ['incidents.manage']);

Expand Down
48 changes: 48 additions & 0 deletions tests/Feature/Mcp/Tools/ScheduleToolsTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use Cachet\Enums\ComponentStatusEnum;
use Cachet\Enums\ResourceVisibilityEnum;
use Cachet\Enums\ScheduleStatusEnum;
use Cachet\Mcp\CachetServer;
use Cachet\Mcp\Tools\Schedules\CreateSchedule;
Expand All @@ -10,6 +12,8 @@
use Cachet\Mcp\Tools\ScheduleUpdates\DeleteScheduleUpdate;
use Cachet\Mcp\Tools\ScheduleUpdates\EditScheduleUpdate;
use Cachet\Mcp\Tools\ScheduleUpdates\RecordScheduleUpdate;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Cachet\Models\Schedule;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Sanctum\Sanctum;
Expand All @@ -25,6 +29,19 @@
->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc());
});

it('does not list unpublished schedules for guests', function () {
Schedule::factory()->create(['name' => 'Published Maintenance']);
Schedule::factory()->create([
'name' => 'Embargoed Maintenance',
'published_at' => now()->addDay(),
]);

CachetServer::tool(ListSchedules::class)
->assertOk()
->assertSee('Published Maintenance')
->assertDontSee('Embargoed Maintenance');
});

it('gets a schedule by id', function () {
$schedule = Schedule::factory()->create(['name' => 'Database Upgrade']);

Expand All @@ -33,6 +50,37 @@
->assertSee('Database Upgrade');
});

it('does not reveal an unpublished schedule to guests by id', function () {
$schedule = Schedule::factory()->create(['published_at' => now()->addDay()]);

CachetServer::tool(GetSchedule::class, ['id' => $schedule->id])
->assertHasErrors(["Schedule [{$schedule->id}] not found."]);
});

it('reveals unpublished schedules to callers that can manage schedules', function () {
Sanctum::actingAs(User::factory()->create(), ['schedules.manage']);

$schedule = Schedule::factory()->create([
'name' => 'Embargoed Maintenance',
'published_at' => now()->addDay(),
]);

CachetServer::tool(GetSchedule::class, ['id' => $schedule->id])
->assertOk()
->assertSee('Embargoed Maintenance');
});

it('does not reveal components in hidden groups through schedules', function () {
$group = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]);
$component = Component::factory()->for($group, 'group')->create(['name' => 'Hidden Component']);
$schedule = Schedule::factory()->create();
$schedule->components()->attach($component, ['component_status' => ComponentStatusEnum::under_maintenance]);

CachetServer::tool(GetSchedule::class, ['id' => $schedule->id])
->assertOk()
->assertDontSee('Hidden Component');
});

it('returns an error for an unknown schedule', function () {
CachetServer::tool(GetSchedule::class, ['id' => 999])
->assertHasErrors(['Schedule [999] not found.']);
Expand Down
4 changes: 4 additions & 0 deletions tests/Feature/Publishing/SchedulePublishingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@
Schedule::factory()->create(['name' => 'Published now']);
Schedule::factory()->scheduled()->create(['name' => 'Prescheduled']);

Sanctum::actingAs(User::factory()->create(), ['schedules.manage']);

CachetServer::tool(ListSchedules::class)
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc());
Expand All @@ -101,6 +103,8 @@
it('exposes a single unpublished schedule to the mcp server', function () {
$schedule = Schedule::factory()->scheduled()->create(['name' => 'Prescheduled']);

Sanctum::actingAs(User::factory()->create(), ['schedules.manage']);

CachetServer::tool(GetSchedule::class, ['id' => $schedule->id])
->assertOk()
->assertSee('Prescheduled');
Expand Down
Loading
Loading