diff --git a/app/Policies/CategoryPolicy.php b/app/Policies/CategoryPolicy.php index 79a0fede1..fccb02a4b 100644 --- a/app/Policies/CategoryPolicy.php +++ b/app/Policies/CategoryPolicy.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Policies; -use Illuminate\Foundation\Auth\User; +use App\Models\User; use TeamTeaTime\Forum\Models\Category; class CategoryPolicy extends \TeamTeaTime\Forum\Policies\CategoryPolicy @@ -19,57 +19,66 @@ class CategoryPolicy extends \TeamTeaTime\Forum\Policies\CategoryPolicy public function manageThreads(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function deleteThreads(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function restoreThreads(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function enableThreads(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function moveThreadsFrom(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function moveThreadsTo(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function lockThreads(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function pinThreads(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } public function view(mixed $user, Category $category): bool { - return $user->hasAnyRole(['Admin', 'Moderator']); + return $this->canViewCategory($user); } public function delete(mixed $user, Category $category): bool { - return $user->hasRole('Admin'); + return $this->isAdmin($user); } - public function edit(User $user, Category $category): bool + public function edit(mixed $user, Category $category): bool { - /** @var \App\Models\User $user */ - return $user->hasRole('Admin'); + return $this->isAdmin($user); + } + + private function isAdmin(mixed $user): bool + { + return $user instanceof User && $user->hasRole('Admin'); + } + + private function canViewCategory(mixed $user): bool + { + return $user instanceof User && $user->hasAnyRole(['Admin', 'Moderator']); } } diff --git a/app/Policies/PostPolicy.php b/app/Policies/PostPolicy.php index c9cbf094a..e6b2c02c8 100644 --- a/app/Policies/PostPolicy.php +++ b/app/Policies/PostPolicy.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Policies; +use App\Models\User; use TeamTeaTime\Forum\Models\Post; class PostPolicy extends \TeamTeaTime\Forum\Policies\PostPolicy @@ -11,18 +12,28 @@ class PostPolicy extends \TeamTeaTime\Forum\Policies\PostPolicy public function edit(mixed $user, Post $post): bool { // Admins can edit any post; users can edit their own - return $user->hasRole('Admin') || ($user->getKey() === $post->author_id); // @phpstan-ignore property.notFound + return $this->isAdmin($user) || $this->isPostAuthor($user, $post); } public function delete(mixed $user, Post $post): bool { // Admins can delete any post; users can delete their own - return $user->hasRole('Admin') || ($user->getKey() === $post->author_id); // @phpstan-ignore property.notFound + return $this->isAdmin($user) || $this->isPostAuthor($user, $post); } public function restore(mixed $user, Post $post): bool { // Admins can restore any post; users can restore their own - return $user->hasRole('Admin') || ($user->getKey() === $post->author_id); // @phpstan-ignore property.notFound + return $this->isAdmin($user) || $this->isPostAuthor($user, $post); + } + + private function isAdmin(mixed $user): bool + { + return $user instanceof User && $user->hasRole('Admin'); + } + + private function isPostAuthor(mixed $user, Post $post): bool + { + return $user instanceof User && $user->getKey() === $post->author_id; } } diff --git a/app/Policies/ThreadPolicy.php b/app/Policies/ThreadPolicy.php index 54699d4b7..43bd1f6c9 100644 --- a/app/Policies/ThreadPolicy.php +++ b/app/Policies/ThreadPolicy.php @@ -4,55 +4,78 @@ declare(strict_types=1); namespace App\Policies; +use App\Models\User; use TeamTeaTime\Forum\Models\Thread; class ThreadPolicy extends \TeamTeaTime\Forum\Policies\ThreadPolicy { public function view(mixed $user, Thread $thread): bool { - // Everyone (including Admin) can view by default - return parent::view($user, $thread); + // Guests can still view threads, but all mutating actions require auth. + return $user === null || parent::view($user, $thread); } public function rename(mixed $user, Thread $thread): bool { // Admins can rename any thread; users can rename their own - return $user->hasRole('Admin') || ($user->getKey() === $thread->author_id); // @phpstan-ignore property.notFound + return $this->isAdmin($user) || $this->isThreadAuthor($user, $thread); } public function reply(mixed $user, Thread $thread): bool { + if (! $user instanceof User) { + return false; + } + // Admins can reply even if locked; otherwise respect lock state - return $user->hasRole('Admin') || (! $thread->locked); + return $this->isAdmin($user) || (! $thread->locked); } public function replyWithoutApproval(mixed $user, Thread $thread): bool { // Admins can reply to unapproved threads; otherwise only thread author - return $user->hasRole('Admin') || ($user->getKey() === $thread->author_id); // @phpstan-ignore property.notFound + return $this->isAdmin($user) || $this->isThreadAuthor($user, $thread); } public function delete(mixed $user, Thread $thread): bool { // Admins can delete any thread; users can delete their own - return $user->hasRole('Admin') || ($user->getKey() === $thread->author_id); // @phpstan-ignore property.notFound + return $this->isAdmin($user) || $this->isThreadAuthor($user, $thread); } public function restore(mixed $user, Thread $thread): bool { // Admins can restore any thread; users can restore their own - return $user->hasRole('Admin') || ($user->getKey() === $thread->author_id); // @phpstan-ignore property.notFound + return $this->isAdmin($user) || $this->isThreadAuthor($user, $thread); } public function deletePosts(mixed $user, Thread $thread): bool { + if (! $user instanceof User) { + return false; + } + // Admins can delete posts in any thread; otherwise fall back to default (true) - return $user->hasRole('Admin') || parent::deletePosts($user, $thread); + return $this->isAdmin($user) || parent::deletePosts($user, $thread); } public function restorePosts(mixed $user, Thread $thread): bool { + if (! $user instanceof User) { + return false; + } + // Admins can restore posts in any thread; otherwise fall back to default (true) - return $user->hasRole('Admin') || parent::restorePosts($user, $thread); + return $this->isAdmin($user) || parent::restorePosts($user, $thread); + } + + private function isAdmin(mixed $user): bool + { + return $user instanceof User && $user->hasRole('Admin'); + } + + private function isThreadAuthor(mixed $user, Thread $thread): bool + { + return $user instanceof User && $user->getKey() === $thread->author_id; } } diff --git a/tests/Unit/Policies/ForumGuestPolicyTest.php b/tests/Unit/Policies/ForumGuestPolicyTest.php new file mode 100644 index 000000000..41b4a115a --- /dev/null +++ b/tests/Unit/Policies/ForumGuestPolicyTest.php @@ -0,0 +1,60 @@ +assertFalse($policy->manageThreads(null, $category)); + $this->assertFalse($policy->deleteThreads(null, $category)); + $this->assertFalse($policy->restoreThreads(null, $category)); + $this->assertFalse($policy->lockThreads(null, $category)); + $this->assertFalse($policy->pinThreads(null, $category)); + $this->assertFalse($policy->moveThreadsFrom(null, $category)); + $this->assertFalse($policy->view(null, $category)); + } + + public function test_guest_thread_actions_are_denied_but_threads_remain_viewable(): void + { + $policy = new ThreadPolicy; + $thread = new Thread([ + 'author_id' => 1, + 'locked' => false, + ]); + + $this->assertTrue($policy->view(null, $thread)); + $this->assertFalse($policy->rename(null, $thread)); + $this->assertFalse($policy->reply(null, $thread)); + $this->assertFalse($policy->replyWithoutApproval(null, $thread)); + $this->assertFalse($policy->delete(null, $thread)); + $this->assertFalse($policy->restore(null, $thread)); + $this->assertFalse($policy->deletePosts(null, $thread)); + $this->assertFalse($policy->restorePosts(null, $thread)); + } + + public function test_guest_post_actions_are_denied_without_throwing(): void + { + $policy = new PostPolicy; + $post = new Post([ + 'author_id' => 1, + ]); + + $this->assertFalse($policy->edit(null, $post)); + $this->assertFalse($policy->delete(null, $post)); + $this->assertFalse($policy->restore(null, $post)); + } +}