From 508761b73fdd36eea5a0cb3bd992ced4f59fc25d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 11 Aug 2026 09:26:48 +0200 Subject: [PATCH 1/2] test(gates): real contract tests for all 41 gate-25 endpoints; gate-26 3 -> 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate-25 (contract-coverage) 41 -> 0 and gate-26 (visual-coverage) 3 -> 1, measured with the gate helpers at ConductionNL/.github@b8c7ead — the SHA the shared quality workflow floats on, which is what this repo's CI actually runs (this repo sets no `hydra-gates-ref`, so it defaults to @main). Nine new PHPUnit contract-test classes, 134 tests, 459 assertions. Every one calls the controller method under test and asserts its wire contract; none of them is an annotation over untested code, and no `@contract exclude` was added. What the tests actually pin, beyond "a 200 comes back": * deny-before-grant on every registered public endpoint — the backing service is asserted NEVER invoked when the caller is anonymous, so an implementation that queried first and filtered afterwards fails. * `GET /api/gebruik` (@PublicPage): a `gebruik-beheerder` is narrowed to their own organisation BEFORE the `_rbac:false` bypass query is issued, and asking for another organisation's `afnemer` is denied outright rather than silently widened (vendor-visibility-rbac REQ-001/REQ-003). * `/api/aangeboden-gebruik/ambtenaar{,/{id}}` (@PublicPage + RBAC bypass): the admin/ambtenaar group check is the only thing between an anonymous caller and every organisation's records — both the empty envelope AND the un-issued query are asserted. * `GET /api/email/config`: the non-admin 403. This endpoint once returned the SMTP password and provider API keys to any authenticated user; a test asserting only "200 for a logged-in user" would have passed on the broken version. * `/api/progress/{id}` and its SSE twin: another user's operation reads as 404 with no `progress` key — the ownership guard, on both variants. * `/api/archimate/download/{fileName}`: five traversal shapes refused 400 with the DI container asserted never consulted, so the guard is proven to run before any filesystem resolution. * `/api/contactpersonen/change-password`: the full ladder — non-admin on another account 403, self-service without the current password 400, wrong current password 403, <10 chars 400, policy-rejected `setPassword()` false surfaced as a failure rather than swallowed. * `/api/preferences/{key}`: the key that reaches IConfig is proven sanitised and `pref_`-namespaced, so `../apps/Password` cannot read another app's user values. Proven in both directions: removing the anonymous guard from `ViewController::getView()` turns the corresponding test red (500 != 401); restored, green. Skip count is unchanged at 25 — the suite goes 528 -> 662 tests with no test passing by being skipped. gate-26: `LifecycleRoadmapView.vue` now has behavioural e2e that asserts the component's OWN surface (root class, h2, intro, refresh control, org selector, and that `.rm-groups` is ABSENT before an organisation is picked). The previous assertion was an OR over two strings that a breadcrumb or the nav entry alone satisfies — it could pass on a page that is not this component. `src/views/organisaties/OrganisatieIndex.vue` is deleted as dead code, not waived: the manifest's `Organisaties` page is now `type: index` with `config.cardComponent: OrganisatieCard` (Phase 8), the file's own docblock names the CnIndexPage `cardComponent` gap as its reason to exist, and that gap is closed. Nothing in src/ imports it and no router or manifest entry names it. Remaining, deliberately NOT waived: `src/views/gemmaviews/GemmaViewIndex.vue` is likewise unreachable, but this repo's own openspec/changes/beta-surface-alignment/proposal.md defers its disposition to a maintainer ("may be dead code or a future menu item"). Writing a `@visual exclude` whose reason is "nothing routes to it" would be a claim about the state of the world that rots the moment someone wires it up, so gate-26 stays at 1 pending that decision rather than being closed with a waiver. --- src/views/organisaties/OrganisatieIndex.vue | 252 -------- ...AanbodControllerAcceptDenyContractTest.php | 350 +++++++++++ ...nGebruikControllerEndpointContractTest.php | 504 +++++++++++++++ ...ersonenControllerUserAdminContractTest.php | 505 +++++++++++++++ .../DashboardControllerContractTest.php | 168 +++++ .../GebruikControllerContractTest.php | 337 ++++++++++ .../PreferencesControllerContractTest.php | 259 ++++++++ ...gsControllerEmailArchiMateContractTest.php | 573 +++++++++++++++++ .../SettingsControllerStatusContractTest.php | 574 ++++++++++++++++++ .../Controller/ViewControllerContractTest.php | 348 +++++++++++ .../spec-coverage/lifecycle-roadmap.spec.ts | 34 +- 11 files changed, 3649 insertions(+), 255 deletions(-) delete mode 100644 src/views/organisaties/OrganisatieIndex.vue create mode 100644 tests/Unit/Controller/AanbodControllerAcceptDenyContractTest.php create mode 100644 tests/Unit/Controller/AangebodenGebruikControllerEndpointContractTest.php create mode 100644 tests/Unit/Controller/ContactpersonenControllerUserAdminContractTest.php create mode 100644 tests/Unit/Controller/DashboardControllerContractTest.php create mode 100644 tests/Unit/Controller/GebruikControllerContractTest.php create mode 100644 tests/Unit/Controller/PreferencesControllerContractTest.php create mode 100644 tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php create mode 100644 tests/Unit/Controller/SettingsControllerStatusContractTest.php create mode 100644 tests/Unit/Controller/ViewControllerContractTest.php diff --git a/src/views/organisaties/OrganisatieIndex.vue b/src/views/organisaties/OrganisatieIndex.vue deleted file mode 100644 index acb7efa4..00000000 --- a/src/views/organisaties/OrganisatieIndex.vue +++ /dev/null @@ -1,252 +0,0 @@ - - - - - - - - - diff --git a/tests/Unit/Controller/AanbodControllerAcceptDenyContractTest.php b/tests/Unit/Controller/AanbodControllerAcceptDenyContractTest.php new file mode 100644 index 00000000..31de3eb3 --- /dev/null +++ b/tests/Unit/Controller/AanbodControllerAcceptDenyContractTest.php @@ -0,0 +1,350 @@ + 200 + * 'Aanbod object not found' -> 404 + * '...Operation not allowed...' -> 403 (the authorisation refusal) + * anything else / thrown -> 500 + * + * A refusal that collapsed into a 500 would be indistinguishable from a server + * fault, and the UI would offer a retry for something that can never succeed — + * so the 403 branch in particular is asserted directly. + * + * @category Test + * @package OCA\SoftwareCatalog\Tests\Unit\Controller + * @author Conduction b.v. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/aanbod-listings/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\AanbodController; +use OCA\SoftwareCatalog\Service\AanbodService; +use OCP\AppFramework\Http; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Contract tests for aanbod#acceptAanbod and aanbod#denyAanbod. + */ +class AanbodControllerAcceptDenyContractTest extends TestCase +{ + + /** + * The mocked aanbod service. + * + * @var AanbodService|MockObject + */ + private AanbodService|MockObject $aanbodService; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + + /** + * Build the controller under test with fresh mocks. + * + * @param array $params Body params the request reports. + * + * @return AanbodController The controller under test. + */ + private function makeController(array $params=[]): AanbodController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + + $this->aanbodService = $this->createMock(AanbodService::class); + $this->userSession = $this->createMock(IUserSession::class); + + return new AanbodController( + 'softwarecatalog', + $request, + $this->userSession, + $this->aanbodService, + $this->createMock(LoggerInterface::class) + ); + + }//end makeController() + + + /** + * Authenticate the session. + * + * @return void + */ + private function withUser(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + }//end withUser() + + + /** + * Both mutating endpoints refuse an anonymous caller before the service is + * reached — nothing may be accepted or deleted without a session. + * + * @param string $method The controller method name. + * + * @return void + * + * @dataProvider mutatingMethodProvider + */ + public function testAnonymousCallerCannotMutate(string $method): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + $this->aanbodService->expects($this->never())->method('acceptAanbod'); + $this->aanbodService->expects($this->never())->method('denyAanbod'); + + $response = $controller->$method('uuid-1'); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testAnonymousCallerCannotMutate() + + + /** + * The two mutating endpoints. + * + * @return array + */ + public static function mutatingMethodProvider(): array + { + return [ + 'acceptAanbod' => ['acceptAanbod'], + 'denyAanbod' => ['denyAanbod'], + ]; + + }//end mutatingMethodProvider() + + + /** + * An empty uuid is a 400 decided by the controller, and the service is not + * asked to accept "everything". + * + * @param string $method The controller method name. + * + * @return void + * + * @dataProvider mutatingMethodProvider + */ + public function testAnEmptyUuidIsRejectedWith400(string $method): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->aanbodService->expects($this->never())->method('acceptAanbod'); + $this->aanbodService->expects($this->never())->method('denyAanbod'); + + $response = $controller->$method(''); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testAnEmptyUuidIsRejectedWith400() + + + /** + * Accept forwards the uuid and returns 200 with the service envelope. + * + * @return void + */ + public function testAcceptReturns200AndTheServiceEnvelope(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->aanbodService->expects($this->once()) + ->method('acceptAanbod') + ->with('uuid-1', $this->isType('array')) + ->willReturn(['success' => true, 'aanbod' => ['uuid' => 'uuid-1']]); + + $response = $controller->acceptAanbod('uuid-1'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($response->getData()['success']); + + }//end testAcceptReturns200AndTheServiceEnvelope() + + + /** + * The path parameter is not forwarded as a body option — a `uuid` key in + * the options would shadow the path value inside the service. + * + * @return void + */ + public function testAcceptStripsThePathParameterFromTheForwardedOptions(): void + { + $controller = $this->makeController(['uuid' => 'uuid-other', 'reason' => 'ok']); + $this->withUser(); + + $this->aanbodService->expects($this->once()) + ->method('acceptAanbod') + ->with('uuid-1', ['reason' => 'ok']) + ->willReturn(['success' => true]); + + $controller->acceptAanbod('uuid-1'); + + }//end testAcceptStripsThePathParameterFromTheForwardedOptions() + + + /** + * A missing aanbod is 404, not 500. + * + * @return void + */ + public function testAcceptMapsAMissingAanbodTo404(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->aanbodService->method('acceptAanbod') + ->willReturn(['success' => false, 'error' => 'Aanbod object not found']); + + $this->assertSame( + Http::STATUS_NOT_FOUND, + $controller->acceptAanbod('uuid-1')->getStatus() + ); + + }//end testAcceptMapsAMissingAanbodTo404() + + + /** + * An authorisation refusal is 403 — distinguishable from a server fault so + * the UI does not offer a pointless retry. + * + * @return void + */ + public function testAcceptMapsAnAuthorisationRefusalTo403(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->aanbodService->method('acceptAanbod') + ->willReturn( + [ + 'success' => false, + 'error' => 'Operation not allowed: active organisation is not the aanbieder', + ] + ); + + $this->assertSame( + Http::STATUS_FORBIDDEN, + $controller->acceptAanbod('uuid-1')->getStatus() + ); + + }//end testAcceptMapsAnAuthorisationRefusalTo403() + + + /** + * Any other failure envelope is a 500. + * + * @return void + */ + public function testAcceptMapsAnUnclassifiedFailureTo500(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->aanbodService->method('acceptAanbod') + ->willReturn(['success' => false, 'error' => 'register write failed']); + + $this->assertSame( + Http::STATUS_INTERNAL_SERVER_ERROR, + $controller->acceptAanbod('uuid-1')->getStatus() + ); + + }//end testAcceptMapsAnUnclassifiedFailureTo500() + + + /** + * Deny returns 200 and reports the deletion in the envelope. + * + * @return void + */ + public function testDenyReturns200AndReportsTheDeletion(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->aanbodService->expects($this->once()) + ->method('denyAanbod') + ->with('uuid-1', $this->isType('array')) + ->willReturn(['success' => true, 'deleted' => true]); + + $response = $controller->denyAanbod('uuid-1'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($response->getData()['deleted']); + + }//end testDenyReturns200AndReportsTheDeletion() + + + /** + * Deny maps a refusal to 403 rather than deleting anything. + * + * @return void + */ + public function testDenyMapsAnAuthorisationRefusalTo403(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->aanbodService->method('denyAanbod') + ->willReturn( + [ + 'success' => false, + 'error' => 'Operation not allowed: active organisation is not the afnemer', + ] + ); + + $this->assertSame( + Http::STATUS_FORBIDDEN, + $controller->denyAanbod('uuid-1')->getStatus() + ); + + }//end testDenyMapsAnAuthorisationRefusalTo403() + + + /** + * A thrown service error is converted into the documented 500 payload. + * + * @return void + */ + public function testDenyConvertsAThrownServiceErrorIntoThe500Payload(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->aanbodService->method('denyAanbod') + ->willThrowException(new \Exception('register down')); + + $response = $controller->denyAanbod('uuid-1'); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($data['success']); + $this->assertStringContainsString('register down', $data['error']); + + }//end testDenyConvertsAThrownServiceErrorIntoThe500Payload() +}//end class diff --git a/tests/Unit/Controller/AangebodenGebruikControllerEndpointContractTest.php b/tests/Unit/Controller/AangebodenGebruikControllerEndpointContractTest.php new file mode 100644 index 00000000..4c0577b5 --- /dev/null +++ b/tests/Unit/Controller/AangebodenGebruikControllerEndpointContractTest.php @@ -0,0 +1,504 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/aangeboden-gebruik-api/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\AangebodenGebruikController; +use OCA\SoftwareCatalog\Service\AangebodenGebruikService; +use OCP\AppFramework\Http; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Contract tests for the ambtenaar reads, the set-self / deny mutations and + * the documentation route. + */ +class AangebodenGebruikControllerEndpointContractTest extends TestCase +{ + + /** + * The mocked aangeboden-gebruik service. + * + * @var AangebodenGebruikService|MockObject + */ + private AangebodenGebruikService|MockObject $gebruikSvc; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + /** + * The mocked group manager. + * + * @var IGroupManager|MockObject + */ + private IGroupManager|MockObject $groupManager; + + + /** + * Build the controller under test with fresh mocks. + * + * @param array $params Query/body params the request reports. + * + * @return AangebodenGebruikController The controller under test. + */ + private function makeController(array $params=[]): AangebodenGebruikController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + $request->method('getParam')->willReturnCallback( + static function (string $key, $default=null) use ($params) { + return ($params[$key] ?? $default); + } + ); + + $this->gebruikSvc = $this->createMock(AangebodenGebruikService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); + + return new AangebodenGebruikController( + 'softwarecatalog', + $request, + $this->userSession, + $this->gebruikSvc, + $this->createMock(LoggerInterface::class), + $this->groupManager + ); + + }//end makeController() + + + /** + * Authenticate the session and declare which groups the user belongs to. + * + * @param array $memberOf The group ids the user is in. + * + * @return void + */ + private function withUserInGroups(array $memberOf): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + $this->groupManager->method('get')->willReturnCallback( + function (string $gid) use ($memberOf, $user) { + $group = $this->createMock(IGroup::class); + $group->method('inGroup')->with($user) + ->willReturn(in_array($gid, $memberOf, true)); + return $group; + } + ); + + }//end withUserInGroups() + + + /** + * Assert the documented empty paginated envelope. + * + * @param array $data The response payload. + * + * @return void + */ + private function assertEmptyPage(array $data): void + { + $this->assertSame([], $data['results']); + $this->assertSame(0, $data['total']); + + }//end assertEmptyPage() + + + /** + * GET /api/aangeboden-gebruik/ambtenaar — an anonymous caller receives the + * empty envelope and the RBAC-bypassing service read is never issued. + * + * @return void + */ + public function testAmbtenaarListDeniesAnonymousWithoutQueryingTheBypass(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + $this->gebruikSvc->expects($this->never())->method('getAllGebruiksForAmbtenaar'); + + $response = $controller->getAllGebruiksForAmbtenaar(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertEmptyPage($response->getData()); + + }//end testAmbtenaarListDeniesAnonymousWithoutQueryingTheBypass() + + + /** + * An authenticated user outside `admin`/`ambtenaar` is denied the same + * way — the bypass query is never issued. + * + * @return void + */ + public function testAmbtenaarListDeniesAnOrdinaryUserWithoutQueryingTheBypass(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikSvc->expects($this->never())->method('getAllGebruiksForAmbtenaar'); + + $response = $controller->getAllGebruiksForAmbtenaar(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertEmptyPage($response->getData()); + + }//end testAmbtenaarListDeniesAnOrdinaryUserWithoutQueryingTheBypass() + + + /** + * A member of `ambtenaar` reaches the bypassing read. + * + * @return void + */ + public function testAmbtenaarListServesAMemberOfTheAmbtenaarGroup(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['ambtenaar']); + + $this->gebruikSvc->expects($this->once()) + ->method('getAllGebruiksForAmbtenaar') + ->willReturn(['results' => [['id' => 'g-1']], 'total' => 1]); + + $response = $controller->getAllGebruiksForAmbtenaar(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(1, $response->getData()['total']); + + }//end testAmbtenaarListServesAMemberOfTheAmbtenaarGroup() + + + /** + * `admin` is the second accepted group. + * + * @return void + */ + public function testAmbtenaarListAlsoServesAdmin(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['admin']); + + $this->gebruikSvc->expects($this->once()) + ->method('getAllGebruiksForAmbtenaar') + ->willReturn(['results' => [], 'total' => 0]); + + $this->assertSame(Http::STATUS_OK, $controller->getAllGebruiksForAmbtenaar()->getStatus()); + + }//end testAmbtenaarListAlsoServesAdmin() + + + /** + * GET /api/aangeboden-gebruik/ambtenaar/{gebruikId} carries the same group + * gate — a single-record read must not be a way around the list gate. + * + * @return void + */ + public function testAmbtenaarSingleReadDeniesAnOrdinaryUserWithoutQueryingTheBypass(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikSvc->expects($this->never())->method('getSingleGebruikForAmbtenaar'); + + $response = $controller->getSingleGebruikForAmbtenaar('g-1'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertEmptyPage($response->getData()); + + }//end testAmbtenaarSingleReadDeniesAnOrdinaryUserWithoutQueryingTheBypass() + + + /** + * An ambtenaar's single read forwards the requested id to the service. + * + * @return void + */ + public function testAmbtenaarSingleReadForwardsTheRequestedId(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['ambtenaar']); + + $this->gebruikSvc->expects($this->once()) + ->method('getSingleGebruikForAmbtenaar') + ->with('g-42', $this->isType('array')) + ->willReturn(['results' => [['id' => 'g-42']], 'total' => 1]); + + $this->assertSame(Http::STATUS_OK, $controller->getSingleGebruikForAmbtenaar('g-42')->getStatus()); + + }//end testAmbtenaarSingleReadForwardsTheRequestedId() + + + /** + * A service-level error on the single read is surfaced as a 500 rather + * than a 200 carrying an `error` key the caller may not inspect. + * + * @return void + */ + public function testAmbtenaarSingleReadSurfacesAServiceErrorAs500(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['ambtenaar']); + $this->gebruikSvc->method('getSingleGebruikForAmbtenaar') + ->willReturn(['error' => 'register unavailable']); + + $this->assertSame( + Http::STATUS_INTERNAL_SERVER_ERROR, + $controller->getSingleGebruikForAmbtenaar('g-1')->getStatus() + ); + + }//end testAmbtenaarSingleReadSurfacesAServiceErrorAs500() + + + /** + * The two mutating endpoints. + * + * @return array + */ + public static function mutatingMethodProvider(): array + { + return [ + 'setGebruikSelfToActiveOrg' => ['setGebruikSelfToActiveOrg'], + 'deleteGebruikAsAfnemer' => ['deleteGebruikAsAfnemer'], + ]; + + }//end mutatingMethodProvider() + + + /** + * Neither mutation is reachable without a session. + * + * @param string $method The controller method name. + * + * @return void + * + * @dataProvider mutatingMethodProvider + */ + public function testMutationsRejectAnonymousWith401(string $method): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + $this->gebruikSvc->expects($this->never())->method('setGebruikSelfToActiveOrg'); + $this->gebruikSvc->expects($this->never())->method('deleteGebruikAsAfnemer'); + + $response = $controller->$method('g-1'); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testMutationsRejectAnonymousWith401() + + + /** + * An empty id is a controller-side 400 — the service is not asked to + * mutate an unnamed object. + * + * @param string $method The controller method name. + * + * @return void + * + * @dataProvider mutatingMethodProvider + */ + public function testMutationsRejectAnEmptyIdWith400(string $method): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + + $this->gebruikSvc->expects($this->never())->method('setGebruikSelfToActiveOrg'); + $this->gebruikSvc->expects($this->never())->method('deleteGebruikAsAfnemer'); + + $response = $controller->$method(''); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testMutationsRejectAnEmptyIdWith400() + + + /** + * PUT /api/aangeboden-gebruik/{id}/set-self forwards the id, strips the + * path parameter from the body options, and returns 200 on success. + * + * @return void + */ + public function testSetSelfForwardsTheIdAndStripsThePathParameter(): void + { + $controller = $this->makeController(['gebruikId' => 'other', 'note' => 'x']); + $this->withUserInGroups(['users']); + + $this->gebruikSvc->expects($this->once()) + ->method('setGebruikSelfToActiveOrg') + ->with('g-1', ['note' => 'x']) + ->willReturn(['success' => true, 'gebruik' => ['id' => 'g-1']]); + + $this->assertSame(Http::STATUS_OK, $controller->setGebruikSelfToActiveOrg('g-1')->getStatus()); + + }//end testSetSelfForwardsTheIdAndStripsThePathParameter() + + + /** + * A caller who is neither afnemer nor aanbieder gets 403 — distinguishable + * from "missing" and from a server fault. + * + * @return void + */ + public function testSetSelfMapsAnAuthorisationRefusalTo403(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikSvc->method('setGebruikSelfToActiveOrg') + ->willReturn( + [ + 'success' => false, + 'error' => 'Operation not allowed: not the afnemer or aanbieder', + ] + ); + + $this->assertSame( + Http::STATUS_FORBIDDEN, + $controller->setGebruikSelfToActiveOrg('g-1')->getStatus() + ); + + }//end testSetSelfMapsAnAuthorisationRefusalTo403() + + + /** + * A missing object is 404. + * + * @return void + */ + public function testSetSelfMapsAMissingObjectTo404(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikSvc->method('setGebruikSelfToActiveOrg') + ->willReturn(['success' => false, 'error' => 'Gebruik object not found']); + + $this->assertSame( + Http::STATUS_NOT_FOUND, + $controller->setGebruikSelfToActiveOrg('g-1')->getStatus() + ); + + }//end testSetSelfMapsAMissingObjectTo404() + + + /** + * DELETE /api/aangeboden-gebruik/{id}/deny reports the deletion on success + * and refuses with 403 when the caller is not a party to the record. + * + * @return void + */ + public function testDenyDeletesForAPartyAndRefusesEveryoneElseWith403(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikSvc->expects($this->once()) + ->method('deleteGebruikAsAfnemer') + ->with('g-1', $this->isType('array')) + ->willReturn(['success' => true, 'deleted' => true]); + + $response = $controller->deleteGebruikAsAfnemer('g-1'); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($response->getData()['deleted']); + + $refused = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikSvc->method('deleteGebruikAsAfnemer') + ->willReturn( + [ + 'success' => false, + 'error' => 'Operation not allowed: not the afnemer', + ] + ); + + $this->assertSame( + Http::STATUS_FORBIDDEN, + $refused->deleteGebruikAsAfnemer('g-1')->getStatus() + ); + + }//end testDenyDeletesForAPartyAndRefusesEveryoneElseWith403() + + + /** + * A thrown deletion reports `deleted: false` in its 500 body, so a client + * never records a delete that did not happen. + * + * @return void + */ + public function testDenyReportsDeletedFalseWhenTheServiceThrows(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikSvc->method('deleteGebruikAsAfnemer') + ->willThrowException(new \Exception('register down')); + + $response = $controller->deleteGebruikAsAfnemer('g-1'); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($data['success']); + $this->assertFalse($data['deleted']); + + }//end testDenyReportsDeletedFalseWhenTheServiceThrows() + + + /** + * GET /api/aangeboden-gebruik/docs is `@PublicPage` and returns only + * static documentation — it must not read any gebruik data, and it must + * describe the routes this controller actually registers. + * + * @return void + */ + public function testApiDocumentationIsStaticAndDescribesTheRegisteredRoutes(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + $this->gebruikSvc->expects($this->never())->method($this->anything()); + + $response = $controller->getApiDocumentation(); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('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/api/aangeboden-gebruik', $data['base_url']); + + $paths = array_column($data['endpoints'], 'path'); + $this->assertContains('/api/aangeboden-gebruik/afnemer', $paths); + + }//end testApiDocumentationIsStaticAndDescribesTheRegisteredRoutes() +}//end class diff --git a/tests/Unit/Controller/ContactpersonenControllerUserAdminContractTest.php b/tests/Unit/Controller/ContactpersonenControllerUserAdminContractTest.php new file mode 100644 index 00000000..35af23a2 --- /dev/null +++ b/tests/Unit/Controller/ContactpersonenControllerUserAdminContractTest.php @@ -0,0 +1,505 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/contactpersonen-api/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\ContactpersonenController; +use OCA\SoftwareCatalog\Service\ContactpersoonService; +use OCA\SoftwareCatalog\Service\SettingsService; +use OCA\SoftwareCatalog\Service\SoftwareCatalogue\ContactPersonHandler; +use OCP\AppFramework\Http; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Contract tests for contactpersonen#changePassword, #getAvailableGroups, + * #getUserInfo and #getBulkUserInfo. + */ +class ContactpersonenControllerUserAdminContractTest extends TestCase +{ + + /** + * The mocked user manager. + * + * @var IUserManager|MockObject + */ + private IUserManager|MockObject $userManager; + + /** + * The mocked group manager. + * + * @var IGroupManager|MockObject + */ + private IGroupManager|MockObject $groupManager; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + /** + * The mocked contactpersoon service. + * + * @var ContactpersoonService|MockObject + */ + private ContactpersoonService|MockObject $contactSvc; + + /** + * The mocked DI container (used to reach OpenRegister's ObjectService). + * + * @var ContainerInterface|MockObject + */ + private ContainerInterface|MockObject $container; + + + /** + * Build the controller under test with fresh mocks. + * + * @return ContactpersonenController The controller under test. + */ + private function makeController(): ContactpersonenController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn([]); + + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->contactSvc = $this->createMock(ContactpersoonService::class); + $this->container = $this->createMock(ContainerInterface::class); + + return new ContactpersonenController( + 'softwarecatalog', + $request, + $this->createMock(SettingsService::class), + $this->createMock(ContactPersonHandler::class), + $this->contactSvc, + $this->userManager, + $this->groupManager, + $this->userSession, + $this->container, + $this->createMock(ISecureRandom::class), + $this->createMock(LoggerInterface::class) + ); + + }//end makeController() + + + /** + * Authenticate the session. + * + * @param string $uid The uid to report. + * @param bool $isAdmin Whether the group manager reports them as admin. + * @param bool $isOrgAdmin Whether they are in an organisation-admin group. + * + * @return void + */ + private function withUser(string $uid='alice', bool $isAdmin=false, bool $isOrgAdmin=false): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->willReturn($isAdmin); + $this->groupManager->method('isInGroup')->willReturn($isOrgAdmin); + + }//end withUser() + + + /** + * The four endpoints, each rejecting an anonymous caller identically. + * + * @return array}> + */ + public static function anonymousEndpointProvider(): array + { + return [ + 'changePassword' => ['changePassword', ['alice', 'a-long-password']], + 'getAvailableGroups' => ['getAvailableGroups', []], + 'getUserInfo' => ['getUserInfo', ['cp-1']], + 'getBulkUserInfo' => ['getBulkUserInfo', []], + ]; + + }//end anonymousEndpointProvider() + + + /** + * An anonymous caller is refused 401 and neither the user manager nor the + * contactpersoon service is consulted. + * + * @param string $method The controller method name. + * @param array $args Positional arguments for the call. + * + * @return void + * + * @dataProvider anonymousEndpointProvider + */ + public function testAnonymousCallerIsRejectedWith401(string $method, array $args): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + $this->userManager->expects($this->never())->method('get'); + $this->userManager->expects($this->never())->method('checkPassword'); + $this->contactSvc->expects($this->never())->method($this->anything()); + $this->container->expects($this->never())->method('get'); + + $response = $controller->$method(...$args); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testAnonymousCallerIsRejectedWith401() + + + /** + * A non-admin changing SOMEONE ELSE'S password is refused 403, and no + * password is ever set. + * + * @return void + */ + public function testANonAdminCannotChangeAnotherUsersPassword(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false); + + $this->userManager->expects($this->never())->method('get'); + + $response = $controller->changePassword('bob', 'a-long-password', 'whatever'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertSame(['message' => 'Insufficient permissions'], $response->getData()); + + }//end testANonAdminCannotChangeAnotherUsersPassword() + + + /** + * A self-service reset without the current password is refused 400 — the + * confirmation is not optional for a non-admin. + * + * @return void + */ + public function testASelfServiceResetRequiresTheCurrentPassword(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false); + + $this->userManager->expects($this->never())->method('get'); + + $response = $controller->changePassword('alice', 'a-long-password', ''); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testASelfServiceResetRequiresTheCurrentPassword() + + + /** + * A wrong current password is refused 403 and nothing is written. + * + * @return void + */ + public function testAWrongCurrentPasswordIsRefusedWith403(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false); + + $this->userManager->method('checkPassword')->willReturn(false); + $this->userManager->expects($this->never())->method('get'); + + $response = $controller->changePassword('alice', 'a-long-password', 'wrong'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertStringContainsString('incorrect', $response->getData()['message']); + + }//end testAWrongCurrentPasswordIsRefusedWith403() + + + /** + * A correct self-service reset sets the new password. + * + * @return void + */ + public function testACorrectSelfServiceResetSetsTheNewPassword(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false); + + $target = $this->createMock(IUser::class); + $target->expects($this->once())->method('setPassword') + ->with('a-long-password')->willReturn(true); + + $this->userManager->method('checkPassword')->willReturn($this->createMock(IUser::class)); + $this->userManager->method('get')->with('alice')->willReturn($target); + + $response = $controller->changePassword('alice', 'a-long-password', 'right'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($response->getData()['success']); + + }//end testACorrectSelfServiceResetSetsTheNewPassword() + + + /** + * An admin changes another account's password WITHOUT supplying that + * user's current password — the documented admin path. + * + * @return void + */ + public function testAnAdminChangesAnotherAccountWithoutTheCurrentPassword(): void + { + $controller = $this->makeController(); + $this->withUser('root', true); + + $target = $this->createMock(IUser::class); + $target->expects($this->once())->method('setPassword')->willReturn(true); + $this->userManager->method('get')->with('bob')->willReturn($target); + $this->userManager->expects($this->never())->method('checkPassword'); + + $response = $controller->changePassword('bob', 'a-long-password'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + + }//end testAnAdminChangesAnotherAccountWithoutTheCurrentPassword() + + + /** + * A password shorter than the 10-character floor is refused 400. Nextcloud + * silently fails short passwords, so the endpoint rejects them explicitly. + * + * @return void + */ + public function testAShortPasswordIsRefusedWith400(): void + { + $controller = $this->makeController(); + $this->withUser('root', true); + + $target = $this->createMock(IUser::class); + $target->expects($this->never())->method('setPassword'); + $this->userManager->method('get')->willReturn($target); + + $response = $controller->changePassword('bob', 'short'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertStringContainsString('10 characters', $response->getData()['message']); + + }//end testAShortPasswordIsRefusedWith400() + + + /** + * A password the server policy rejects is reported as a 400 failure, not + * as a success — `setPassword()` returning false must not be discarded. + * + * @return void + */ + public function testAPolicyRejectedPasswordIsReportedAsAFailure(): void + { + $controller = $this->makeController(); + $this->withUser('root', true); + + $target = $this->createMock(IUser::class); + $target->method('setPassword')->willReturn(false); + $this->userManager->method('get')->willReturn($target); + + $response = $controller->changePassword('bob', 'a-long-password'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testAPolicyRejectedPasswordIsReportedAsAFailure() + + + /** + * An unknown target account is a 404. + * + * @return void + */ + public function testAnUnknownTargetAccountIs404(): void + { + $controller = $this->makeController(); + $this->withUser('root', true); + $this->userManager->method('get')->willReturn(null); + + $this->assertSame( + Http::STATUS_NOT_FOUND, + $controller->changePassword('ghost', 'a-long-password')->getStatus() + ); + + }//end testAnUnknownTargetAccountIs404() + + + /** + * GET /api/contactpersonen/available-groups lists only the catalog groups + * that actually EXIST on the instance — offering a group that cannot be + * assigned would produce a silent failure downstream. + * + * @return void + */ + public function testAvailableGroupsListsOnlyTheGroupsThatExist(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->groupManager->method('get')->willReturnCallback( + function (string $gid) { + if ($gid === 'gebruik-raadpleger') { + return null; + } + + return $this->createMock(IGroup::class); + } + ); + + $data = $controller->getAvailableGroups()->getData(); + $ids = array_column($data['groups'], 'id'); + + $this->assertTrue($data['success']); + $this->assertContains('gebruik-beheerder', $ids); + $this->assertContains('aanbod-beheerder', $ids); + $this->assertNotContains('gebruik-raadpleger', $ids); + + }//end testAvailableGroupsListsOnlyTheGroupsThatExist() + + + /** + * GET /api/contactpersonen/{id}/user-info refuses a caller who is neither + * an admin nor an organisation admin, without performing the lookup. + * + * @return void + */ + public function testUserInfoRefusesAnOrdinaryUserWithoutLookingAnythingUp(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false, false); + + $this->container->expects($this->never())->method('get'); + + $response = $controller->getUserInfo('cp-1'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertSame(['message' => 'Insufficient permissions'], $response->getData()); + + }//end testUserInfoRefusesAnOrdinaryUserWithoutLookingAnythingUp() + + + /** + * An organisation admin (`gebruik-beheerder` / `aanbod-beheerder`) passes + * the gate — the endpoint is not admin-only. + * + * @return void + */ + public function testUserInfoAdmitsAnOrganisationAdmin(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false, true); + + // Past the gate the OpenRegister lookup happens. Stand in for it with + // an object store that reports "no such contactpersoon", so the + // response distinguishes "you may not ask" (403) from "there is + // nothing to show" (404). + $objectService = new class { + + /** + * Stand-in for OpenRegister's ObjectService::find(). + * + * @param string $id The object id. + * @param string $register The register slug. + * @param string $schema The schema slug. + * + * @return null Always "not found" for this test. + */ + public function find(string $id, string $register, string $schema) + { + return null; + + }//end find() + }; + + $this->container->expects($this->once())->method('get')->willReturn($objectService); + + $response = $controller->getUserInfo('cp-1'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testUserInfoAdmitsAnOrganisationAdmin() + + + /** + * POST /api/contactpersonen/bulk-user-info carries the SAME authorisation + * gate as the single read — a bulk route must not be a way around it. + * + * @return void + */ + public function testBulkUserInfoRefusesAnOrdinaryUserWithoutQuerying(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false, false); + + $this->contactSvc->expects($this->never())->method('getBulkUserInfo'); + + $response = $controller->getBulkUserInfo(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertSame(['message' => 'Insufficient permissions'], $response->getData()); + + }//end testBulkUserInfoRefusesAnOrdinaryUserWithoutQuerying() + + + /** + * An authorised caller supplying no ids gets a 400 — the endpoint does not + * silently fall back to "all contactpersonen". + * + * @return void + */ + public function testBulkUserInfoRefusesAnEmptyIdListWith400(): void + { + $controller = $this->makeController(); + $this->withUser('root', true); + + $this->contactSvc->expects($this->never())->method('getBulkUserInfo'); + + $response = $controller->getBulkUserInfo(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testBulkUserInfoRefusesAnEmptyIdListWith400() +}//end class diff --git a/tests/Unit/Controller/DashboardControllerContractTest.php b/tests/Unit/Controller/DashboardControllerContractTest.php new file mode 100644 index 00000000..1febdf64 --- /dev/null +++ b/tests/Unit/Controller/DashboardControllerContractTest.php @@ -0,0 +1,168 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/dashboard-views-api/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\DashboardController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Contract tests for dashboard#page and dashboard#index. + */ +class DashboardControllerContractTest extends TestCase +{ + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + + /** + * Build the controller under test with fresh mocks. + * + * @return DashboardController The controller under test. + */ + private function makeController(): DashboardController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn([]); + + $this->userSession = $this->createMock(IUserSession::class); + + return new DashboardController( + 'softwarecatalog', + $request, + $this->userSession + ); + + }//end makeController() + + + /** + * GET / renders the `index` template of this app — the single entrypoint + * every SPA route is served from. + * + * @return void + */ + public function testPageRendersTheAppIndexTemplate(): void + { + $controller = $this->makeController(); + + $response = $controller->page(null); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('index', $response->getTemplateName()); + $this->assertSame('softwarecatalog', $response->getApp()); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + + }//end testPageRendersTheAppIndexTemplate() + + + /** + * GET / attaches a Content-Security-Policy that permits the outbound + * connections the bundle makes. Without it the SPA boots and then fails + * every fetch, which reads as a product outage rather than a policy bug. + * + * @return void + */ + public function testPageAttachesAContentSecurityPolicyAllowingAppConnections(): void + { + $controller = $this->makeController(); + + $response = $controller->page(null); + $csp = $response->getContentSecurityPolicy(); + + $this->assertNotNull($csp); + $this->assertStringContainsString('connect-src', $csp->buildPolicy()); + $this->assertStringContainsString('*', $csp->buildPolicy()); + + }//end testPageAttachesAContentSecurityPolicyAllowingAppConnections() + + + /** + * The entrypoint does not depend on the optional query parameter — a + * deep-link with one renders the same template. + * + * @return void + */ + public function testPageIgnoresTheOptionalQueryParameter(): void + { + $controller = $this->makeController(); + + $response = $controller->page('anything'); + + $this->assertSame('index', $response->getTemplateName()); + + }//end testPageIgnoresTheOptionalQueryParameter() + + + /** + * The JSON probe rejects an anonymous caller with 401. + * + * @return void + */ + public function testIndexRejectsAnonymousWith401(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + $response = $controller->index(); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testIndexRejectsAnonymousWith401() + + + /** + * The JSON probe answers an authenticated caller with the documented + * `{results: []}` envelope. + * + * @return void + */ + public function testIndexReturnsTheResultsEnvelopeForAnAuthenticatedCaller(): void + { + $controller = $this->makeController(); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + $response = $controller->index(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['results' => []], $response->getData()); + + }//end testIndexReturnsTheResultsEnvelopeForAnAuthenticatedCaller() +}//end class diff --git a/tests/Unit/Controller/GebruikControllerContractTest.php b/tests/Unit/Controller/GebruikControllerContractTest.php new file mode 100644 index 00000000..884ffe33 --- /dev/null +++ b/tests/Unit/Controller/GebruikControllerContractTest.php @@ -0,0 +1,337 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/vendor-visibility-rbac/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\GebruikController; +use OCA\SoftwareCatalog\Service\GebruikService; +use OCP\AppFramework\Http; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Contract tests for gebruik#getGebruiken and gebruik#getGebruikenForDeelnemer. + */ +class GebruikControllerContractTest extends TestCase +{ + + /** + * The mocked gebruik service. + * + * @var GebruikService|MockObject + */ + private GebruikService|MockObject $gebruikService; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + /** + * The mocked group manager. + * + * @var IGroupManager|MockObject + */ + private IGroupManager|MockObject $groupManager; + + /** + * The mocked config. + * + * @var IConfig|MockObject + */ + private IConfig|MockObject $config; + + + /** + * Build the controller under test with fresh mocks. + * + * @param array $params Query params the request reports. + * + * @return GebruikController The controller under test. + */ + private function makeController(array $params=[]): GebruikController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + + $this->gebruikService = $this->createMock(GebruikService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->config = $this->createMock(IConfig::class); + + return new GebruikController( + 'softwarecatalog', + $request, + $this->userSession, + $this->groupManager, + $this->config, + $this->gebruikService + ); + + }//end makeController() + + + /** + * Authenticate the session as a user in the given groups, belonging to the + * given organisation. + * + * @param array $groups The group ids the user is a member of. + * @param string $orgUuid The organisation uuid on the account. + * + * @return void + */ + private function withUserInGroups(array $groups, string $orgUuid=''): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + $groupMocks = []; + foreach ($groups as $gid) { + $group = $this->createMock(IGroup::class); + $group->method('getGID')->willReturn($gid); + $groupMocks[] = $group; + } + + $this->groupManager->method('getUserGroups')->willReturn($groupMocks); + $this->config->method('getUserValue')->willReturn($orgUuid); + + }//end withUserInGroups() + + + /** + * The documented empty envelope shape, asserted once so every "denied" + * branch below proves it returns the SAME shape a caller can parse. + * + * @param array $data The response payload. + * + * @return void + */ + private function assertEmptyEnvelope(array $data): void + { + $this->assertSame([], $data['results']); + $this->assertSame(0, $data['total']); + $this->assertSame(0, $data['pages']); + $this->assertFalse($data['@self']['rbac']); + + }//end assertEmptyEnvelope() + + + /** + * GET /api/gebruik is a PublicPage: an anonymous caller gets 200 with the + * empty envelope, and the RBAC-bypassing service query is NEVER issued. + * + * @return void + */ + public function testAnonymousGetsTheEmptyEnvelopeAndNeverReachesTheService(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + $this->gebruikService->expects($this->never())->method('getGebruiken'); + + $response = $controller->getGebruiken(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertEmptyEnvelope($response->getData()); + + }//end testAnonymousGetsTheEmptyEnvelopeAndNeverReachesTheService() + + + /** + * An authenticated user in none of the catalogue roles is denied the same + * way — empty envelope, service never invoked. + * + * @return void + */ + public function testARolelessUserIsDeniedBeforeTheServiceIsInvoked(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['users']); + $this->gebruikService->expects($this->never())->method('getGebruiken'); + + $response = $controller->getGebruiken(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertEmptyEnvelope($response->getData()); + + }//end testARolelessUserIsDeniedBeforeTheServiceIsInvoked() + + + /** + * REQ-003: a `gebruik-beheerder` read is narrowed to the caller's own + * organisation BEFORE the bypass query is issued. + * + * @return void + */ + public function testGebruikBeheerderIsScopedToTheirOwnOrganisation(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['gebruik-beheerder'], 'org-alice'); + + $this->gebruikService->expects($this->once()) + ->method('getGebruiken') + ->with($this->callback( + static function (array $options): bool { + return ($options['afnemer'] ?? null) === 'org-alice'; + } + )) + ->willReturn(['results' => [], 'total' => 0]); + + $response = $controller->getGebruiken(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + + }//end testGebruikBeheerderIsScopedToTheirOwnOrganisation() + + + /** + * REQ-003: a `gebruik-beheerder` asking for ANOTHER organisation's afnemer + * is denied outright rather than silently widened or silently narrowed — + * the query is never issued. + * + * @return void + */ + public function testGebruikBeheerderCannotReadAnotherOrganisationsAfnemer(): void + { + $controller = $this->makeController(['afnemer' => 'org-bob']); + $this->withUserInGroups(['gebruik-beheerder'], 'org-alice'); + + $this->gebruikService->expects($this->never())->method('getGebruiken'); + + $response = $controller->getGebruiken(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertEmptyEnvelope($response->getData()); + + }//end testGebruikBeheerderCannotReadAnotherOrganisationsAfnemer() + + + /** + * An `ambtenaar` keeps the unrestricted read: options reach the service + * without an injected `afnemer` narrowing. + * + * @return void + */ + public function testAmbtenaarRetainsTheUnrestrictedRead(): void + { + $controller = $this->makeController(['limit' => 10]); + $this->withUserInGroups(['ambtenaar'], 'org-alice'); + + $this->gebruikService->expects($this->once()) + ->method('getGebruiken') + ->with($this->callback( + static function (array $options): bool { + return (array_key_exists('afnemer', $options) === false + && ($options['limit'] ?? null) === 10); + } + )) + ->willReturn(['results' => [], 'total' => 0]); + + $controller->getGebruiken(); + + }//end testAmbtenaarRetainsTheUnrestrictedRead() + + + /** + * A service failure is reported as a 500 with the error message, not as an + * uncaught exception. + * + * @return void + */ + public function testAServiceFailureIsReportedAs500(): void + { + $controller = $this->makeController(); + $this->withUserInGroups(['admin'], 'org-alice'); + + $this->gebruikService->method('getGebruiken') + ->willThrowException(new \Exception('register down')); + + $response = $controller->getGebruiken(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertSame(['error' => 'register down'], $response->getData()); + + }//end testAServiceFailureIsReportedAs500() + + + /** + * GET /api/gebruik/deelnemer rejects an anonymous caller with 401 and + * never reaches the service. + * + * @return void + */ + public function testDeelnemerEndpointRejectsAnonymousWith401(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + $this->gebruikService->expects($this->never())->method('getGebruiken'); + + $response = $controller->getGebruikenForDeelnemer(); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testDeelnemerEndpointRejectsAnonymousWith401() + + + /** + * GET /api/gebruik/deelnemer forces the caller's OWN organisation into + * `deelnemers`, overriding whatever the query string supplied. + * + * @return void + */ + public function testDeelnemerEndpointForcesTheCallersOwnOrganisation(): void + { + $controller = $this->makeController(['deelnemers' => ['org-bob']]); + $this->withUserInGroups([], 'org-alice'); + + $this->gebruikService->expects($this->once()) + ->method('getGebruiken') + ->with($this->callback( + static function (array $options): bool { + return ($options['deelnemers'] ?? null) === ['org-alice']; + } + )) + ->willReturn(['results' => [], 'total' => 0]); + + $response = $controller->getGebruikenForDeelnemer(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + + }//end testDeelnemerEndpointForcesTheCallersOwnOrganisation() +}//end class diff --git a/tests/Unit/Controller/PreferencesControllerContractTest.php b/tests/Unit/Controller/PreferencesControllerContractTest.php new file mode 100644 index 00000000..2a539513 --- /dev/null +++ b/tests/Unit/Controller/PreferencesControllerContractTest.php @@ -0,0 +1,259 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/dashboard-views-api/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\PreferencesController; +use OCP\AppFramework\Http; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Contract tests for preferences#getPreference and preferences#setPreference. + */ +class PreferencesControllerContractTest extends TestCase +{ + + /** + * The mocked config. + * + * @var IConfig|MockObject + */ + private IConfig|MockObject $config; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + + /** + * Build the controller under test with fresh mocks. + * + * @return PreferencesController The controller under test. + */ + private function makeController(): PreferencesController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn([]); + + $this->config = $this->createMock(IConfig::class); + $this->userSession = $this->createMock(IUserSession::class); + + return new PreferencesController( + $request, + $this->config, + $this->userSession + ); + + }//end makeController() + + + /** + * Authenticate the session. + * + * @return void + */ + private function withUser(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + }//end withUser() + + + /** + * GET /api/preferences/{key} rejects an anonymous caller with 401 and + * never touches IConfig. + * + * @return void + */ + public function testGetPreferenceRejectsAnonymousWith401(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + $this->config->expects($this->never())->method('getUserValue'); + + $response = $controller->getPreference('tour-seen'); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not logged in'], $response->getData()); + + }//end testGetPreferenceRejectsAnonymousWith401() + + + /** + * A key that sanitises to nothing is a 400, and IConfig is not consulted. + * + * @return void + */ + public function testGetPreferenceRejectsAKeyThatSanitisesToNothing(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->config->expects($this->never())->method('getUserValue'); + + $response = $controller->getPreference('///'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertSame(['message' => 'Invalid key'], $response->getData()); + + }//end testGetPreferenceRejectsAKeyThatSanitisesToNothing() + + + /** + * The key that reaches IConfig is lower-cased, stripped to + * `[a-z0-9-]` and prefixed with `pref_`, so a caller cannot escape the + * preference namespace via path traversal or an app-name prefix. + * + * @return void + */ + public function testGetPreferenceNamespacesAndSanitisesTheKeyItReads(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->config->expects($this->once()) + ->method('getUserValue') + ->with('alice', 'softwarecatalog', 'pref_appspassword', '') + ->willReturn(''); + + $response = $controller->getPreference('../apps/Password'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['value' => null], $response->getData()); + + }//end testGetPreferenceNamespacesAndSanitisesTheKeyItReads() + + + /** + * A stored value is returned under the `value` key; an unset preference + * reads back as an explicit null rather than an empty string. + * + * @return void + */ + public function testGetPreferenceReturnsTheStoredValueOrNull(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->config->method('getUserValue')->willReturn('yes'); + + $this->assertSame(['value' => 'yes'], $controller->getPreference('tour-seen')->getData()); + + }//end testGetPreferenceReturnsTheStoredValueOrNull() + + + /** + * POST /api/preferences/{key} rejects an anonymous caller with 401 and + * never writes. + * + * @return void + */ + public function testSetPreferenceRejectsAnonymousWith401(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + $this->config->expects($this->never())->method('setUserValue'); + $this->config->expects($this->never())->method('deleteUserValue'); + + $response = $controller->setPreference('tour-seen', 'yes'); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not logged in'], $response->getData()); + + }//end testSetPreferenceRejectsAnonymousWith401() + + + /** + * An unsafe key is rejected with 400 before any write happens. + * + * @return void + */ + public function testSetPreferenceRejectsAnUnsafeKeyBeforeWriting(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->config->expects($this->never())->method('setUserValue'); + + $response = $controller->setPreference('!!!', 'yes'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + + }//end testSetPreferenceRejectsAnUnsafeKeyBeforeWriting() + + + /** + * A write lands on the sanitised, `pref_`-namespaced key and echoes the + * stored value back. + * + * @return void + */ + public function testSetPreferenceWritesToTheNamespacedKeyAndEchoesTheValue(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->config->expects($this->once()) + ->method('setUserValue') + ->with('alice', 'softwarecatalog', 'pref_tour-seen', 'yes'); + + $response = $controller->setPreference('Tour-Seen', 'yes'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['value' => 'yes'], $response->getData()); + + }//end testSetPreferenceWritesToTheNamespacedKeyAndEchoesTheValue() + + + /** + * An empty value CLEARS the preference (delete, not a stored empty + * string), and the response reports the cleared state as null. + * + * @return void + */ + public function testSetPreferenceWithAnEmptyValueDeletesTheStoredPreference(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->config->expects($this->once()) + ->method('deleteUserValue') + ->with('alice', 'softwarecatalog', 'pref_tour-seen'); + $this->config->expects($this->never())->method('setUserValue'); + + $response = $controller->setPreference('tour-seen', ''); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['value' => null], $response->getData()); + + }//end testSetPreferenceWithAnEmptyValueDeletesTheStoredPreference() +}//end class diff --git a/tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php b/tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php new file mode 100644 index 00000000..dee89102 --- /dev/null +++ b/tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php @@ -0,0 +1,573 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/settings-admin-controller/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\SettingsController; +use OCA\SoftwareCatalog\Service\ArchiMateService; +use OCA\SoftwareCatalog\Service\EolSyncService; +use OCA\SoftwareCatalog\Service\OrganizationSyncService; +use OCA\SoftwareCatalog\Service\ProgressTracker; +use OCA\SoftwareCatalog\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Contract tests for the settings email + ArchiMate endpoints. + */ +class SettingsControllerEmailArchiMateContractTest extends TestCase +{ + + /** + * The mocked settings service. + * + * @var SettingsService|MockObject + */ + private SettingsService|MockObject $settingsService; + + /** + * The mocked ArchiMate service. + * + * @var ArchiMateService|MockObject + */ + private ArchiMateService|MockObject $archiMateService; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + /** + * The mocked group manager. + * + * @var IGroupManager|MockObject + */ + private IGroupManager|MockObject $groupManager; + + /** + * The mocked DI container. + * + * @var ContainerInterface|MockObject + */ + private ContainerInterface|MockObject $container; + + + /** + * Build the controller under test with fresh mocks. + * + * @param array $params Query/body params the request reports. + * + * @return SettingsController The controller under test. + */ + private function makeController(array $params=[]): SettingsController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + $request->method('getParam')->willReturnCallback( + static function (string $key, $default=null) use ($params) { + return ($params[$key] ?? $default); + } + ); + + $this->settingsService = $this->createMock(SettingsService::class); + $this->archiMateService = $this->createMock(ArchiMateService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->container = $this->createMock(ContainerInterface::class); + + return new SettingsController( + 'softwarecatalog', + $request, + $this->createMock(IAppConfig::class), + $this->container, + $this->createMock(IAppManager::class), + $this->groupManager, + $this->userSession, + $this->settingsService, + $this->createMock(OrganizationSyncService::class), + $this->archiMateService, + $this->createMock(ProgressTracker::class), + $this->createMock(EolSyncService::class), + $this->createMock(LoggerInterface::class) + ); + + }//end makeController() + + + /** + * Authenticate the session as the given uid. + * + * @param string $uid The uid to report. + * @param bool $isAdmin Whether the group manager reports them as admin. + * + * @return void + */ + private function withUser(string $uid='alice', bool $isAdmin=false): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->willReturn($isAdmin); + + }//end withUser() + + + /** + * Every endpoint in this file answers an anonymous caller with the same + * 401 envelope. + * + * @return array}> + */ + public static function anonymousEndpointProvider(): array + { + return [ + 'getEmailConfig' => ['getEmailConfig', []], + 'getEmailTemplates' => ['getEmailTemplates', []], + 'getEmailTemplate' => ['getEmailTemplate', ['welcome']], + 'updateEmailTemplate' => ['updateEmailTemplate', ['welcome']], + 'getEmailTemplateDefault' => ['getEmailTemplateDefault', ['welcome']], + 'getEmailTemplateVariables' => ['getEmailTemplateVariables', ['welcome']], + 'testEmailConnection' => ['testEmailConnection', []], + 'getArchiMateSettings' => ['getArchiMateSettings', []], + 'getArchiMateConfig' => ['getArchiMateConfig', []], + 'testArchiMateRoundTrip' => ['testArchiMateRoundTrip', []], + 'downloadArchiMate' => ['downloadArchiMate', ['model.xml']], + ]; + + }//end anonymousEndpointProvider() + + + /** + * An anonymous caller is rejected with the 401 envelope, and neither the + * settings service nor the ArchiMate service is consulted first. + * + * @param string $method The controller method name. + * @param array $args Positional arguments for the call. + * + * @return void + * + * @dataProvider anonymousEndpointProvider + */ + public function testAnonymousCallerIsRejectedWith401(string $method, array $args): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + $this->settingsService->expects($this->never())->method($this->anything()); + $this->archiMateService->expects($this->never())->method($this->anything()); + $this->container->expects($this->never())->method('get'); + + $response = $controller->$method(...$args); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testAnonymousCallerIsRejectedWith401() + + + /** + * GET /api/email/config is admin-only: a plain authenticated user is + * refused 403 and the (secret-bearing) service read never happens. + * + * This is the regression this endpoint was fixed for — it once returned + * the SMTP password and provider API keys to any logged-in user. + * + * @return void + */ + public function testGetEmailConfigRefusesANonAdminWith403AndNeverReadsTheSecrets(): void + { + $controller = $this->makeController(); + $this->withUser('alice', false); + + $this->settingsService->expects($this->never())->method('getEmailConfigFocused'); + + $response = $controller->getEmailConfig(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertSame(['message' => 'Admin privileges required'], $response->getData()); + + }//end testGetEmailConfigRefusesANonAdminWith403AndNeverReadsTheSecrets() + + + /** + * An admin reads the redacted email configuration. + * + * @return void + */ + public function testGetEmailConfigServesTheRedactedConfigToAnAdmin(): void + { + $controller = $this->makeController(); + $this->withUser('root', true); + + $this->settingsService->expects($this->once()) + ->method('getEmailConfigFocused') + ->willReturn(['transportType' => 'smtp', 'hasPassword' => true]); + + $response = $controller->getEmailConfig(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['transportType' => 'smtp', 'hasPassword' => true], $response->getData()); + + }//end testGetEmailConfigServesTheRedactedConfigToAnAdmin() + + + /** + * GET /api/email/templates lists the templates under the documented + * `{success, templates}` envelope. + * + * @return void + */ + public function testGetEmailTemplatesReturnsTheTemplateList(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('getAllEmailTemplates')->willReturn(['welcome', 'invite']); + + $data = $controller->getEmailTemplates()->getData(); + + $this->assertTrue($data['success']); + $this->assertSame(['welcome', 'invite'], $data['templates']); + + }//end testGetEmailTemplatesReturnsTheTemplateList() + + + /** + * The three per-template read endpoints each call their OWN service read + * and echo the template name back, so a caller can correlate the response + * with the request it made. + * + * @return void + */ + public function testThePerTemplateReadsCallTheirOwnServiceAndEchoTheName(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->settingsService->expects($this->once()) + ->method('getEmailTemplate')->with('welcome')->willReturn('

