diff --git a/lib/Controller/WidgetApiController.php b/lib/Controller/WidgetApiController.php index eb5d136e7..921b6c092 100644 --- a/lib/Controller/WidgetApiController.php +++ b/lib/Controller/WidgetApiController.php @@ -78,6 +78,23 @@ public function __construct( */ #[NoAdminRequired] public function listAvailable(): JSONResponse { + $denial = $this->denyListAvailable(); + if ($denial !== null) { + return $denial; + } + + $widgets = $this->sources->widgetService->getAvailableWidgets(); + + return ResponseHelper::success(data: $this->allowedWidgets(widgets: $widgets)); + }//end listAvailable() + + /** + * The caller guard of {@see self::listAvailable()}: no session user + * → 401, a denied ADR-023 action → 403; NULL to proceed. + * + * @return JSONResponse|null The refusal, or NULL. + */ + private function denyListAvailable(): ?JSONResponse { $user = $this->actor->userSession->getUser(); if ($user === null) { return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); @@ -89,10 +106,21 @@ public function listAvailable(): JSONResponse { return new JSONResponse(['error' => 'Forbidden'], \OCP\AppFramework\Http::STATUS_FORBIDDEN); } - $widgets = $this->sources->widgetService->getAvailableWidgets(); + return null; + }//end denyListAvailable() + /** + * The widgets the signed-in user's role-feature profile permits; every + * widget without a user or when nothing is configured + * (backwards-compat). + * + * @param array> $widgets The available widgets. + * + * @return array> The permitted widgets. + */ + private function allowedWidgets(array $widgets): array { if ($this->userId === null) { - return ResponseHelper::success(data: $widgets); + return $widgets; } $allowed = $this->policy->roleFeaturePerm->getAllowedWidgetIds( @@ -100,26 +128,31 @@ public function listAvailable(): JSONResponse { ); if ($allowed === null) { // Backwards-compat: nothing configured, return everything. - return ResponseHelper::success(data: $widgets); + return $widgets; } - $filtered = array_values( - array: array_filter( - array: $widgets, - callback: function (array $widget) use ($allowed): bool { - $id = (string)($widget['id'] ?? ''); - return $id !== '' - && in_array( - needle: $id, - haystack: $allowed, - strict: true - ); - } - ) - ); + return self::widgetsWithIdIn(widgets: $widgets, allowed: $allowed); + }//end allowedWidgets() - return ResponseHelper::success(data: $filtered); - }//end listAvailable() + /** + * The widgets whose non-empty id is in the allowed list, in order. + * + * @param array> $widgets The available widgets. + * @param array $allowed The allowed widget ids. + * + * @return array> The kept widgets. + */ + private static function widgetsWithIdIn(array $widgets, array $allowed): array { + $kept = []; + foreach ($widgets as $widget) { + $id = (string)($widget['id'] ?? ''); + if ($id !== '' && in_array(needle: $id, haystack: $allowed, strict: true) === true) { + $kept[] = $widget; + } + } + + return $kept; + }//end widgetsWithIdIn() /** * Get widget items for specified widgets. @@ -213,41 +246,66 @@ public function addWidget( return $depthDenial; } - // Forward the per-type content payload (registry-driven custom - // widgets carry their config here — `label`, `text`, `image`, etc.). - // Tolerant of legacy callers that send only `widgetId` and grid - // coords with no content blob: $contentParam stays null and - // PlacementService leaves the column NULL. - $contentToPersist = null; - if (is_array($contentParam) === true) { - $contentToPersist = $contentParam; - } + $layout = [ + 'gridX' => $gridX, + 'gridY' => $gridY, + 'gridWidth' => $gridWidth, + 'gridHeight' => $gridHeight, + 'content' => self::contentToPersist(content: $contentParam), + ]; - try { - $placement = $this->sources->widgetService->addWidget( + return $this->createPlacement( + create: fn (): object => $this->sources->widgetService->addWidget( dashboardId: $dashboardId, widgetId: (string)$widgetId, - layout: [ - 'gridX' => $gridX, - 'gridY' => $gridY, - 'gridWidth' => $gridWidth, - 'gridHeight' => $gridHeight, - 'content' => $contentToPersist, - ] - ); + layout: $layout + ) + ); + }//end addWidget() + + /** + * The per-type content payload to persist with a new widget. + * + * Registry-driven custom widgets carry their config here — `label`, + * `text`, `image`, etc. Tolerant of legacy callers that send only + * `widgetId` and grid coords with no content blob: the content stays + * null and PlacementService leaves the column NULL. + * + * @param mixed $content The request's `content` parameter. + * + * @return array|null The content, or null. + */ + private static function contentToPersist(mixed $content): ?array { + if (is_array($content) === true) { + return $content; + } + + return null; + }//end contentToPersist() + /** + * Create a placement and answer 201 with it, mapping the widget quota + * to 409 and any other failure to the generic error. + * + * @param callable(): object $create Creates the placement. + * + * @return JSONResponse The placement, or the error. + */ + private function createPlacement(callable $create): JSONResponse { + try { return ResponseHelper::success( - data: $placement->jsonSerialize(), + data: $create()->jsonSerialize(), statusCode: Http::STATUS_CREATED ); } catch (QuotaExceededException $e) { - // Dashboard-quota-limits REQ-QUOTA-003: dashboard is at the - // widget limit — HTTP 409 with the structured body. + // Dashboard-quota-limits REQ-QUOTA-003: the dashboard is at the + // widget limit (tiles are placements too) — HTTP 409 with the + // structured body. return ResponseHelper::quotaExceeded(exception: $e); } catch (\Exception $e) { return ResponseHelper::error(exception: $e); }//end try - }//end addWidget() + }//end createPlacement() /** * Resolve the guard chain for {@see self::addWidget()}. @@ -280,12 +338,26 @@ private function denyAddWidget(int $dashboardId, ?string $widgetId): ?JSONRespon return ResponseHelper::unauthorized(); } - // Validate the widgetId parameter explicitly so a missing / - // empty value returns 400 rather than letting PHP's TypeError - // bubble up to a 500. The route declared `string $widgetId` - // (non-nullable) before — Newman's drift test sends a body - // without the field and used to crash the dispatcher. - if ($widgetId === null || $widgetId === '') { + return $this->denyWidgetChoice(dashboardId: $dashboardId, widgetId: $widgetId); + }//end denyAddWidget() + + /** + * Refuse a widget the request does not name, or one the user may not + * add to this dashboard. + * + * Validate the widgetId parameter explicitly so a missing / + * empty value returns 400 rather than letting PHP's TypeError + * bubble up to a 500. The route declared `string $widgetId` + * (non-nullable) before — Newman's drift test sends a body + * without the field and used to crash the dispatcher. + * + * @param int $dashboardId The dashboard ID. + * @param string|null $widgetId The widget type ID. + * + * @return JSONResponse|null The refusal, or NULL to proceed. + */ + private function denyWidgetChoice(int $dashboardId, ?string $widgetId): ?JSONResponse { + if (self::isBlankId(id: $widgetId) === true) { return ResponseHelper::error( exception: new InvalidArgumentException( 'Missing required field: widgetId' @@ -294,28 +366,48 @@ private function denyAddWidget(int $dashboardId, ?string $widgetId): ?JSONRespon ); } - if ($this->permissionService->canAddWidget( - userId: $this->userId, - dashboardId: $dashboardId - ) === false - ) { - return ResponseHelper::forbidden(); - } - - // REQ-RFP-001 / REQ-RFP-003: a user may only add a widget that their - // role-feature-permission profile permits. `isWidgetAllowed` returns - // `true` when no restriction is configured (null allowed set), so - // unconfigured deployments are unaffected. - if ($this->policy->roleFeaturePerm->isWidgetAllowed( - userId: $this->userId, - widgetId: $widgetId - ) === false - ) { + if ($this->mayAddWidget(dashboardId: $dashboardId, widgetId: (string)$widgetId) === false) { return ResponseHelper::forbidden(); } return null; - }//end denyAddWidget() + }//end denyWidgetChoice() + + /** + * Whether an id is missing or empty. + * + * @param string|null $id The id. + * + * @return bool True when blank. + */ + private static function isBlankId(?string $id): bool { + return $id === null || $id === ''; + }//end isBlankId() + + /** + * Whether the user may add this widget to this dashboard: the + * dashboard permission, then the role-feature profile. + * + * REQ-RFP-001 / REQ-RFP-003: a user may only add a widget that their + * role-feature-permission profile permits. `isWidgetAllowed` returns + * `true` when no restriction is configured (null allowed set), so + * unconfigured deployments are unaffected. + * + * @param int $dashboardId The dashboard ID. + * @param string $widgetId The widget type ID. + * + * @return bool True when both permit it. + */ + private function mayAddWidget(int $dashboardId, string $widgetId): bool { + return $this->permissionService->canAddWidget( + userId: $this->userId, + dashboardId: $dashboardId + ) !== false + && $this->policy->roleFeaturePerm->isWidgetAllowed( + userId: $this->userId, + widgetId: $widgetId + ) !== false; + }//end mayAddWidget() /** * Guard the mandatory-read acknowledgement fields on a placement @@ -336,23 +428,7 @@ private function denyAddWidget(int $dashboardId, ?string $widgetId): ?JSONRespon * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-34 */ private function denyAcknowledgementChange(int $placementId): ?JSONResponse { - $ackFields = [ - 'requiresAcknowledgement', - 'acknowledgementPrompt', - 'acknowledgementDeadline', - 'reacknowledgeOnChange', - 'acknowledgementContentVersion', - ]; - - $touchesAck = false; - foreach ($ackFields as $ackField) { - if ($this->request->getParam(key: $ackField) !== null) { - $touchesAck = true; - break; - } - } - - if ($touchesAck === false) { + if ($this->touchesAcknowledgement() === false) { return null; } @@ -369,6 +445,29 @@ private function denyAcknowledgementChange(int $placementId): ?JSONResponse { return null; }//end denyAcknowledgementChange() + /** + * Whether the request sets any acknowledgement field. + * + * @return bool True when at least one acknowledgement field is present. + */ + private function touchesAcknowledgement(): bool { + $ackFields = [ + 'requiresAcknowledgement', + 'acknowledgementPrompt', + 'acknowledgementDeadline', + 'reacknowledgeOnChange', + 'acknowledgementContentVersion', + ]; + + foreach ($ackFields as $ackField) { + if ($this->request->getParam(key: $ackField) !== null) { + return true; + } + } + + return false; + }//end touchesAcknowledgement() + /** * Validate the container-nesting depth of an inbound content payload. * @@ -445,35 +544,45 @@ public function addTile(int $dashboardId): JSONResponse { return ResponseHelper::unauthorized(); } - if ($this->permissionService->canAddWidget( - userId: $this->userId, - dashboardId: $dashboardId - ) === false - ) { + if ($this->mayAddTo(dashboardId: $dashboardId) === false) { return ResponseHelper::forbidden(); } - try { - $placement = $this->sources->widgetService->addTileFromArray( - dashboardId: $dashboardId, - tileData: RequestDataExtractor::extractTileData( - request: $this->request - ) - ); - - return ResponseHelper::success( - data: $placement->jsonSerialize(), - statusCode: Http::STATUS_CREATED - ); - } catch (QuotaExceededException $e) { - // Dashboard-quota-limits REQ-QUOTA-003: tiles are placements - // and bound by the widget quota — HTTP 409 structured body. - return ResponseHelper::quotaExceeded(exception: $e); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try + return $this->createPlacement( + create: fn (): object => $this->addTileFromRequest(dashboardId: $dashboardId) + ); }//end addTile() + /** + * Whether the user may add widgets or tiles to a dashboard. + * + * @param int $dashboardId The dashboard ID. + * + * @return bool True when allowed. + */ + private function mayAddTo(int $dashboardId): bool { + return $this->permissionService->canAddWidget( + userId: $this->userId, + dashboardId: $dashboardId + ) !== false; + }//end mayAddTo() + + /** + * Add the tile the request describes to a dashboard. + * + * @param int $dashboardId The dashboard ID. + * + * @return object The created placement. + */ + private function addTileFromRequest(int $dashboardId): object { + return $this->sources->widgetService->addTileFromArray( + dashboardId: $dashboardId, + tileData: RequestDataExtractor::extractTileData( + request: $this->request + ) + ); + }//end addTileFromRequest() + /** * Update a widget placement. * @@ -496,11 +605,29 @@ public function updatePlacement(int $placementId): JSONResponse { return ResponseHelper::unauthorized(); } - if ($this->permissionService->canStyleWidget( - userId: $this->userId, - placementId: $placementId - ) === false - ) { + $denial = $this->denyPlacementUpdate(placementId: $placementId); + if ($denial !== null) { + return $denial; + } + + try { + return $this->updatedPlacement(placementId: $placementId); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + } + }//end updatePlacement() + + /** + * Refuse a placement update the user may not style, one that sets an + * acknowledgement requirement the user may not manage, or one whose + * container content nests too deep. + * + * @param int $placementId The placement ID. + * + * @return JSONResponse|null The refusal, or NULL to proceed. + */ + private function denyPlacementUpdate(int $placementId): ?JSONResponse { + if ($this->mayStyle(placementId: $placementId) === false) { return ResponseHelper::forbidden(); } @@ -512,28 +639,44 @@ public function updatePlacement(int $placementId): JSONResponse { // REQ-CONT-006: validate the container depth invariant on // update too — a placement can grow nested children via PUT // without ever going through addWidget. - $depthDenial = $this->denyContainerDepth( + return $this->denyContainerDepth( content: $this->request->getParam(key: 'content') ); - if ($depthDenial !== null) { - return $depthDenial; - } + }//end denyPlacementUpdate() - try { - $placement = $this->sources->widgetService->updatePlacement( - placementId: $placementId, - data: RequestDataExtractor::extractPlacementData( - request: $this->request - ) - ); + /** + * Whether the user may restyle a placement. + * + * @param int $placementId The placement ID. + * + * @return bool True when allowed. + */ + private function mayStyle(int $placementId): bool { + return $this->permissionService->canStyleWidget( + userId: $this->userId, + placementId: $placementId + ) !== false; + }//end mayStyle() - return ResponseHelper::success( - data: $placement->jsonSerialize() - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end updatePlacement() + /** + * Apply the request's placement fields and answer with the placement. + * + * @param int $placementId The placement ID. + * + * @return JSONResponse The updated placement. + */ + private function updatedPlacement(int $placementId): JSONResponse { + $placement = $this->sources->widgetService->updatePlacement( + placementId: $placementId, + data: RequestDataExtractor::extractPlacementData( + request: $this->request + ) + ); + + return ResponseHelper::success( + data: $placement->jsonSerialize() + ); + }//end updatedPlacement() /** * Remove a widget placement. @@ -602,12 +745,35 @@ public function newsItems(int $placementId, ?int $limit = 10): JSONResponse { return $refusal; } - $effectiveLimit = $limit; - if ($effectiveLimit === null) { - $effectiveLimit = 10; + return $this->newsFor(placementId: $placementId, limit: self::effectiveNewsLimit(limit: $limit)); + }//end newsItems() + + /** + * The news limit to use: 10 when none was given. + * + * @param int|null $limit The requested limit. + * + * @return int The limit. + */ + private static function effectiveNewsLimit(?int $limit): int { + if ($limit === null) { + return 10; } - if ($effectiveLimit < 1 || $effectiveLimit > 50) { + return $limit; + }//end effectiveNewsLimit() + + /** + * Check the limit and the view permission, then answer with the news + * items. + * + * @param int $placementId The placement ID. + * @param int $limit The effective limit. + * + * @return JSONResponse The items, or the refusal. + */ + private function newsFor(int $placementId, int $limit): JSONResponse { + if ($limit < 1 || $limit > 50) { return $this->failure(message: 'limit out of range (1..50)', statusCode: Http::STATUS_BAD_REQUEST); } @@ -620,11 +786,11 @@ public function newsItems(int $placementId, ?int $limit = 10): JSONResponse { $payload = $this->sources->newsWidgetService->getItemsForPlacement( placementId: $placementId, - limit: $effectiveLimit + limit: $limit ); return ResponseHelper::success(data: $payload); - }//end newsItems() + }//end newsFor() /** * Get aggregated events for a calendar-widget placement. @@ -661,9 +827,19 @@ public function calendarEvents( return $range['error']; } - $start = $range['start']; - $end = $range['end']; + return $this->calendarEventsIn(placementId: $placementId, range: $range); + }//end calendarEvents() + /** + * Look the placement up, check the view permission, then answer with + * its events inside the range. + * + * @param int $placementId The placement ID. + * @param array{start: DateTimeImmutable, end: DateTimeImmutable} $range The resolved range. + * + * @return JSONResponse The events, or the refusal or error. + */ + private function calendarEventsIn(int $placementId, array $range): JSONResponse { try { $placement = $this->sources->widgetService->getPlacement(placementId: $placementId); } catch (Throwable $exception) { @@ -678,20 +854,32 @@ public function calendarEvents( return $refusal; } + return $this->calendarEventsOf(placement: $placement, range: $range); + }//end calendarEventsIn() + + /** + * Answer with a calendar placement's events inside the range. + * + * @param object $placement The calendar placement. + * @param array{start: DateTimeImmutable, end: DateTimeImmutable} $range The resolved range. + * + * @return JSONResponse The events, or the error. + */ + private function calendarEventsOf(object $placement, array $range): JSONResponse { $config = $this->extractCalendarConfig(placement: $placement); try { $result = $this->sources->calendarService->getEvents( config: $config, - from: $start->format(format: \DATE_ATOM), - to: $end->format(format: \DATE_ATOM) + from: $range['start']->format(format: \DATE_ATOM), + to: $range['end']->format(format: \DATE_ATOM) ); } catch (\Exception $exception) { return ResponseHelper::error(exception: $exception); } return ResponseHelper::success(data: $result); - }//end calendarEvents() + }//end calendarEventsOf() /** * Resolve the authentication / authorisation guard chain for @@ -739,22 +927,46 @@ private function resolveCalendarRange(string $from, string $to): array { return $this->rangeRefusal(message: 'Invalid date format'); } + return $this->orderedRange(start: $start, end: $end); + }//end resolveCalendarRange() + + /** + * The range of two parsed instants, refused when it ends before it + * starts, with the end capped one year after the start. + * + * @param DateTimeImmutable $start The range start. + * @param DateTimeImmutable $end The requested range end. + * + * @return array{error: JSONResponse|null, start: DateTimeImmutable|null, end: DateTimeImmutable|null} + */ + private function orderedRange(DateTimeImmutable $start, DateTimeImmutable $end): array { if ($end < $start) { return $this->rangeRefusal(message: '`to` must be greater than or equal to `from`'); } - // Defensive 1-year cap per design D1 to bound RRULE expansion. - $maxEnd = $start->modify(modifier: '+1 year'); - if ($end > $maxEnd) { - $end = $maxEnd; - } - return [ 'error' => null, 'start' => $start, - 'end' => $end, + 'end' => self::cappedEnd(start: $start, end: $end), ]; - }//end resolveCalendarRange() + }//end orderedRange() + + /** + * Defensive 1-year cap per design D1 to bound RRULE expansion. + * + * @param DateTimeImmutable $start The range start. + * @param DateTimeImmutable $end The requested range end. + * + * @return DateTimeImmutable The end, at most one year after the start. + */ + private static function cappedEnd(DateTimeImmutable $start, DateTimeImmutable $end): DateTimeImmutable { + $maxEnd = $start->modify(modifier: '+1 year'); + if ($end > $maxEnd) { + return $maxEnd; + } + + return $end; + }//end cappedEnd() /** * List the current user's internal Nextcloud calendars so the calendar diff --git a/tests/Unit/Controller/WidgetApiControllerActionsGoldenTest.php b/tests/Unit/Controller/WidgetApiControllerActionsGoldenTest.php new file mode 100644 index 000000000..fa240e29b --- /dev/null +++ b/tests/Unit/Controller/WidgetApiControllerActionsGoldenTest.php @@ -0,0 +1,324 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 LaunchPad Contributors + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Controller; + +use DateTimeImmutable; +use Exception; +use InvalidArgumentException; +use OCA\LaunchPad\Controller\RequestActor; +use OCA\LaunchPad\Controller\WidgetApiController; +use OCA\LaunchPad\Controller\WidgetApiPolicy; +use OCA\LaunchPad\Controller\WidgetApiSources; +use OCA\LaunchPad\Db\WidgetPlacement; +use OCA\LaunchPad\Exception\QuotaExceededException; +use OCA\LaunchPad\Service\ActionAuthService; +use OCA\LaunchPad\Service\CalendarWidgetService; +use OCA\LaunchPad\Service\NewsWidgetService; +use OCA\LaunchPad\Service\PermissionService; +use OCA\LaunchPad\Service\RoleFeaturePermissionService; +use OCA\LaunchPad\Service\WidgetPlacementService; +use OCA\LaunchPad\Service\WidgetService; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors the controller constructor. + */ +class WidgetApiControllerActionsGoldenTest extends TestCase { + private IRequest $request; + private WidgetService $widgetService; + private PermissionService $permissionService; + private NewsWidgetService $newsService; + private CalendarWidgetService $calendarService; + private WidgetPlacementService $placementService; + private RoleFeaturePermissionService $roleFeaturePerm; + + /** @var array */ + private array $params = []; + + protected function setUp(): void { + $this->request = $this->createMock(IRequest::class); + $this->request->method('getParam')->willReturnCallback(fn (string $key, $default = null) => $this->params[$key] ?? $default); + $this->widgetService = $this->createMock(WidgetService::class); + $this->permissionService = $this->createMock(PermissionService::class); + $this->newsService = $this->createMock(NewsWidgetService::class); + $this->calendarService = $this->createMock(CalendarWidgetService::class); + $this->placementService = $this->createMock(WidgetPlacementService::class); + $this->roleFeaturePerm = $this->createMock(RoleFeaturePermissionService::class); + } + + private function controller(?string $userId = 'alice'): WidgetApiController { + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($this->createMock(IUser::class)); + + return new WidgetApiController( + $this->request, + new WidgetApiSources($this->widgetService, $this->newsService, $this->calendarService, $this->placementService), + $this->permissionService, + new WidgetApiPolicy($this->roleFeaturePerm, $this->createMock(LoggerInterface::class)), + new RequestActor($session, $this->createMock(ActionAuthService::class)), + $userId, + ); + } + + private function outcome(JSONResponse $response): array { + return [$response->getStatus(), $response->getData()]; + } + + private function placement(int $id = 9): WidgetPlacement { + $placement = new WidgetPlacement(); + $placement->setId($id); + $placement->setWidgetId('w'); + + return $placement; + } + + public function testAvailableWidgetsFilter(): void { + $widgets = [['id' => 'a'], ['id' => 'b'], ['id' => ''], ['name' => 'no id'], ['id' => 3]]; + $this->widgetService->method('getAvailableWidgets')->willReturn($widgets); + $this->roleFeaturePerm->method('getAllowedWidgetIds')->willReturnOnConsecutiveCalls(null, ['b', '3', '']); + $this->roleFeaturePerm->expects($this->exactly(2))->method('getAllowedWidgetIds'); + + $this->assertSame([200, $widgets], $this->outcome($this->controller(null)->listAvailable())); + $this->assertSame([200, $widgets], $this->outcome($this->controller()->listAvailable())); + $this->assertSame([200, [['id' => 'b'], ['id' => 3]]], $this->outcome($this->controller()->listAvailable())); + } + + /** + * Add-widget refusals after the caller guard. + * + * @return array + */ + public static function addWidgetRefusals(): array { + return [ + 'no widget id' => [null, true, true, null, 400], + 'empty widget id' => ['', true, true, null, 400], + 'dashboard refused' => ['w', false, true, null, 403], + 'role refused' => ['w', true, false, null, 403], + 'too deep' => ['w', true, true, ['placements' => 'deep'], 400], + 'other depth error' => ['w', true, true, ['placements' => 'odd'], 400], + ]; + } + + /** + * @dataProvider addWidgetRefusals + */ + public function testAddWidgetRefusal(?string $widgetId, bool $canAdd, bool $roleAllows, mixed $content, int $status): void { + $this->permissionService->method('canAddWidget')->willReturn($canAdd); + $this->roleFeaturePerm->method('isWidgetAllowed')->willReturn($roleAllows); + $this->params = ['content' => $content]; + $this->placementService->method('validateContainerDepth')->willReturnCallback(static function (array $content): void { + throw new InvalidArgumentException($content['placements'] === 'deep' ? 'container_depth_exceeded' : 'weird'); + }); + $this->widgetService->expects($this->never())->method('addWidget'); + + $this->assertSame($status, $this->controller()->addWidget(dashboardId: 3, widgetId: $widgetId)->getStatus()); + } + + public function testRoleFilterIsNotAskedWhenTheDashboardRefuses(): void { + $this->permissionService->method('canAddWidget')->willReturn(false); + $this->roleFeaturePerm->expects($this->never())->method('isWidgetAllowed'); + + $this->assertSame([403, ['error' => 'Access denied']], $this->outcome($this->controller()->addWidget(dashboardId: 3, widgetId: 'w'))); + } + + public function testAddWidgetPassesLayoutAndContent(): void { + $this->permissionService->method('canAddWidget')->willReturn(true); + $this->roleFeaturePerm->method('isWidgetAllowed')->willReturn(true); + $calls = []; + $this->widgetService->method('addWidget')->willReturnCallback(function (int $dashboardId, string $widgetId, array $layout) use (&$calls): WidgetPlacement { + $calls[] = [$dashboardId, $widgetId, $layout]; + if ($widgetId === 'full') { + throw new QuotaExceededException(quota: QuotaExceededException::QUOTA_WIDGETS, limit: 1, current: 1); + } + + if ($widgetId === 'boom') { + throw new RuntimeException('x'); + } + + return $this->placement(); + }); + + $this->params = ['content' => ['label' => 'L']]; + $created = $this->controller()->addWidget(3, 'w', 1, 2, 3, 4); + $this->params = ['content' => 'text']; + $this->controller()->addWidget(3, 'w'); + $full = $this->controller()->addWidget(3, 'full'); + $boom = $this->controller()->addWidget(3, 'boom'); + + $this->assertSame(201, $created->getStatus()); + $this->assertSame(9, $created->getData()['id']); + $this->assertSame([ + [3, 'w', ['gridX' => 1, 'gridY' => 2, 'gridWidth' => 3, 'gridHeight' => 4, 'content' => ['label' => 'L']]], + [3, 'w', ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 4, 'gridHeight' => 4, 'content' => null]], + ], array_slice($calls, 0, 2)); + $this->assertSame(409, $full->getStatus()); + $this->assertSame([400, ['error' => 'Operation failed']], $this->outcome($boom)); + } + + public function testAddTileOutcomes(): void { + $this->permissionService->method('canAddWidget')->willReturnOnConsecutiveCalls(false, true, true, true); + $calls = 0; + $this->widgetService->method('addTileFromArray')->willReturnCallback(function () use (&$calls): WidgetPlacement { + $calls++; + return match ($calls) { + 1 => $this->placement(4), + 2 => throw new QuotaExceededException(quota: QuotaExceededException::QUOTA_WIDGETS, limit: 1, current: 1), + default => throw new RuntimeException('x'), + }; + }); + + $this->assertSame([403, ['error' => 'Access denied']], $this->outcome($this->controller()->addTile(dashboardId: 3))); + $this->assertSame(201, $this->controller()->addTile(dashboardId: 3)->getStatus()); + $this->assertSame(409, $this->controller()->addTile(dashboardId: 3)->getStatus()); + $this->assertSame([400, ['error' => 'Operation failed']], $this->outcome($this->controller()->addTile(dashboardId: 3))); + } + + /** + * Placement-update cases: style permission, params, ack permission, status. + * + * @return array,2:bool,3:string,4:int}> + */ + public static function placementUpdates(): array { + return [ + 'style refused' => [false, [], true, 'ok', 403], + 'ack refused' => [true, ['reacknowledgeOnChange' => false], false, 'ok', 403], + 'ack allowed' => [true, ['acknowledgementPrompt' => 'p'], true, 'ok', 200], + 'no ack fields' => [true, ['title' => 'x'], false, 'ok', 200], + 'too deep' => [true, ['content' => ['placements' => 'deep']], false, 'ok', 400], + 'service error' => [true, [], false, 'boom', 400], + ]; + } + + /** + * @dataProvider placementUpdates + */ + public function testPlacementUpdate(bool $canStyle, array $params, bool $canAck, string $service, int $status): void { + $this->params = $params; + $this->permissionService->method('canStyleWidget')->willReturn($canStyle); + $this->permissionService->method('canManageAcknowledgement')->willReturn($canAck); + $this->placementService->method('validateContainerDepth')->willThrowException(new InvalidArgumentException('container_depth_exceeded')); + $this->widgetService->method('updatePlacement')->willReturnCallback(function () use ($service): WidgetPlacement { + if ($service === 'boom') { + throw new RuntimeException('x'); + } + + return $this->placement(); + }); + + $response = $this->controller()->updatePlacement(placementId: 9); + + $this->assertSame($status, $response->getStatus()); + if ($params === ['reacknowledgeOnChange' => false]) { + $this->assertSame(['error' => 'Only an admin or the template owner may set an acknowledgement requirement'], $response->getData()); + } + } + + /** + * News limits and their outcome. + * + * @return array + */ + public static function newsLimits(): array { + return ['null' => [null, 10], 'zero' => [0, 'refused'], 'one' => [1, 1], 'fifty' => [50, 50], 'over' => [51, 'refused'], 'negative' => [-1, 'refused']]; + } + + /** + * @dataProvider newsLimits + */ + public function testNewsLimit(?int $limit, int|string $expected): void { + $this->permissionService->method('canViewPlacement')->willReturn(true); + $this->newsService->method('getItemsForPlacement')->willReturnCallback(static fn (int $placementId, int $limit): array => ['limit' => $limit]); + + $outcome = $this->outcome($this->controller()->newsItems(placementId: 4, limit: $limit)); + + if ($expected === 'refused') { + $this->assertSame(400, $outcome[0]); + $this->assertSame('limit out of range (1..50)', $outcome[1]['message'] ?? $outcome[1]['error'] ?? null); + return; + } + + $this->assertSame([200, ['limit' => $expected]], $outcome); + } + + /** + * Calendar ranges and the window passed on (or the refusal message). + * + * @return array + */ + public static function calendarRanges(): array { + return [ + 'no from' => ['', '2026-01-02T00:00:00+00:00', 'Both from and to are required ISO 8601 timestamps'], + 'no to' => ['2026-01-01T00:00:00+00:00', '', 'Both from and to are required ISO 8601 timestamps'], + 'bad date' => ['yesterday-ish', '2026-01-02T00:00:00+00:00', 'Invalid date format'], + 'inverted' => ['2026-01-02T00:00:00+00:00', '2026-01-01T00:00:00+00:00', '`to` must be greater than or equal to `from`'], + 'same instant' => ['2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00|2026-01-01T00:00:00+00:00'], + 'one year' => ['2026-01-01T00:00:00+00:00', '2027-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00|2027-01-01T00:00:00+00:00'], + 'capped' => ['2026-01-01T00:00:00+00:00', '2028-06-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00|2027-01-01T00:00:00+00:00'], + ]; + } + + /** + * @dataProvider calendarRanges + */ + public function testCalendarRange(string $from, string $to, string $expected): void { + $this->widgetService->method('getPlacement')->willReturn($this->placement()); + $this->permissionService->method('canViewPlacement')->willReturn(true); + $this->calendarService->method('getEvents')->willReturnCallback(static fn (array $config, string $from, string $to): array => ['window' => $from . '|' . $to]); + + [$status, $data] = $this->outcome($this->controller()->calendarEvents(placementId: 4, from: $from, to: $to)); + + if (str_contains($expected, '|') === true) { + $this->assertSame([200, ['window' => $expected]], [$status, $data]); + return; + } + + $this->assertSame(400, $status); + $this->assertSame($expected, $data['message'] ?? $data['error'] ?? null); + } + + public function testCalendarPlacementLookupAndEventFailures(): void { + $this->permissionService->method('canViewPlacement')->willReturnOnConsecutiveCalls(false, true); + $this->widgetService->method('getPlacement')->willReturnCallback(function (int $placementId): WidgetPlacement { + if ($placementId === 404) { + throw new Exception('gone'); + } + + return $this->placement($placementId); + }); + $this->calendarService->method('getEvents')->willThrowException(new RuntimeException('caldav down')); + $range = ['from' => '2026-01-01T00:00:00+00:00', 'to' => '2026-01-02T00:00:00+00:00']; + + $this->assertSame(404, $this->controller()->calendarEvents(404, ...$range)->getStatus()); + $this->assertSame([403, ['error' => 'Access denied']], $this->outcome($this->controller()->calendarEvents(5, ...$range))); + $this->assertSame([400, ['error' => 'Operation failed']], $this->outcome($this->controller()->calendarEvents(5, ...$range))); + } +}//end class