board -> project), failing closed * on the shared canvas tables. The pre-existing userCanAccessCanvasItem checks are migrated onto the * permission engine. */ class IdeasServiceTest extends TestCase { use \Codeception\Test\Feature\Stub; private const SESSION_USER = 5; protected function setUp(): void { parent::setUp(); session(['userdata.id' => self::SESSION_USER]); } private function makeService( ?IdeasRepository $ideasRepo = null, ?CommentRepository $commentsRepo = null, ?LanguageCore $language = null, ?PermissionService $perms = null, ): IdeaService { $service = new IdeaService( $ideasRepo ?? $this->make(IdeasRepository::class), $commentsRepo ?? $this->make(CommentRepository::class), $this->make(ProjectService::class), $this->make(TicketService::class), $language ?? $this->make(LanguageCore::class), ); $service->setPermissionService($perms ?? $this->allowingPermissions()); return $service; } /** A repo whose board #3 / item #5 both resolve to project 9. */ private function ideaRepoInProject9(array $overrides = []): IdeasRepository { return $this->make(IdeasRepository::class, array_merge([ 'getSingleCanvas' => fn () => [['id' => 3, 'projectId' => 9, 'title' => 'Board']], 'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'], ], $overrides)); } private function allowingPermissions(): PermissionService { return $this->make(PermissionService::class, [ 'authorize' => fn () => null, 'currentUserCan' => fn () => true, ]); } private function denyingPermissions(): PermissionService { return $this->make(PermissionService::class, [ 'authorize' => function (): void { throw new AuthorizationException; }, 'currentUserCan' => fn () => false, ]); } // --------------------------------------------------------------------- // Item factory / normalization (behavioural). // --------------------------------------------------------------------- public function test_get_idea_item_returns_empty_default_for_null_id(): void { $item = $this->makeService()->getIdeaItem(null, 'research'); $this->assertSame('', $item['id']); $this->assertSame('research', $item['box']); $this->assertSame('idea', $item['status']); $this->assertSame('', $item['milestoneId']); } public function test_get_idea_item_defaults_type_to_idea(): void { $this->assertSame('idea', $this->makeService()->getIdeaItem(null)['box']); } public function test_get_idea_item_normalizes_zero_box_to_idea(): void { $repo = $this->ideaRepoInProject9([ 'getSingleCanvasItem' => fn () => ['id' => 5, 'box' => '0', 'canvasId' => 3, 'description' => 'x'], ]); $item = $this->makeService(ideasRepo: $repo)->getIdeaItem(5); $this->assertSame('idea', $item['box']); $this->assertSame(5, $item['id']); } public function test_get_idea_item_keeps_non_zero_box(): void { $repo = $this->ideaRepoInProject9([ 'getSingleCanvasItem' => fn () => ['id' => 7, 'box' => 'prototype', 'canvasId' => 3], ]); $this->assertSame('prototype', $this->makeService(ideasRepo: $repo)->getIdeaItem(7)['box']); } public function test_ensure_board_exists_returns_zero_when_boards_present(): void { $addCalls = 0; $repo = $this->make(IdeasRepository::class, [ 'addCanvas' => function () use (&$addCalls) { $addCalls++; return '99'; }, ]); $this->assertSame(0, $this->makeService(ideasRepo: $repo)->ensureBoardExists(1, 2, [['id' => 10]])); $this->assertSame(0, $addCalls, 'No board should be created when one already exists'); } public function test_ensure_board_exists_creates_default_when_none(): void { $repo = $this->make(IdeasRepository::class, ['addCanvas' => fn () => '99']); $language = $this->make(LanguageCore::class, ['__' => fn () => 'Board']); $this->assertSame(99, $this->makeService(ideasRepo: $repo, language: $language)->ensureBoardExists(1, 2, [])); } public function test_get_all_boards_normalizes_false_to_empty_array(): void { $repo = $this->make(IdeasRepository::class, ['getAllCanvas' => fn () => false]); $this->assertSame([], $this->makeService(ideasRepo: $repo)->getAllBoards(1)); } public function test_get_board_items_normalizes_false_to_empty_array(): void { $repo = $this->ideaRepoInProject9(['getCanvasItemsById' => fn () => false]); $this->assertSame([], $this->makeService(ideasRepo: $repo)->getBoardItems(3)); } public function test_get_board_title_returns_empty_when_not_found(): void { $repo = $this->make(IdeasRepository::class, ['getSingleCanvas' => fn () => false]); $this->assertSame('', $this->makeService(ideasRepo: $repo)->getBoardTitle(123)); } public function test_get_board_title_returns_first_row_title(): void { $repo = $this->make(IdeasRepository::class, [ 'getSingleCanvas' => fn () => [['id' => 1, 'title' => 'My Board', 'projectId' => 9]], ]); $this->assertSame('My Board', $this->makeService(ideasRepo: $repo)->getBoardTitle(1)); } // --------------------------------------------------------------------- // Read fences (single-entity-by-id). // --------------------------------------------------------------------- public function test_get_board_is_denied_for_a_foreign_project(): void { $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->getBoard(3); } public function test_get_board_items_is_denied_for_a_foreign_project(): void { $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->getBoardItems(3); } // --------------------------------------------------------------------- // Mutation fences (fail closed + project-scoped). // --------------------------------------------------------------------- public function test_patch_idea_item_is_denied_without_edit(): void { $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->patchIdeaItem(5, ['box' => 'done']); } public function test_patch_idea_item_allowed_with_edit(): void { $repo = $this->ideaRepoInProject9(['patchCanvasItem' => fn () => true]); $this->assertTrue($this->makeService(ideasRepo: $repo)->patchIdeaItem(5, ['box' => 'done'])); } public function test_patch_idea_item_fails_closed_for_unknown_item(): void { $patched = false; $repo = $this->make(IdeasRepository::class, [ 'getSingleCanvasItem' => fn () => false, 'patchCanvasItem' => function () use (&$patched): bool { $patched = true; return true; }, ]); $this->assertFalse($this->makeService(ideasRepo: $repo)->patchIdeaItem(999, ['box' => 'done'])); $this->assertFalse($patched, 'A non-idea/unknown id must never reach the repo patch'); } public function test_update_idea_item_fails_closed_for_unknown_item(): void { $edited = false; $repo = $this->make(IdeasRepository::class, [ 'getSingleCanvasItem' => fn () => false, 'editCanvasItem' => function () use (&$edited): void { $edited = true; }, ]); $input = ['itemId' => 999, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 3, 'milestoneId' => '']; $this->assertSame(0, $this->makeService(ideasRepo: $repo)->updateIdeaItem($input, 9, self::SESSION_USER)); $this->assertFalse($edited, 'A non-idea/unknown id must never reach the repo edit'); } public function test_update_idea_item_is_denied_without_edit(): void { $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions()); $input = ['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 3, 'milestoneId' => '']; $this->expectException(AuthorizationException::class); $service->updateIdeaItem($input, 9, self::SESSION_USER); } public function test_create_idea_item_is_denied_without_create(): void { $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->createIdeaItem(['box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'canvasId' => 3], 9, self::SESSION_USER); } public function test_create_board_is_denied_without_create(): void { $service = $this->makeService(perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->createBoard('New board', 9, self::SESSION_USER); } public function test_update_board_is_denied_without_edit(): void { $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->updateBoard(3, 'Renamed'); } public function test_update_board_fails_closed_for_unknown_board(): void { $updated = false; $repo = $this->make(IdeasRepository::class, [ 'getSingleCanvas' => fn () => [], 'updateCanvas' => function () use (&$updated) { $updated = true; return 1; }, ]); $this->assertFalse($this->makeService(ideasRepo: $repo)->updateBoard(999, 'Renamed')); $this->assertFalse($updated, 'A non-idea/unknown board id must never reach the repo update'); } public function test_delete_canvas_is_denied_and_does_not_delete(): void { $repo = $this->ideaRepoInProject9([ 'deleteCanvas' => function (): void { throw new \RuntimeException('delete must not run when denied'); }, ]); $service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->deleteCanvas(3); } public function test_delete_canvas_item_is_denied_and_does_not_delete(): void { $repo = $this->ideaRepoInProject9([ 'delCanvasItem' => function (): void { throw new \RuntimeException('delete must not run when denied'); }, ]); $service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->deleteCanvasItem(5); } // --------------------------------------------------------------------- // Batch mutators reject (return false) without writing. // --------------------------------------------------------------------- public function test_reorder_ideas_rejects_batch_when_cannot_edit(): void { $sorted = false; $repo = $this->ideaRepoInProject9([ 'updateIdeaSorting' => function () use (&$sorted): bool { $sorted = true; return true; }, ]); $service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions()); $this->assertFalse($service->reorderIdeas([['id' => 5, 'sortIndex' => 1]])); $this->assertFalse($sorted, 'A denied batch must never reach the repo sort'); } public function test_bulk_update_status_rejects_batch_when_cannot_edit(): void { $repo = $this->ideaRepoInProject9(['bulkUpdateIdeaStatus' => fn () => true]); $service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions()); $this->assertFalse($service->bulkUpdateStatus(['done' => 'item[]=5'])); } // --------------------------------------------------------------------- // Comment delete: author allowed; non-author requires moderation. // --------------------------------------------------------------------- public function test_remove_idea_comment_allows_the_author_without_moderation(): void { $deleted = null; $commentsRepo = $this->make(CommentRepository::class, [ 'getComment' => fn () => ['id' => 1, 'userId' => self::SESSION_USER, 'moduleId' => 5], 'deleteComment' => function ($id) use (&$deleted): bool { $deleted = $id; return true; }, ]); // Denying engine proves the author path does NOT require comments.moderate. $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), commentsRepo: $commentsRepo, perms: $this->denyingPermissions()); $service->removeIdeaComment(1); $this->assertSame(1, $deleted); } public function test_remove_idea_comment_denies_non_author_without_moderation(): void { $commentsRepo = $this->make(CommentRepository::class, [ 'getComment' => fn () => ['id' => 1, 'userId' => 7, 'moduleId' => 5], 'deleteComment' => function (): bool { throw new \RuntimeException('delete must not run when moderation is denied'); }, ]); $service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), commentsRepo: $commentsRepo, perms: $this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->removeIdeaComment(1); } // --------------------------------------------------------------------- // Fail-closed on a non-resolving (non-idea) id: never authorize against a null project, never // fall back to the caller-supplied project. (A null project here means "not an idea entity".) // --------------------------------------------------------------------- public function test_get_idea_comments_fails_closed_for_non_idea_entity(): void { $fetched = false; $repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]); $commentsRepo = $this->make(CommentRepository::class, [ 'getComments' => function () use (&$fetched) { $fetched = true; return []; }, ]); // Denying engine: if it reached authorize(VIEW, null) it would throw; fail-closed returns [] first. $service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo, perms: $this->denyingPermissions()); $this->assertSame([], $service->getIdeaComments('ticket', 123)); $this->assertFalse($fetched, 'A non-idea entity must short-circuit before the comment read'); } public function test_create_idea_item_fails_closed_for_non_idea_board(): void { $created = false; $repo = $this->make(IdeasRepository::class, [ 'getSingleCanvas' => fn () => [], // canvasId is not an idea board 'addCanvasItem' => function () use (&$created) { $created = true; return '1'; }, ]); $service = $this->makeService(ideasRepo: $repo); $this->assertSame(0, $service->createIdeaItem(['box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'canvasId' => 999], 9, self::SESSION_USER)); $this->assertFalse($created, 'A non-idea board canvasId must never create an item against the caller project'); } public function test_add_idea_comment_fails_closed_for_non_idea_item(): void { $added = false; $repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]); $commentsRepo = $this->make(CommentRepository::class, [ 'addComment' => function () use (&$added) { $added = true; return '1'; }, ]); $service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo); $this->assertFalse($service->addIdeaComment('hi', 999, 0, 9, self::SESSION_USER)); $this->assertFalse($added, 'A non-idea item must never receive a comment against the caller project'); } public function test_remove_idea_comment_fails_closed_for_non_author_non_idea_comment(): void { $deleted = false; $repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]); $commentsRepo = $this->make(CommentRepository::class, [ 'getComment' => fn () => ['id' => 1, 'userId' => 7, 'moduleId' => 999], 'deleteComment' => function () use (&$deleted): bool { $deleted = true; return true; }, ]); // Allowing engine: proves the refusal is the fail-closed null guard, not a denied authorize. $service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo, perms: $this->allowingPermissions()); $service->removeIdeaComment(1); $this->assertFalse($deleted, 'A non-author comment on a non-idea item must not be deleted'); } // --------------------------------------------------------------------- // Relocation / mass-assignment fences (Copilot review): the incoming canvasId/params can move // an item to another board, so the target board's project must also be authorized. // --------------------------------------------------------------------- public function test_patch_idea_item_strips_relocation_and_identity_fields(): void { // patchCanvasItem updates any column it receives; canvasId/id/author must be stripped so a // caller can't relocate the item to another board/project or rewrite its identity. $patched = null; $repo = $this->ideaRepoInProject9([ 'patchCanvasItem' => function ($id, $params) use (&$patched): bool { $patched = $params; return true; }, ]); $service = $this->makeService(ideasRepo: $repo); $service->patchIdeaItem(5, ['status' => 'done', 'canvasId' => 999, 'id' => 1, 'author' => 7]); $this->assertArrayNotHasKey('canvasId', $patched); $this->assertArrayNotHasKey('id', $patched); $this->assertArrayNotHasKey('author', $patched); $this->assertSame('done', $patched['status'], 'Legitimate fields still pass through'); } public function test_update_idea_item_denies_relocation_to_a_foreign_board(): void { // Item lives in project 9 (board 3); the edit's canvasId points at board 99 in project 7. // The user may edit project 9 but NOT project 7 -> the relocation is denied before the write. $repo = $this->make(IdeasRepository::class, [ 'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'], 'getSingleCanvas' => fn ($id) => $id === 99 ? [['projectId' => 7]] : [['projectId' => 9]], 'editCanvasItem' => function (): void { throw new \RuntimeException('relocation must be blocked before the write'); }, ]); $perms = $this->make(PermissionService::class, [ 'authorize' => function (string $p, ?int $projectId = null): void { if ($projectId === 7) { throw new AuthorizationException; } }, 'currentUserCan' => fn () => true, ]); $service = $this->makeService(ideasRepo: $repo, perms: $perms); $this->expectException(AuthorizationException::class); $service->updateIdeaItem(['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 99, 'milestoneId' => ''], 9, self::SESSION_USER); } public function test_update_idea_item_fails_closed_when_target_board_is_not_an_idea_board(): void { $edited = false; $repo = $this->make(IdeasRepository::class, [ 'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'], 'getSingleCanvas' => fn ($id) => $id === 3 ? [['projectId' => 9]] : [], // target 99 -> not an idea board 'editCanvasItem' => function () use (&$edited): void { $edited = true; }, ]); $service = $this->makeService(ideasRepo: $repo); $this->assertSame(0, $service->updateIdeaItem(['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 99, 'milestoneId' => ''], 9, self::SESSION_USER)); $this->assertFalse($edited, 'A non-idea target board must never receive the relocated item'); } }