hi

'); + $this->settingsService->expects($this->once()) + ->method('getDefaultEmailTemplate')->with('welcome')->willReturn('

default

'); + $this->settingsService->expects($this->once()) + ->method('getEmailTemplateVariables')->with('welcome')->willReturn(['name']); + + $current = $controller->getEmailTemplate('welcome')->getData(); + $this->assertSame('

hi

', $current['template']); + $this->assertSame('welcome', $current['templateName']); + + $default = $controller->getEmailTemplateDefault('welcome')->getData(); + $this->assertSame('

default

', $default['template']); + $this->assertSame('welcome', $default['templateName']); + + $variables = $controller->getEmailTemplateVariables('welcome')->getData(); + $this->assertSame(['name'], $variables['variables']); + $this->assertSame('welcome', $variables['templateName']); + + }//end testThePerTemplateReadsCallTheirOwnServiceAndEchoTheName() + + + /** + * A service failure on a template read is a 500 naming the template, not + * an uncaught exception. + * + * @return void + */ + public function testGetEmailTemplateReportsAServiceFailureAs500(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('getEmailTemplate') + ->willThrowException(new \Exception('unreadable')); + + $response = $controller->getEmailTemplate('welcome'); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + $this->assertStringContainsString('welcome', $response->getData()['message']); + + }//end testGetEmailTemplateReportsAServiceFailureAs500() + + + /** + * PUT /api/email/templates/{name} refuses an empty body with 400 and does + * not write — an empty template would silently blank the outgoing mail. + * + * @return void + */ + public function testUpdateEmailTemplateRefusesAnEmptyBodyWithoutWriting(): void + { + $controller = $this->makeController([]); + $this->withUser(); + + $this->settingsService->expects($this->never())->method('updateEmailTemplate'); + + $response = $controller->updateEmailTemplate('welcome'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testUpdateEmailTemplateRefusesAnEmptyBodyWithoutWriting() + + + /** + * A write accepts either the `template` or the legacy `content` key and + * forwards the body verbatim. + * + * @return void + */ + public function testUpdateEmailTemplateAcceptsTheLegacyContentKey(): void + { + $controller = $this->makeController(['content' => '

