diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index c86a267b..667278af 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -24,8 +24,11 @@ use OCA\Decidesk\AppInfo\Application; use OCA\Decidesk\Service\SettingsService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; use OCP\IRequest; +use OCP\IUserSession; /** * Controller for managing Decidesk application settings. @@ -37,21 +40,42 @@ class SettingsController extends Controller * * @param IRequest $request The request object * @param SettingsService $settingsService The settings service + * @param IGroupManager $groupManager The group manager for admin checks + * @param IUserSession $userSession The user session * * @return void */ public function __construct( IRequest $request, private SettingsService $settingsService, + private IGroupManager $groupManager, + private IUserSession $userSession, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() + /** + * Check whether the current user is an admin. Returns a 403 JSONResponse if not. + * + * @return JSONResponse|null Null if admin, 403 response if not. + */ + private function requireAdmin(): ?JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null || $this->groupManager->isAdmin($user->getUID()) === false) { + return new JSONResponse(['message' => 'Admin required'], Http::STATUS_FORBIDDEN); + } + + return null; + }//end requireAdmin() + /** * Retrieve all current settings. * * @NoAdminRequired * + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-2.1 + * * @return JSONResponse */ public function index(): JSONResponse @@ -64,10 +88,17 @@ public function index(): JSONResponse /** * Update settings with provided data. * + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-2.2 + * * @return JSONResponse */ public function create(): JSONResponse { + $denied = $this->requireAdmin(); + if ($denied !== null) { + return $denied; + } + $data = $this->request->getParams(); $config = $this->settingsService->updateSettings($data); @@ -85,10 +116,18 @@ public function create(): JSONResponse * Forces a fresh import regardless of version, auto-configuring * all schema and register IDs from the import result. * + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-2.1 + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-2.2 + * * @return JSONResponse */ public function load(): JSONResponse { + $denied = $this->requireAdmin(); + if ($denied !== null) { + return $denied; + } + $result = $this->settingsService->loadConfiguration(force: true); return new JSONResponse($result); diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index c1151728..3e121045 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -69,6 +69,8 @@ public function __construct( /** * Check whether OpenRegister is installed and available. * + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-1.3 + * * @return bool */ public function isOpenRegisterAvailable(): bool @@ -82,6 +84,8 @@ public function isOpenRegisterAvailable(): bool * Returns a flat array containing all app config values plus metadata * fields (openregisters, isAdmin) consumed by the frontend. * + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-2.1 + * * @return array */ public function getSettings(): array @@ -108,6 +112,8 @@ public function getSettings(): array * * @param array $data The data to update * + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-2.2 + * * @return array The updated settings */ public function updateSettings(array $data): array @@ -126,6 +132,9 @@ public function updateSettings(array $data): array * * @param bool $force Force re-import even if already configured. * + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-1.3 + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-2.1 + * * @return array Result with success flag, message, and version. */ public function loadConfiguration(bool $force=false): array @@ -162,7 +171,7 @@ public function loadConfiguration(bool $force=false): array ); return [ 'success' => false, - 'message' => $e->getMessage(), + 'message' => 'Configuration import failed. See server log for details.', ]; }//end try }//end loadConfiguration() diff --git a/openspec/changes/p1-dashboard-and-navigation/tasks.md b/openspec/changes/p1-dashboard-and-navigation/tasks.md index 116d81ab..6e71f9bf 100644 --- a/openspec/changes/p1-dashboard-and-navigation/tasks.md +++ b/openspec/changes/p1-dashboard-and-navigation/tasks.md @@ -1,67 +1,67 @@ ## 1. Backend — Register Configuration -- [ ] 1.1 Create or update `lib/Settings/decidesk_register.json` with all 17 schema definitions (ActionItem, AgendaItem, Amendment, Decision, DigitalDocument, GovernanceBody, Meeting, Minutes, MonetaryAmount, Motion, Offer, Order, Participant, Product, Report, Vote, VotingRound) in OpenAPI 3.0.0 + x-openregister format -- [ ] 1.2 Add seed data objects (8 objects: 2 GovernanceBody, 2 Meeting, 2 Participant, 1 Motion, 1 Decision) with Dutch values and `@self` envelope in the register JSON -- [ ] 1.3 Verify `SettingsService::importFromApp()` is called in the repair step and imports all schemas and seed objects without errors +- [x] 1.1 Create or update `lib/Settings/decidesk_register.json` with all 17 schema definitions (ActionItem, AgendaItem, Amendment, Decision, DigitalDocument, GovernanceBody, Meeting, Minutes, MonetaryAmount, Motion, Offer, Order, Participant, Product, Report, Vote, VotingRound) in OpenAPI 3.0.0 + x-openregister format +- [x] 1.2 Add seed data objects (8 objects: 2 GovernanceBody, 2 Meeting, 2 Participant, 1 Motion, 1 Decision) with Dutch values and `@self` envelope in the register JSON +- [x] 1.3 Verify `SettingsService::importFromApp()` is called in the repair step and imports all schemas and seed objects without errors ## 2. Backend — Settings Controller -- [ ] 2.1 Ensure `SettingsController` exposes `GET /apps/decidesk/api/settings` returning `{ openRegisters: bool, isAdmin: bool }` and register slugs for all 17 entity types -- [ ] 2.2 Ensure `POST /apps/decidesk/api/settings/load` triggers `ConfigurationService::importFromApp()` and returns success/error response +- [x] 2.1 Ensure `SettingsController` exposes `GET /apps/decidesk/api/settings` returning `{ openRegisters: bool, isAdmin: bool }` and register slugs for all 17 entity types +- [x] 2.2 Ensure `POST /apps/decidesk/api/settings/load` triggers `ConfigurationService::importFromApp()` and returns success/error response ## 3. Frontend — Project Scaffold -- [ ] 3.1 Create `src/main.js` bootstrapping Vue 2 app with Pinia and Vue Router, mounted on `#content` -- [ ] 3.2 Create `src/store/store.js` exporting `initializeStores()` that fetches settings then registers all 17 entity types via `objectStore.registerObjectType(name, schemaSlug, registerSlug)` -- [ ] 3.3 Create settings Pinia store (`src/store/modules/settings.js`) with `fetchSettings()` and `saveSettings()` actions -- [ ] 3.4 Create `src/router/index.js` with Vue Router in history mode, base `/index.php/apps/decidesk/`, all 12 named flat routes (Dashboard, MeetingList, MeetingDetail, MotionList, MotionDetail, DecisionList, DecisionDetail, ParticipantList, ParticipantDetail, GovernanceBodyList, GovernanceBodyDetail, Settings), and catch-all `*` redirecting to `/` +- [x] 3.1 Create `src/main.js` bootstrapping Vue 2 app with Pinia and Vue Router, mounted on `#content` +- [x] 3.2 Create `src/store/store.js` exporting `initializeStores()` that fetches settings then registers all 17 entity types via `objectStore.registerObjectType(name, schemaSlug, registerSlug)` +- [x] 3.3 Create settings Pinia store (`src/store/modules/settings.js`) with `fetchSettings()` and `saveSettings()` actions +- [x] 3.4 Create `src/router/index.js` with Vue Router in history mode, base `/index.php/apps/decidesk/`, all 12 named flat routes (Dashboard, MeetingList, MeetingDetail, MotionList, MotionDetail, DecisionList, DecisionDetail, ParticipantList, ParticipantDetail, GovernanceBodyList, GovernanceBodyDetail, Settings), and catch-all `*` redirecting to `/` ## 4. Frontend — App.vue and MainMenu -- [ ] 4.1 Create `src/App.vue` using `NcContent` with three rendering states: loading (`NcLoadingIcon`), no-OpenRegister (`NcEmptyContent`), ready (`MainMenu` + `NcAppContent` + `router-view`); call `initializeStores()` in `created()` -- [ ] 4.2 Create `src/components/MainMenu.vue` using `NcAppNavigation` with `NcAppNavigationItem` for: Dashboard, Vergaderingen, Moties, Besluiten, Deelnemers, Bestuursorganen; footer settings link via `NcAppNavigationSettings`; all strings via `t(appName, '...')` -- [ ] 4.3 Inject `sidebarState` in App.vue and provide it to child components for `CnIndexSidebar` support +- [x] 4.1 Create `src/App.vue` using `NcContent` with three rendering states: loading (`NcLoadingIcon`), no-OpenRegister (`NcEmptyContent`), ready (`MainMenu` + `NcAppContent` + `router-view`); call `initializeStores()` in `created()` +- [x] 4.2 Create `src/components/MainMenu.vue` using `NcAppNavigation` with `NcAppNavigationItem` for: Dashboard, Vergaderingen, Moties, Besluiten, Deelnemers, Bestuursorganen; footer settings link via `NcAppNavigationSettings`; all strings via `t(appName, '...')` +- [x] 4.3 Inject `sidebarState` in App.vue and provide it to child components for `CnIndexSidebar` support ## 5. Frontend — Dashboard Page -- [ ] 5.1 Create `src/views/DashboardView.vue` using `CnDashboardPage` as the layout component -- [ ] 5.2 Add four `CnStatsBlock` KPI cards: upcoming meetings (lifecycle=scheduled), pending motions (lifecycle=submitted|debating), open action items (taskStatus=open|in-progress), recent decisions (outcome=adopted, last 30 days) -- [ ] 5.3 Fetch all four KPI data sources in parallel using `Promise.all` in the `created()` hook; show `NcLoadingIcon` skeleton while loading -- [ ] 5.4 Add `CnChartWidget` (donut) showing Meeting lifecycle distribution; show empty-state message when no Meeting objects exist -- [ ] 5.5 Add `CnTileWidget` tiles for Meetings, Motions, Decisions, Participants, Governance Bodies; each tile routes to its list view -- [ ] 5.6 Implement 60-character title truncation with `…` for meeting titles on dashboard cards; expose full title via tooltip (`title` attribute) +- [x] 5.1 Create `src/views/DashboardView.vue` using `CnDashboardPage` as the layout component +- [x] 5.2 Add four `CnStatsBlock` KPI cards: upcoming meetings (lifecycle=scheduled), pending motions (lifecycle=submitted|debating), open action items (taskStatus=open|in-progress), recent decisions (outcome=adopted, last 30 days) +- [x] 5.3 Fetch all four KPI data sources in parallel using `Promise.all` in the `created()` hook; show `NcLoadingIcon` skeleton while loading +- [x] 5.4 Add `CnChartWidget` (donut) showing Meeting lifecycle distribution; show empty-state message when no Meeting objects exist +- [x] 5.5 Add `CnTileWidget` tiles for Meetings, Motions, Decisions, Participants, Governance Bodies; each tile routes to its list view +- [x] 5.6 Implement 60-character title truncation with `…` for meeting titles on dashboard cards; expose full title via tooltip (`title` attribute) ## 6. Frontend — Global Search -- [ ] 6.1 Add a search input component in the navigation header (or `NcAppNavigationSearch`) that triggers a search after 3+ characters with 400 ms debounce -- [ ] 6.2 Implement `useSearch` composable (or inline) that calls OpenRegister `_search` API across Meeting, Motion, Decision, AgendaItem, Participant schemas -- [ ] 6.3 Render up to 10 results in a floating dropdown with entity type icon, title, and lifecycle/status badge; group or label by type -- [ ] 6.4 Implement keyboard navigation in dropdown: down/up arrow to move between results, Enter to navigate, Escape to close and return focus to input -- [ ] 6.5 Show "Geen resultaten gevonden" message when the search returns zero matches +- [x] 6.1 Add a search input component in the navigation header (or `NcAppNavigationSearch`) that triggers a search after 3+ characters with 400 ms debounce +- [x] 6.2 Implement `useSearch` composable (or inline) that calls OpenRegister `_search` API across Meeting, Motion, Decision, AgendaItem, Participant schemas +- [x] 6.3 Render up to 10 results in a floating dropdown with entity type icon, title, and lifecycle/status badge; group or label by type +- [x] 6.4 Implement keyboard navigation in dropdown: down/up arrow to move between results, Enter to navigate, Escape to close and return focus to input +- [x] 6.5 Show "Geen resultaten gevonden" message when the search returns zero matches ## 7. Frontend — Settings Page -- [ ] 7.1 Create `src/views/SettingsView.vue` with `CnVersionInfoCard` as first element, then `CnRegisterMapping`, then `CnSettingsSection` blocks per feature area -- [ ] 7.2 Add "Register opnieuw importeren" button that calls `POST /apps/decidesk/api/settings/load` and shows an `NcToast` success or error notification +- [x] 7.1 Create `src/views/SettingsView.vue` with `CnVersionInfoCard` as first element, then `CnRegisterMapping`, then `CnSettingsSection` blocks per feature area +- [x] 7.2 Add "Register opnieuw importeren" button that calls `POST /apps/decidesk/api/settings/load` and shows an `NcToast` success or error notification ## 8. Frontend — NL Design System Theming -- [ ] 8.1 Create `src/assets/nl-design.css` mapping NL Design System token names (e.g. `--nldesign-color-brand-1`) to Nextcloud CSS variable equivalents (e.g. `var(--color-primary)`) -- [ ] 8.2 Import `nl-design.css` in `src/main.js`; verify no `--nldesign-*` references remain in component ` diff --git a/src/main.js b/src/main.js index 3278f24e..7b160404 100644 --- a/src/main.js +++ b/src/main.js @@ -1,14 +1,19 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + import Vue from 'vue' import { PiniaVuePlugin } from 'pinia' import { translate as t, translatePlural as n, loadTranslations } from '@nextcloud/l10n' import pinia from './pinia.js' import router from './router/index.js' import App from './App.vue' -import { initializeStores } from './store/store.js' // Library CSS — must be explicit import (webpack tree-shakes side-effect imports from aliased packages) import '@conduction/nextcloud-vue/css/index.css' +// NL Design System token mapping (ADR-010) +import './assets/nl-design.css' + // Global (unscoped) app styles import './assets/app.css' @@ -25,7 +30,4 @@ loadTranslations('decidesk', () => { // Mount immediately so the App renders (NC32 needs #content to be taken over). app.$mount('#content') - - // Initialize stores after mount. - initializeStores() }) diff --git a/src/router/index.js b/src/router/index.js index 7f7fc7b4..3f0c966e 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,17 +1,48 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + import Vue from 'vue' import Router from 'vue-router' import { generateUrl } from '@nextcloud/router' -import Dashboard from '../views/Dashboard.vue' -import AdminRoot from '../views/settings/AdminRoot.vue' + +/** + * @spec openspec/changes/p1-dashboard-and-navigation/tasks.md#task-3.4 + */ Vue.use(Router) +/** + * Lazy-load view components to reduce initial bundle size. + */ +const DashboardView = () => import('../views/DashboardView.vue') +const MeetingList = () => import('../views/MeetingList.vue') +const MeetingDetail = () => import('../views/MeetingDetail.vue') +const MotionList = () => import('../views/MotionList.vue') +const MotionDetail = () => import('../views/MotionDetail.vue') +const DecisionList = () => import('../views/DecisionList.vue') +const DecisionDetail = () => import('../views/DecisionDetail.vue') +const ParticipantList = () => import('../views/ParticipantList.vue') +const ParticipantDetail = () => import('../views/ParticipantDetail.vue') +const GovernanceBodyList = () => import('../views/GovernanceBodyList.vue') +const GovernanceBodyDetail = () => import('../views/GovernanceBodyDetail.vue') +const SettingsView = () => import('../views/SettingsView.vue') + export default new Router({ mode: 'history', - base: generateUrl('/apps/decidesk'), + base: generateUrl('/apps/decidesk') + '/', routes: [ - { path: '/', name: 'Dashboard', component: Dashboard }, - { path: '/settings', name: 'Settings', component: AdminRoot }, + { path: '/', name: 'Dashboard', component: DashboardView }, + { path: '/meetings', name: 'MeetingList', component: MeetingList }, + { path: '/meetings/:id', name: 'MeetingDetail', component: MeetingDetail, props: true }, + { path: '/motions', name: 'MotionList', component: MotionList }, + { path: '/motions/:id', name: 'MotionDetail', component: MotionDetail, props: true }, + { path: '/decisions', name: 'DecisionList', component: DecisionList }, + { path: '/decisions/:id', name: 'DecisionDetail', component: DecisionDetail, props: true }, + { path: '/participants', name: 'ParticipantList', component: ParticipantList }, + { path: '/participants/:id', name: 'ParticipantDetail', component: ParticipantDetail, props: true }, + { path: '/governance-bodies', name: 'GovernanceBodyList', component: GovernanceBodyList }, + { path: '/governance-bodies/:id', name: 'GovernanceBodyDetail', component: GovernanceBodyDetail, props: true }, + { path: '/settings', name: 'Settings', component: SettingsView }, { path: '*', redirect: '/' }, ], }) diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue new file mode 100644 index 00000000..2e7bada8 --- /dev/null +++ b/src/views/DashboardView.vue @@ -0,0 +1,275 @@ + + + + + + + + + diff --git a/src/views/DecisionDetail.vue b/src/views/DecisionDetail.vue new file mode 100644 index 00000000..aa9e49d9 --- /dev/null +++ b/src/views/DecisionDetail.vue @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/views/DecisionList.vue b/src/views/DecisionList.vue new file mode 100644 index 00000000..ac3f31e4 --- /dev/null +++ b/src/views/DecisionList.vue @@ -0,0 +1,28 @@ + + + + + + + diff --git a/src/views/GovernanceBodyDetail.vue b/src/views/GovernanceBodyDetail.vue new file mode 100644 index 00000000..bb8afc7f --- /dev/null +++ b/src/views/GovernanceBodyDetail.vue @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/views/GovernanceBodyList.vue b/src/views/GovernanceBodyList.vue new file mode 100644 index 00000000..bbdf63bf --- /dev/null +++ b/src/views/GovernanceBodyList.vue @@ -0,0 +1,28 @@ + + + + + + + diff --git a/src/views/MeetingDetail.vue b/src/views/MeetingDetail.vue new file mode 100644 index 00000000..c6bfbb11 --- /dev/null +++ b/src/views/MeetingDetail.vue @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/views/MeetingList.vue b/src/views/MeetingList.vue new file mode 100644 index 00000000..fe4f8844 --- /dev/null +++ b/src/views/MeetingList.vue @@ -0,0 +1,28 @@ + + + + + + + diff --git a/src/views/MotionDetail.vue b/src/views/MotionDetail.vue new file mode 100644 index 00000000..8ee5cab8 --- /dev/null +++ b/src/views/MotionDetail.vue @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/views/MotionList.vue b/src/views/MotionList.vue new file mode 100644 index 00000000..0a4b7224 --- /dev/null +++ b/src/views/MotionList.vue @@ -0,0 +1,28 @@ + + + + + + + diff --git a/src/views/ParticipantDetail.vue b/src/views/ParticipantDetail.vue new file mode 100644 index 00000000..d90512a5 --- /dev/null +++ b/src/views/ParticipantDetail.vue @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/views/ParticipantList.vue b/src/views/ParticipantList.vue new file mode 100644 index 00000000..5fcf99cb --- /dev/null +++ b/src/views/ParticipantList.vue @@ -0,0 +1,28 @@ + + + + + + + diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue new file mode 100644 index 00000000..2a72a30b --- /dev/null +++ b/src/views/SettingsView.vue @@ -0,0 +1,162 @@ + + + + + + + + + diff --git a/tests/unit/Controller/SettingsControllerTest.php b/tests/unit/Controller/SettingsControllerTest.php index 5342ec2d..d2e239a7 100644 --- a/tests/unit/Controller/SettingsControllerTest.php +++ b/tests/unit/Controller/SettingsControllerTest.php @@ -21,8 +21,12 @@ use OCA\Decidesk\Controller\SettingsController; use OCA\Decidesk\Service\SettingsService; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -53,6 +57,34 @@ class SettingsControllerTest extends TestCase */ private SettingsService&MockObject $settingsService; + /** + * Mock IGroupManager. + * + * @var IGroupManager&MockObject + */ + private IGroupManager&MockObject $groupManager; + + /** + * Mock IUserSession. + * + * @var IUserSession&MockObject + */ + private IUserSession&MockObject $userSession; + + /** + * Mock admin IUser. + * + * @var IUser&MockObject + */ + private IUser&MockObject $adminUser; + + /** + * Mock non-admin IUser. + * + * @var IUser&MockObject + */ + private IUser&MockObject $nonAdminUser; + /** * Set up test fixtures. * @@ -64,10 +96,20 @@ protected function setUp(): void $this->request = $this->createMock(originalClassName: IRequest::class); $this->settingsService = $this->createMock(originalClassName: SettingsService::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + $this->userSession = $this->createMock(originalClassName: IUserSession::class); + + $this->adminUser = $this->createMock(originalClassName: IUser::class); + $this->adminUser->method('getUID')->willReturn('admin'); + + $this->nonAdminUser = $this->createMock(originalClassName: IUser::class); + $this->nonAdminUser->method('getUID')->willReturn('regularuser'); $this->controller = new SettingsController( request: $this->request, settingsService: $this->settingsService, + groupManager: $this->groupManager, + userSession: $this->userSession, ); }//end setUp() @@ -97,7 +139,7 @@ public function testIndexReturnsJsonResponseWithSettings(): void }//end testIndexReturnsJsonResponseWithSettings() /** - * Test that create() calls updateSettings with request params and returns success. + * Test that create() calls updateSettings with request params and returns success for admin. * * @return void */ @@ -106,6 +148,15 @@ public function testCreateCallsUpdateSettingsAndReturnsSuccess(): void $params = ['register' => 'new-uuid']; $updated = ['register' => 'new-uuid', 'openregisters' => true, 'isAdmin' => false]; + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($this->adminUser); + + $this->groupManager->expects($this->once()) + ->method('isAdmin') + ->with('admin') + ->willReturn(true); + $this->request->expects($this->once()) ->method('getParams') ->willReturn($params); @@ -124,7 +175,54 @@ public function testCreateCallsUpdateSettingsAndReturnsSuccess(): void }//end testCreateCallsUpdateSettingsAndReturnsSuccess() /** - * Test that load() returns the result of loadConfiguration. + * Test that create() returns HTTP 403 for non-admin users. + * + * @return void + */ + public function testCreateReturnsForbiddenForNonAdmin(): void + { + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($this->nonAdminUser); + + $this->groupManager->expects($this->once()) + ->method('isAdmin') + ->with('regularuser') + ->willReturn(false); + + $this->settingsService->expects($this->never()) + ->method('updateSettings'); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testCreateReturnsForbiddenForNonAdmin() + + /** + * Test that create() returns HTTP 403 when no user is logged in. + * + * @return void + */ + public function testCreateReturnsForbiddenForUnauthenticatedUser(): void + { + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn(null); + + $this->settingsService->expects($this->never()) + ->method('updateSettings'); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testCreateReturnsForbiddenForUnauthenticatedUser() + + /** + * Test that load() returns the result of loadConfiguration for admin. * * @return void */ @@ -136,6 +234,15 @@ public function testLoadReturnsConfigurationResult(): void 'version' => '0.1.0', ]; + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($this->adminUser); + + $this->groupManager->expects($this->once()) + ->method('isAdmin') + ->with('admin') + ->willReturn(true); + $this->settingsService->expects($this->once()) ->method('loadConfiguration') ->with(force: true) @@ -147,4 +254,52 @@ public function testLoadReturnsConfigurationResult(): void self::assertTrue(condition: $result->getData()['success']); }//end testLoadReturnsConfigurationResult() + + /** + * Test that load() returns HTTP 403 for non-admin users. + * + * @return void + */ + public function testLoadReturnsForbiddenForNonAdmin(): void + { + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($this->nonAdminUser); + + $this->groupManager->expects($this->once()) + ->method('isAdmin') + ->with('regularuser') + ->willReturn(false); + + $this->settingsService->expects($this->never()) + ->method('loadConfiguration'); + + $result = $this->controller->load(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testLoadReturnsForbiddenForNonAdmin() + + /** + * Test that load() returns HTTP 403 when no user is logged in. + * + * @return void + */ + public function testLoadReturnsForbiddenForUnauthenticatedUser(): void + { + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn(null); + + $this->settingsService->expects($this->never()) + ->method('loadConfiguration'); + + $result = $this->controller->load(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testLoadReturnsForbiddenForUnauthenticatedUser() + }//end class