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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('softwarecatalog', 'Add organisation') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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..f35775b6 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,36 @@ 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()).
+ //
+ // 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 data', 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)
})