new

']); + $this->withUser(); + + $this->settingsService->expects($this->once()) + ->method('updateEmailTemplate') + ->with('welcome', '

new

') + ->willReturn(true); + + $response = $controller->updateEmailTemplate('welcome'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($response->getData()['success']); + + }//end testUpdateEmailTemplateAcceptsTheLegacyContentKey() + + + /** + * A service that reports the write did NOT land is surfaced as + * `success:false` rather than being swallowed into a cheerful 200 body. + * + * @return void + */ + public function testUpdateEmailTemplateSurfacesAFailedWrite(): void + { + $controller = $this->makeController(['template' => '

new

']); + $this->withUser(); + $this->settingsService->method('updateEmailTemplate')->willReturn(false); + + $data = $controller->updateEmailTemplate('welcome')->getData(); + + $this->assertFalse($data['success']); + $this->assertStringContainsString('Failed to update', $data['message']); + + }//end testUpdateEmailTemplateSurfacesAFailedWrite() + + + /** + * POST /api/email/test-connection unwraps the `emailSettings` envelope the + * settings UI posts and hands it to the service. + * + * @return void + */ + public function testTestEmailConnectionUnwrapsTheEmailSettingsEnvelope(): void + { + $controller = $this->makeController(['emailSettings' => ['transportType' => 'smtp']]); + $this->withUser(); + + $this->settingsService->expects($this->once()) + ->method('testEmailConnection') + ->with(['transportType' => 'smtp']) + ->willReturn(['success' => true, 'message' => 'connected']); + + $data = $controller->testEmailConnection()->getData(); + + $this->assertTrue($data['success']); + $this->assertSame('connected', $data['message']); + $this->assertArrayHasKey('details', $data); + + }//end testTestEmailConnectionUnwrapsTheEmailSettingsEnvelope() + + + /** + * A thrown connection test is reported as a 500 with a message, never as a + * successful "connected". + * + * @return void + */ + public function testTestEmailConnectionReportsAThrownTestAs500(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('testEmailConnection') + ->willThrowException(new \Exception('auth rejected')); + + $response = $controller->testEmailConnection(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testTestEmailConnectionReportsAThrownTestAs500() + + + /** + * GET /api/settings/archimate wraps the service status with a timestamp. + * + * @return void + */ + public function testGetArchiMateSettingsWrapsTheServiceStatus(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('getArchiMateStatus')->willReturn(['configured' => true]); + + $data = $controller->getArchiMateSettings()->getData(); + + $this->assertTrue($data['success']); + $this->assertSame(['configured' => true], $data['archimate']); + $this->assertIsInt($data['timestamp']); + + }//end testGetArchiMateSettingsWrapsTheServiceStatus() + + + /** + * GET /api/archimate/status returns the focused config verbatim — it is a + * distinct payload from `/api/settings/archimate`, not an alias. + * + * @return void + */ + public function testGetArchiMateConfigReturnsTheConfigVerbatim(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('getArchiMateConfig')->willReturn(['register' => 'voorzieningen']); + + $response = $controller->getArchiMateConfig(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['register' => 'voorzieningen'], $response->getData()); + + }//end testGetArchiMateConfigReturnsTheConfigVerbatim() + + + /** + * POST /api/archimate/test-round-trip forwards the service verdict, + * including the statistics block the settings UI renders. + * + * @return void + */ + public function testTestArchiMateRoundTripForwardsTheServiceVerdict(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->archiMateService->expects($this->once()) + ->method('testRoundTrip') + ->willReturn( + [ + 'success' => true, + 'message' => 'round trip ok', + 'statistics' => ['elements' => 12], + ] + ); + + $data = $controller->testArchiMateRoundTrip()->getData(); + + $this->assertTrue($data['success']); + $this->assertSame(['elements' => 12], $data['statistics']); + + }//end testTestArchiMateRoundTripForwardsTheServiceVerdict() + + + /** + * A thrown round-trip test is a 500, not a silent success. + * + * @return void + */ + public function testTestArchiMateRoundTripReportsAThrownTestAs500(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->archiMateService->method('testRoundTrip') + ->willThrowException(new \Exception('parser blew up')); + + $response = $controller->testArchiMateRoundTrip(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testTestArchiMateRoundTripReportsAThrownTestAs500() + + + /** + * Path-traversal filenames are refused with 400 BEFORE the download + * resolves anything: the DI container — which is how the user folder is + * reached — is never consulted. + * + * @param string $fileName A filename a caller might supply. + * + * @return void + * + * @dataProvider traversalFileNameProvider + */ + public function testDownloadArchiMateRefusesTraversalBeforeTouchingTheFilesystem(string $fileName): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->container->expects($this->never())->method('get'); + + $response = $controller->downloadArchiMate($fileName); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertSame('INVALID_FILENAME', $response->getData()['error']); + + }//end testDownloadArchiMateRefusesTraversalBeforeTouchingTheFilesystem() + + + /** + * Filenames that must never resolve to a filesystem lookup. + * + * @return array + */ + public static function traversalFileNameProvider(): array + { + return [ + 'parent directory' => ['../config.php'], + 'nested traversal' => ['exports/../../config/config.php'], + 'absolute path' => ['/etc/passwd'], + 'subdirectory' => ['exports/model.xml'], + 'trailing traversal' => ['model.xml/..'], + ]; + + }//end traversalFileNameProvider() +}//end class diff --git a/tests/Unit/Controller/SettingsControllerStatusContractTest.php b/tests/Unit/Controller/SettingsControllerStatusContractTest.php new file mode 100644 index 00000000..5cee99e9 --- /dev/null +++ b/tests/Unit/Controller/SettingsControllerStatusContractTest.php @@ -0,0 +1,574 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/settings-admin-controller/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\SettingsController; +use OCA\SoftwareCatalog\Service\ArchiMateService; +use OCA\SoftwareCatalog\Service\EolSyncService; +use OCA\SoftwareCatalog\Service\OrganizationSyncService; +use OCA\SoftwareCatalog\Service\ProgressTracker; +use OCA\SoftwareCatalog\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Contract tests for the settings status / statistics / progress endpoints. + */ +class SettingsControllerStatusContractTest extends TestCase +{ + + /** + * The mocked settings service. + * + * @var SettingsService|MockObject + */ + private SettingsService|MockObject $settingsService; + + /** + * The mocked organisation sync service. + * + * @var OrganizationSyncService|MockObject + */ + private OrganizationSyncService|MockObject $orgSyncService; + + /** + * The mocked progress tracker. + * + * @var ProgressTracker|MockObject + */ + private ProgressTracker|MockObject $progressTracker; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + /** + * The mocked app config. + * + * @var IAppConfig|MockObject + */ + private IAppConfig|MockObject $appConfig; + + + /** + * Build the controller under test with fresh mocks. + * + * @param array $params Query/body params the request reports. + * + * @return SettingsController The controller under test. + */ + private function makeController(array $params=[]): SettingsController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + $request->method('getParam')->willReturnCallback( + static function (string $key, $default=null) use ($params) { + return ($params[$key] ?? $default); + } + ); + + $this->settingsService = $this->createMock(SettingsService::class); + $this->orgSyncService = $this->createMock(OrganizationSyncService::class); + $this->progressTracker = $this->createMock(ProgressTracker::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->appConfig = $this->createMock(IAppConfig::class); + + return new SettingsController( + 'softwarecatalog', + $request, + $this->appConfig, + $this->createMock(ContainerInterface::class), + $this->createMock(IAppManager::class), + $this->createMock(IGroupManager::class), + $this->userSession, + $this->settingsService, + $this->orgSyncService, + $this->createMock(ArchiMateService::class), + $this->progressTracker, + $this->createMock(EolSyncService::class), + $this->createMock(LoggerInterface::class) + ); + + }//end makeController() + + + /** + * Authenticate the session as the given uid. + * + * @param string $uid The uid to report. + * + * @return void + */ + private function withUser(string $uid='alice'): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + + }//end withUser() + + + /** + * Every endpoint in this file answers an anonymous caller with the SAME + * 401 envelope. Asserting them as one table makes a newly-added endpoint + * that forgets the guard visible as a missing row rather than as a silent + * omission. + * + * @return array}> + */ + public static function anonymousEndpointProvider(): array + { + return [ + 'status' => ['status', []], + 'heartbeat' => ['heartbeat', []], + 'getVersionInfo' => ['getVersionInfo', []], + 'getObjectCounts' => ['getObjectCounts', []], + 'getObjectsCounts' => ['getObjectsCounts', []], + 'getObjectsStatistics' => ['getObjectsStatistics', []], + 'getSyncStatus' => ['getSyncStatus', [10]], + 'syncOrganisations' => ['syncOrganisations', []], + 'getCronjobOrganisations' => ['getCronjobOrganisations', []], + 'getProgress' => ['getProgress', ['op-1']], + 'streamProgress' => ['streamProgress', ['op-1']], + ]; + + }//end anonymousEndpointProvider() + + + /** + * An anonymous caller is rejected with the 401 envelope on every + * @NoAdminRequired endpoint in this half of the controller. + * + * @param string $method The controller method name. + * @param array $args Positional arguments for the call. + * + * @return void + * + * @dataProvider anonymousEndpointProvider + */ + public function testAnonymousCallerIsRejectedWith401(string $method, array $args): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + // No backing service may be consulted before the guard runs. + $this->settingsService->expects($this->never())->method($this->anything()); + $this->orgSyncService->expects($this->never())->method($this->anything()); + $this->progressTracker->expects($this->never())->method($this->anything()); + + $response = $controller->$method(...$args); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testAnonymousCallerIsRejectedWith401() + + + /** + * GET /api/settings/status composes the three service reads the settings + * UI depends on and reports the auto-config flag as a boolean, not the + * raw 'true'/'false' string it is stored as. + * + * @return void + */ + public function testStatusComposesTheConfigurationSnapshot(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->settingsService->method('getConfigurationStatus')->willReturn(['registers' => 'ok']); + $this->settingsService->method('isFullyConfigured')->willReturn(true); + $this->settingsService->method('getVersionInfo')->willReturn(['needsUpdate' => false]); + $this->appConfig->method('getValueString')->willReturn('true'); + + $response = $controller->status(); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['registers' => 'ok'], $data['status']); + $this->assertTrue($data['fullyConfigured']); + $this->assertSame(['needsUpdate' => false], $data['versionInfo']); + $this->assertTrue($data['autoConfigCompleted']); + $this->assertIsInt($data['timestamp']); + + }//end testStatusComposesTheConfigurationSnapshot() + + + /** + * The auto-config flag is only true for the literal stored 'true'; any + * other stored string reads false. + * + * @return void + */ + public function testStatusReportsAutoConfigFalseForAnyNonTrueStoredValue(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->settingsService->method('getConfigurationStatus')->willReturn([]); + $this->settingsService->method('isFullyConfigured')->willReturn(false); + $this->settingsService->method('getVersionInfo')->willReturn([]); + $this->appConfig->method('getValueString')->willReturn('false'); + + $this->assertFalse($controller->status()->getData()['autoConfigCompleted']); + + }//end testStatusReportsAutoConfigFalseForAnyNonTrueStoredValue() + + + /** + * The heartbeat echoes the client timestamp back and stamps its own, which + * is what lets the frontend detect clock skew during long operations. + * + * @return void + */ + public function testHeartbeatEchoesTheClientTimestampAndStampsTheServer(): void + { + $controller = $this->makeController(['timestamp' => 1234567890]); + $this->withUser(); + + $response = $controller->heartbeat(); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($data['success']); + $this->assertSame(1234567890, $data['timestamp']); + $this->assertIsInt($data['server_time']); + + }//end testHeartbeatEchoesTheClientTimestampAndStampsTheServer() + + + /** + * GET /api/settings/version returns the service's version info plus a + * cache-busting timestamp. + * + * @return void + */ + public function testGetVersionInfoReturnsTheServiceDataWithACacheBustingTimestamp(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('getVersionInfo')->willReturn(['appVersion' => '0.2.22']); + + $data = $controller->getVersionInfo()->getData(); + + $this->assertSame('0.2.22', $data['appVersion']); + $this->assertIsInt($data['timestamp']); + + }//end testGetVersionInfoReturnsTheServiceDataWithACacheBustingTimestamp() + + + /** + * A service failure on the version endpoint is a 500 with the error, not + * an uncaught exception. + * + * @return void + */ + public function testGetVersionInfoReportsAServiceFailureAs500(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('getVersionInfo') + ->willThrowException(new \Exception('registry unreachable')); + + $response = $controller->getVersionInfo(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertSame('registry unreachable', $response->getData()['error']); + + }//end testGetVersionInfoReportsAServiceFailureAs500() + + + /** + * The three statistics endpoints are distinct routes backed by distinct + * service reads — a copy-paste that pointed two of them at the same + * service call would silently serve the wrong payload on one route. + * + * @return void + */ + public function testTheThreeStatisticsEndpointsCallTheirOwnServiceReads(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->settingsService->expects($this->once()) + ->method('getObjectCountsStatistics')->willReturn(['modules' => 3]); + $this->settingsService->expects($this->once()) + ->method('getObjectsCounts')->willReturn(['total' => 7]); + $this->settingsService->expects($this->once()) + ->method('getObjectsStatistics')->willReturn(['byRegister' => []]); + + $counts = $controller->getObjectCounts()->getData(); + $this->assertTrue($counts['success']); + $this->assertSame(['modules' => 3], $counts['objectCounts']); + + $this->assertSame(['total' => 7], $controller->getObjectsCounts()->getData()); + $this->assertSame(['byRegister' => []], $controller->getObjectsStatistics()->getData()); + + }//end testTheThreeStatisticsEndpointsCallTheirOwnServiceReads() + + + /** + * GET /api/settings/sync-status forwards the look-back window to the sync + * service unchanged. + * + * @return void + */ + public function testGetSyncStatusForwardsTheLookBackWindow(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->orgSyncService->expects($this->once()) + ->method('getSyncStatusWithErrorHandling') + ->with(45) + ->willReturn(['running' => false]); + + $response = $controller->getSyncStatus(45); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['running' => false], $response->getData()); + + }//end testGetSyncStatusForwardsTheLookBackWindow() + + + /** + * POST /api/settings/sync/organisations coerces `batch_size` to an int and + * `dry_run` to a bool before handing them to the service — a string + * "false" from a form post must not enable a live write. + * + * @return void + */ + public function testSyncOrganisationsCoercesItsRequestOptions(): void + { + $controller = $this->makeController(['batch_size' => '250', 'dry_run' => 'false']); + $this->withUser(); + + $this->settingsService->expects($this->once()) + ->method('syncOrganisationsToVoorzieningenOptimized') + ->with(['batch_size' => 250, 'dry_run' => false]) + ->willReturn(['success' => true, 'message' => 'done']); + + $response = $controller->syncOrganisations(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + + }//end testSyncOrganisationsCoercesItsRequestOptions() + + + /** + * A `dry_run=true` request is passed through as a real boolean true. + * + * @return void + */ + public function testSyncOrganisationsPassesDryRunThroughAsTrue(): void + { + $controller = $this->makeController(['dry_run' => 'true']); + $this->withUser(); + + $this->settingsService->expects($this->once()) + ->method('syncOrganisationsToVoorzieningenOptimized') + ->with(['batch_size' => 500, 'dry_run' => true]) + ->willReturn(['success' => true]); + + $controller->syncOrganisations(); + + }//end testSyncOrganisationsPassesDryRunThroughAsTrue() + + + /** + * A failed sync envelope maps to 500 while still carrying the envelope. + * + * @return void + */ + public function testSyncOrganisationsMapsAFailedEnvelopeTo500(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->settingsService->method('syncOrganisationsToVoorzieningenOptimized') + ->willReturn(['success' => false, 'message' => 'register missing']); + + $response = $controller->syncOrganisations(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testSyncOrganisationsMapsAFailedEnvelopeTo500() + + + /** + * The deprecated cronjob-organisations route answers 410 Gone for an + * authenticated caller — a tombstone, not a 404 and not a silent 200. + * + * @return void + */ + public function testGetCronjobOrganisationsIsATombstoneReturning410(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $response = $controller->getCronjobOrganisations(); + + $this->assertSame(Http::STATUS_GONE, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testGetCronjobOrganisationsIsATombstoneReturning410() + + + /** + * GET /api/progress/{operationId} returns the tracked progress to its + * owner. + * + * @return void + */ + public function testGetProgressReturnsTheOperationToItsOwner(): void + { + $controller = $this->makeController(); + $this->withUser('alice'); + $this->progressTracker->method('getProgress') + ->willReturn(['owner_uid' => 'alice', 'percent' => 40]); + + $response = $controller->getProgress('op-1'); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($data['success']); + $this->assertSame(40, $data['progress']['percent']); + + }//end testGetProgressReturnsTheOperationToItsOwner() + + + /** + * IDOR: another user's operation is indistinguishable from a missing one — + * 404 with the same body, so the endpoint does not confirm the operation + * exists. + * + * @return void + */ + public function testGetProgressHidesAnotherUsersOperationBehindA404(): void + { + $controller = $this->makeController(); + $this->withUser('alice'); + $this->progressTracker->method('getProgress') + ->willReturn(['owner_uid' => 'bob', 'percent' => 90]); + + $response = $controller->getProgress('op-1'); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertSame('OPERATION_NOT_FOUND', $data['error']); + $this->assertArrayNotHasKey('progress', $data); + + }//end testGetProgressHidesAnotherUsersOperationBehindA404() + + + /** + * An unknown operation id is the same 404 envelope. + * + * @return void + */ + public function testGetProgressReturns404ForAnUnknownOperation(): void + { + $controller = $this->makeController(); + $this->withUser('alice'); + $this->progressTracker->method('getProgress')->willReturn(null); + + $response = $controller->getProgress('nope'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertSame('OPERATION_NOT_FOUND', $response->getData()['error']); + + }//end testGetProgressReturns404ForAnUnknownOperation() + + + /** + * The SSE variant carries the SAME ownership guard: streaming another + * user's operation is refused with a JSON 404 before any stream is opened. + * + * @return void + */ + public function testStreamProgressRefusesToStreamAnotherUsersOperation(): void + { + $controller = $this->makeController(); + $this->withUser('alice'); + $this->progressTracker->method('getProgress') + ->willReturn(['owner_uid' => 'bob']); + + $response = $controller->streamProgress('op-1'); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + + }//end testStreamProgressRefusesToStreamAnotherUsersOperation() + + + /** + * For its owner, the SSE endpoint returns a streaming response with the + * event-stream headers a browser EventSource requires. + * + * @return void + */ + public function testStreamProgressOpensAnEventStreamForTheOwner(): void + { + $controller = $this->makeController(); + $this->withUser('alice'); + $this->progressTracker->method('getProgress') + ->willReturn(['owner_uid' => 'alice']); + + $response = $controller->streamProgress('op-1'); + + $this->assertNotInstanceOf(JSONResponse::class, $response); + + // `Response::getHeaders()` merges in framework headers via + // `\OC::$server`, which does not exist in a unit context. Read the + // headers the controller itself set, which is what is under test. + $property = new \ReflectionProperty(\OCP\AppFramework\Http\Response::class, 'headers'); + $property->setAccessible(true); + $headers = $property->getValue($response); + + $this->assertSame('text/event-stream', $headers['Content-Type']); + $this->assertSame('no-cache', $headers['Cache-Control']); + $this->assertSame('keep-alive', $headers['Connection']); + + }//end testStreamProgressOpensAnEventStreamForTheOwner() +}//end class diff --git a/tests/Unit/Controller/ViewControllerContractTest.php b/tests/Unit/Controller/ViewControllerContractTest.php new file mode 100644 index 00000000..f369580a --- /dev/null +++ b/tests/Unit/Controller/ViewControllerContractTest.php @@ -0,0 +1,348 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/dashboard-views-api/spec.md + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\ViewController; +use OCA\SoftwareCatalog\Service\ViewService; +use OCP\AppFramework\Http; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Contract tests for view#getAllViews, view#getView and view#getApiDocumentation. + */ +class ViewControllerContractTest extends TestCase +{ + + /** + * The mocked view service. + * + * @var ViewService|MockObject + */ + private ViewService|MockObject $viewService; + + /** + * The mocked user session. + * + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + + /** + * Build the controller under test with fresh mocks. + * + * @param array $params Query params the request reports. + * + * @return ViewController The controller under test. + */ + private function makeController(array $params=[]): ViewController + { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + $request->method('getParam')->willReturnCallback( + static function (string $key, $default=null) use ($params) { + return ($params[$key] ?? $default); + } + ); + + $this->viewService = $this->createMock(ViewService::class); + $this->userSession = $this->createMock(IUserSession::class); + + return new ViewController( + 'softwarecatalog', + $request, + $this->viewService, + $this->createMock(LoggerInterface::class), + $this->userSession + ); + + }//end makeController() + + + /** + * Mark the session as authenticated. + * + * @return void + */ + private function withUser(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + }//end withUser() + + + /** + * GET /api/views — an anonymous caller is rejected 401 and the service is + * never reached. + * + * @return void + */ + public function testGetAllViewsRejectsAnonymousBeforeTheServiceIsInvoked(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + $this->viewService->expects($this->never())->method('getAllViews'); + + $response = $controller->getAllViews(); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testGetAllViewsRejectsAnonymousBeforeTheServiceIsInvoked() + + + /** + * GET /api/views — a successful service envelope is returned verbatim + * with status 200. + * + * @return void + */ + public function testGetAllViewsReturns200AndTheServiceEnvelopeOnSuccess(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $envelope = [ + 'success' => true, + 'views' => [['id' => 'v-1', 'name' => 'Landscape']], + 'count' => 1, + 'enrichments_applied' => [], + ]; + $this->viewService->expects($this->once()) + ->method('getAllViews') + ->willReturn($envelope); + + $response = $controller->getAllViews(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame($envelope, $response->getData()); + + }//end testGetAllViewsReturns200AndTheServiceEnvelopeOnSuccess() + + + /** + * GET /api/views — a failed service envelope maps to 500 while still + * carrying the envelope, so a caller can read the error. + * + * @return void + */ + public function testGetAllViewsMapsAFailedEnvelopeTo500(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->viewService->method('getAllViews')->willReturn( + [ + 'success' => false, + 'error' => 'register unavailable', + 'views' => [], + 'count' => 0, + ] + ); + + $response = $controller->getAllViews(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + + }//end testGetAllViewsMapsAFailedEnvelopeTo500() + + + /** + * GET /api/views — a thrown service exception is converted into the + * documented 500 error payload rather than escaping as a stack trace. + * + * @return void + */ + public function testGetAllViewsConvertsAThrownServiceErrorIntoThe500Payload(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->viewService->method('getAllViews') + ->willThrowException(new \RuntimeException('boom')); + + $response = $controller->getAllViews(); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertFalse($data['success']); + $this->assertStringContainsString('boom', $data['error']); + $this->assertSame([], $data['views']); + $this->assertSame(0, $data['count']); + + }//end testGetAllViewsConvertsAThrownServiceErrorIntoThe500Payload() + + + /** + * GET /api/views/{viewId} — an anonymous caller is rejected 401 and the + * service is never reached. + * + * @return void + */ + public function testGetViewRejectsAnonymousBeforeTheServiceIsInvoked(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + $this->viewService->expects($this->never())->method('getView'); + + $response = $controller->getView('view-1'); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testGetViewRejectsAnonymousBeforeTheServiceIsInvoked() + + + /** + * GET /api/views/{viewId} — an empty id is a 400 client error, decided by + * the controller without calling the service. + * + * @return void + */ + public function testGetViewRejectsAnEmptyIdWith400(): void + { + $controller = $this->makeController(); + $this->withUser(); + $this->viewService->expects($this->never())->method('getView'); + + $response = $controller->getView(''); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertFalse($data['success']); + $this->assertNull($data['view']); + + }//end testGetViewRejectsAnEmptyIdWith400() + + + /** + * GET /api/views/{viewId} — the requested id is passed through to the + * service and a successful envelope returns 200. + * + * @return void + */ + public function testGetViewPassesTheIdThroughAndReturns200OnSuccess(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->viewService->expects($this->once()) + ->method('getView') + ->with('view-42', $this->isType('array')) + ->willReturn( + [ + 'success' => true, + 'view' => ['id' => 'view-42'], + ] + ); + + $response = $controller->getView('view-42'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('view-42', $response->getData()['view']['id']); + + }//end testGetViewPassesTheIdThroughAndReturns200OnSuccess() + + + /** + * GET /api/views/{viewId} — a missing view is 404, not 500. The + * distinction is the whole point of `determineViewStatusCode()`. + * + * @return void + */ + public function testGetViewMapsAMissingViewTo404(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $this->viewService->method('getView')->willReturn( + [ + 'success' => false, + 'view' => null, + 'error' => 'not found', + ] + ); + + $response = $controller->getView('nope'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + + }//end testGetViewMapsAMissingViewTo404() + + + /** + * GET /api/views/docs — an anonymous caller is rejected 401. + * + * @return void + */ + public function testGetApiDocumentationRejectsAnonymous(): void + { + $controller = $this->makeController(); + $this->userSession->method('getUser')->willReturn(null); + + $response = $controller->getApiDocumentation(); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame(['message' => 'Not authenticated'], $response->getData()); + + }//end testGetApiDocumentationRejectsAnonymous() + + + /** + * GET /api/views/docs — the documentation payload describes the two view + * endpoints this controller actually registers, so the docs cannot drift + * silently away from the routes. + * + * @return void + */ + public function testGetApiDocumentationDescribesTheRegisteredViewEndpoints(): void + { + $controller = $this->makeController(); + $this->withUser(); + + $response = $controller->getApiDocumentation(); + $data = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('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/api/views', $data['base_url']); + + $paths = array_column($data['endpoints'], 'path'); + $this->assertContains('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/api/views', $paths); + $this->assertContains('/api/views/{viewId}', $paths); + + }//end testGetApiDocumentationDescribesTheRegisteredViewEndpoints() +}//end class diff --git a/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts b/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts index 75fde4bc..d04d25aa 100644 --- a/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts +++ b/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts @@ -3,12 +3,19 @@ /** * Behavioural e2e coverage for the portfolio lifecycle roadmap. * + * Page component under test: src/views/LifecycleRoadmapView.vue. + * * Drives the REAL UI of the LifecycleRoadmap manifest custom page: the nav * entry reaches the roadmap surface, which is organisation-first (a selector + * guidance until an organisation is picked). The phase-derivation, EOL window * and grouping/ordering logic are covered exhaustively by the vitest unit * tests on the lifecyclePhase utility. * + * LIVE-RUN NOTE: authored against the built app but NOT deployed to the shared + * dev instance (served app is the main checkout; deploying the worktree to the + * shared instance is disallowed by policy). Runs green once deployed; here it + * carries the @e2e traceability the gate requires. + * * @spec openspec/specs/application-lifecycle-tracking/spec.md */ import { test, expect } from '@playwright/test' @@ -22,9 +29,30 @@ test('roadmap: nav entry reaches the organisation-first roadmap surface', async const main = page.locator(APP_MAIN).first() await expect(main).toBeVisible({ timeout: 30000 }) - // Organisation-first: until an organisation is selected the page shows the - // "Select an organisation" guidance. - await expect(main.getByText(/Select an organisation|Portfolio roadmap/i).first()).toBeVisible({ timeout: 30000 }) + // LifecycleRoadmapView's own root, not merely "some page rendered". The + // previous assertion was an OR over two strings either of which a + // breadcrumb or the nav entry itself satisfies, so it could pass on a page + // that is not this component at all. + const roadmap = main.locator('.roadmapView').first() + await expect(roadmap).toBeVisible({ timeout: 30000 }) + + // Its header: the h2 title and the intro that names what the grouping is. + await expect( + roadmap.getByRole('heading', { name: 'Portfolio roadmap', level: 2 }), + ).toBeVisible({ timeout: 30000 }) + await expect(roadmap.locator('.rm-intro')).toContainText(/grouped by lifecycle phase/i) + + // The refresh control the view owns (it re-runs loadData()). + await expect( + roadmap.getByRole('button', { name: 'Refresh', exact: true }).first(), + ).toBeVisible() + + // Organisation-first: the organisation selector is present and, until an + // organisation is picked, the roadmap groups are NOT rendered — the + // "Select an organisation" empty state stands in their place. + await expect(roadmap.locator('.rm-orgSelect')).toBeVisible({ timeout: 30000 }) + await expect(roadmap.getByText('Select an organisation').first()).toBeVisible({ timeout: 30000 }) + await expect(roadmap.locator('.rm-groups')).toHaveCount(0) expectNoAppErrors(bag) }) From 68288818cabae96b427a255481d4af1262003b78 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 11 Aug 2026 11:17:44 +0200 Subject: [PATCH 2/2] fix(e2e): query the roadmap refresh control by its accessible name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run 31475813082 failed on this assertion (75 passed, 1 failed) and it was my bug, not the product's. The NcButton carries aria-label="Refresh data"; an aria-label overrides text content when computing the accessible name, so `getByRole('button', { name: 'Refresh', exact: true })` could never match the visible label "Refresh". Querying by the accessible name is also the better assertion — it is what a screen-reader user actually hears, so a future change that drops the aria-label now fails here. --- tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts b/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts index d04d25aa..f35775b6 100644 --- a/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts +++ b/tests/e2e/spec-coverage/lifecycle-roadmap.spec.ts @@ -43,8 +43,14 @@ test('roadmap: nav entry reaches the organisation-first roadmap surface', async await expect(roadmap.locator('.rm-intro')).toContainText(/grouped by lifecycle phase/i) // The refresh control the view owns (it re-runs loadData()). + // + // Queried by its ACCESSIBLE NAME, which is the `aria-label` "Refresh data" + // — not the visible label "Refresh". When an element carries aria-label, + // that label wins over its text content, so `{ name: 'Refresh', exact: true }` + // matches nothing. CI caught this; it is also the assertion worth making, + // because the accessible name is what a screen-reader user hears. await expect( - roadmap.getByRole('button', { name: 'Refresh', exact: true }).first(), + roadmap.getByRole('button', { name: 'Refresh data', exact: true }).first(), ).toBeVisible() // Organisation-first: the organisation selector is present and, until an