From 461a00c06e98df35c24b1ca2b2f3a267cf88ca85 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Fri, 2 Oct 2026 18:28:39 +0200 Subject: [PATCH] Protect private project activity --- app/Http/Controllers/WidgetController.php | 11 +++-- app/Livewire/Activity.php | 11 +++-- app/Livewire/Welcome/RecentActivity.php | 11 +++-- tests/Feature/Livewire/ActivityTest.php | 16 +++++++ .../Livewire/Welcome/RecentActivityTest.php | 16 +++++++ tests/Feature/Widget/WidgetTest.php | 42 +++++++++++++++++++ 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/WidgetController.php b/app/Http/Controllers/WidgetController.php index 957a5cf5..47c8314d 100644 --- a/app/Http/Controllers/WidgetController.php +++ b/app/Http/Controllers/WidgetController.php @@ -221,10 +221,14 @@ public function activityList(Request $request): JsonResponse $search = $request->input('search', ''); $paginator = Activity::query() - ->with(['causer', 'subject.user', 'subject.comments']) + ->with([ + 'causer', + 'subject.user', + 'subject.comments' => fn ($query) => $query->public(), + ]) ->where(function (Builder $query) use ($search) { $query->whereHasMorph('subject', ['App\Models\Item'], function (Builder $query) use ($search) { - $query->where('private', false); + $query->visibleForCurrentUser(); // Add search filter for items if (!empty($search)) { @@ -232,7 +236,8 @@ public function activityList(Request $request): JsonResponse } }) ->orWhereHasMorph('subject', ['App\Models\Comment'], function (Builder $query) use ($search) { - $query->where('private', false); + $query->public() + ->whereHas('item', fn ($itemQuery) => $itemQuery->visibleForCurrentUser()); // For comments, search the related item title if (!empty($search)) { diff --git a/app/Livewire/Activity.php b/app/Livewire/Activity.php index 8349e26b..f9d5d09a 100644 --- a/app/Livewire/Activity.php +++ b/app/Livewire/Activity.php @@ -24,13 +24,18 @@ public function table(Table $table): Table return $table ->query( ActivityModel::query() - ->with(['causer', 'subject.user', 'subject.comments']) + ->with([ + 'causer', + 'subject.user', + 'subject.comments' => fn ($query) => $query->public(), + ]) ->where(function (Builder $query) { $query->whereHasMorph('subject', ['App\Models\Item'], function (Builder $query) { - $query->where('private', false); + $query->visibleForCurrentUser(); }) ->orWhereHasMorph('subject', ['App\Models\Comment'], function (Builder $query) { - $query->where('private', false); + $query->public() + ->whereHas('item', fn ($itemQuery) => $itemQuery->visibleForCurrentUser()); }); }) ->whereNotNull('causer_id') diff --git a/app/Livewire/Welcome/RecentActivity.php b/app/Livewire/Welcome/RecentActivity.php index 0f26f8a9..079ecea5 100644 --- a/app/Livewire/Welcome/RecentActivity.php +++ b/app/Livewire/Welcome/RecentActivity.php @@ -24,13 +24,18 @@ public function table(Table $table): Table return $table ->query( Activity::query() - ->with(['causer', 'subject.user', 'subject.comments']) + ->with([ + 'causer', + 'subject.user', + 'subject.comments' => fn ($query) => $query->public(), + ]) ->where(function (Builder $query) { $query->whereHasMorph('subject', ['App\Models\Item'], function (Builder $query) { - $query->where('private', false); + $query->visibleForCurrentUser(); }) ->orWhereHasMorph('subject', ['App\Models\Comment'], function (Builder $query) { - $query->where('private', false); + $query->public() + ->whereHas('item', fn ($itemQuery) => $itemQuery->visibleForCurrentUser()); }); }) ->whereNotNull('causer_id') diff --git a/tests/Feature/Livewire/ActivityTest.php b/tests/Feature/Livewire/ActivityTest.php index 3c667c55..7d56ade6 100644 --- a/tests/Feature/Livewire/ActivityTest.php +++ b/tests/Feature/Livewire/ActivityTest.php @@ -2,6 +2,7 @@ use App\Models\Item; use Livewire\Livewire; +use App\Models\Project; use App\Livewire\Activity; use App\Enums\ItemActivity; @@ -95,3 +96,18 @@ $response->assertSee('Created the item'); $response->assertDontSee('Made item private'); }); + +test('activity page does not show activities for items in private projects', function () { + $user = createAndLoginUser(); + $project = Project::factory()->private()->create(); + $item = Item::factory()->create([ + 'project_id' => $project->id, + 'title' => 'Private Project Activity', + 'user_id' => $user->id, + ]); + + ItemActivity::createForItem($item, ItemActivity::Created); + + $this->get(route('activity')) + ->assertDontSee('Private Project Activity'); +}); diff --git a/tests/Feature/Livewire/Welcome/RecentActivityTest.php b/tests/Feature/Livewire/Welcome/RecentActivityTest.php index 2322315f..6f6a3f9f 100644 --- a/tests/Feature/Livewire/Welcome/RecentActivityTest.php +++ b/tests/Feature/Livewire/Welcome/RecentActivityTest.php @@ -4,6 +4,7 @@ use App\Models\User; use Livewire\Livewire; use App\Models\Comment; +use App\Models\Project; use App\Enums\ItemActivity; use Spatie\Activitylog\Models\Activity; use App\Livewire\Welcome\RecentActivity; @@ -55,6 +56,21 @@ $component->assertDontSee('Made item private'); }); +test('recent activity does not show activities for items in private projects', function () { + $user = createAndLoginUser(); + $project = Project::factory()->private()->create(); + $item = Item::factory()->create([ + 'project_id' => $project->id, + 'title' => 'Private Project Recent Activity', + 'user_id' => $user->id, + ]); + + ItemActivity::createForItem($item, ItemActivity::Created); + + Livewire::test(RecentActivity::class) + ->assertDontSee('Private Project Recent Activity'); +}); + test('recent activity does not show activities without causer', function () { $user = createAndLoginUser(); diff --git a/tests/Feature/Widget/WidgetTest.php b/tests/Feature/Widget/WidgetTest.php index bd3642af..676dac40 100644 --- a/tests/Feature/Widget/WidgetTest.php +++ b/tests/Feature/Widget/WidgetTest.php @@ -1,7 +1,11 @@ settings->save(); }); +test('activity widget excludes private project items and private note counts', function () { + $activitySettings = app(ActivityWidgetSettings::class); + $activitySettings->enabled = true; + $activitySettings->allowed_domains = []; + $activitySettings->save(); + + $user = createUser(); + $this->actingAs($user); + $privateProject = Project::factory()->private()->create(); + $privateProjectItem = Item::factory()->create([ + 'project_id' => $privateProject->id, + 'title' => 'Private Project Widget Activity', + 'user_id' => $user->id, + ]); + $publicItem = Item::factory()->create([ + 'title' => 'Public Widget Activity', + 'user_id' => $user->id, + ]); + + Comment::factory()->create(['item_id' => $publicItem->id, 'private' => false]); + Comment::factory()->create(['item_id' => $publicItem->id, 'private' => true]); + ItemActivity::createForItem($privateProjectItem, ItemActivity::Created); + ItemActivity::createForItem($publicItem, ItemActivity::Created); + + $response = $this->getJson('/api/activity-widget/activities'); + + $response->assertSuccessful(); + + expect(json_encode($response->json(), JSON_THROW_ON_ERROR)) + ->not->toContain('Private Project Widget Activity'); + + $publicActivity = collect($response->json('activities')) + ->first(fn (array $activity) => str_contains($activity['description'], 'Public Widget Activity')); + + expect($publicActivity)->not->toBeNull() + ->and($publicActivity['comments'])->toBe(1); +}); + test('widget config endpoint returns configuration when enabled', function () { $response = $this->getJson('/api/widget/config');