From 078c023ef245e4e46efe64d59bde362a7c8609aa Mon Sep 17 00:00:00 2001 From: Oleksander Piskun Date: Sat, 27 Jun 2026 11:15:41 +0000 Subject: [PATCH] feat(setup-checks): let ExApps surface setup checks in the admin overview Signed-off-by: Oleksander Piskun --- appinfo/info.xml | 1 + appinfo/routes.php | 4 + lib/AppInfo/Application.php | 4 + .../ExAppSetupChecksRefreshJob.php | 35 ++ .../ExAppSetupChecksRefreshOnceJob.php | 32 ++ lib/Controller/SetupCheckController.php | 67 ++++ lib/Service/ExAppService.php | 2 + lib/Service/ExAppSetupCheckRefreshService.php | 204 +++++++++++ lib/Service/ExAppSetupCheckService.php | 110 ++++++ lib/SetupChecks/AbstractExAppsSetupCheck.php | 199 +++++++++++ lib/SetupChecks/ExAppsErrorSetupCheck.php | 23 ++ lib/SetupChecks/ExAppsWarningSetupCheck.php | 24 ++ openapi-full.json | 133 ++++++++ openapi.json | 133 ++++++++ .../ExAppSetupCheckRefreshServiceTest.php | 320 ++++++++++++++++++ .../Service/ExAppSetupCheckServiceTest.php | 99 ++++++ .../php/SetupChecks/ExAppsSetupCheckTest.php | 226 +++++++++++++ 17 files changed, 1616 insertions(+) create mode 100644 lib/BackgroundJob/ExAppSetupChecksRefreshJob.php create mode 100644 lib/BackgroundJob/ExAppSetupChecksRefreshOnceJob.php create mode 100644 lib/Controller/SetupCheckController.php create mode 100644 lib/Service/ExAppSetupCheckRefreshService.php create mode 100644 lib/Service/ExAppSetupCheckService.php create mode 100644 lib/SetupChecks/AbstractExAppsSetupCheck.php create mode 100644 lib/SetupChecks/ExAppsErrorSetupCheck.php create mode 100644 lib/SetupChecks/ExAppsWarningSetupCheck.php create mode 100644 tests/php/Service/ExAppSetupCheckRefreshServiceTest.php create mode 100644 tests/php/Service/ExAppSetupCheckServiceTest.php create mode 100644 tests/php/SetupChecks/ExAppsSetupCheckTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 4ec6cee6e..c41865e7a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -62,6 +62,7 @@ See the [admin documentation](https://docs.nextcloud.com/server/latest/admin_man OCA\AppAPI\BackgroundJob\ExAppInitStatusCheckJob + OCA\AppAPI\BackgroundJob\ExAppSetupChecksRefreshJob diff --git a/appinfo/routes.php b/appinfo/routes.php index abe7b5503..2d4b7144a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -145,5 +145,9 @@ ['name' => 'taskProcessing#registerProvider', 'url' => '/api/v1/ai_provider/task_processing', 'verb' => 'POST'], ['name' => 'taskProcessing#unregisterProvider', 'url' => '/api/v1/ai_provider/task_processing', 'verb' => 'DELETE'], ['name' => 'taskProcessing#getProvider', 'url' => '/api/v1/ai_provider/task_processing', 'verb' => 'GET'], + + // Setup checks (admin "Security & setup warnings" panel) + ['name' => 'SetupCheck#registerChecks', 'url' => '/api/v1/setup_check', 'verb' => 'POST'], + ['name' => 'SetupCheck#unregisterChecks', 'url' => '/api/v1/setup_check', 'verb' => 'DELETE'], ], ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 670bed03e..4e8238ad1 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,6 +25,8 @@ use OCA\AppAPI\Notifications\ExAppNotifier; use OCA\AppAPI\PublicCapabilities; use OCA\AppAPI\SetupChecks\DaemonCheck; +use OCA\AppAPI\SetupChecks\ExAppsErrorSetupCheck; +use OCA\AppAPI\SetupChecks\ExAppsWarningSetupCheck; use OCA\AppAPI\SetupChecks\HarpVersionCheck; use OCA\DAV\Events\SabrePluginAddEvent; use OCA\DAV\Events\SabrePluginAuthInitEvent; @@ -74,6 +76,8 @@ public function register(IRegistrationContext $context): void { $context->registerSetupCheck(DaemonCheck::class); $context->registerSetupCheck(HarpVersionCheck::class); + $context->registerSetupCheck(ExAppsErrorSetupCheck::class); + $context->registerSetupCheck(ExAppsWarningSetupCheck::class); } public function boot(IBootContext $context): void { diff --git a/lib/BackgroundJob/ExAppSetupChecksRefreshJob.php b/lib/BackgroundJob/ExAppSetupChecksRefreshJob.php new file mode 100644 index 000000000..594df39a5 --- /dev/null +++ b/lib/BackgroundJob/ExAppSetupChecksRefreshJob.php @@ -0,0 +1,35 @@ +setInterval(self::REFRESH_INTERVAL_SECONDS); + } + + protected function run($argument): void { + $this->refreshService->refresh(); + } +} diff --git a/lib/BackgroundJob/ExAppSetupChecksRefreshOnceJob.php b/lib/BackgroundJob/ExAppSetupChecksRefreshOnceJob.php new file mode 100644 index 000000000..450c24a4a --- /dev/null +++ b/lib/BackgroundJob/ExAppSetupChecksRefreshOnceJob.php @@ -0,0 +1,32 @@ +refreshService->refresh(); + } +} diff --git a/lib/Controller/SetupCheckController.php b/lib/Controller/SetupCheckController.php new file mode 100644 index 000000000..9563b0861 --- /dev/null +++ b/lib/Controller/SetupCheckController.php @@ -0,0 +1,67 @@ +request = $request; + } + + /** + * Opt the calling ExApp in to setup checks + * + * The ExApp is identified from the authenticated `ex-app-id` header, so it can only ever opt + * itself in. Its live results are then fetched from the ExApp's `/setup_checks` endpoint by a + * background job and surfaced in the admin "Security & setup warnings" panel. + * + * @return DataResponse, array{}> + * + * 200: ExApp opted in to setup checks + */ + #[AppAPIAuth] + #[NoCSRFRequired] + #[PublicPage] + public function registerChecks(): DataResponse { + $this->setupCheckService->optIn($this->request->getHeader('ex-app-id')); + return new DataResponse(); + } + + /** + * Opt the calling ExApp out of setup checks + * + * @return DataResponse, array{}> + * + * 200: ExApp opted out of setup checks + */ + #[AppAPIAuth] + #[NoCSRFRequired] + #[PublicPage] + public function unregisterChecks(): DataResponse { + $this->setupCheckService->optOut($this->request->getHeader('ex-app-id')); + return new DataResponse(); + } +} diff --git a/lib/Service/ExAppService.php b/lib/Service/ExAppService.php index 2682a368b..a0a538241 100644 --- a/lib/Service/ExAppService.php +++ b/lib/Service/ExAppService.php @@ -56,6 +56,7 @@ public function __construct( private readonly SettingsService $settingsService, private readonly ExAppOccService $occService, private readonly ExAppDeployOptionsService $deployOptionsService, + private readonly ExAppSetupCheckService $setupCheckService, private readonly IConfig $config, ) { if ($cacheFactory->isAvailable()) { @@ -119,6 +120,7 @@ public function unregisterExApp(string $appId): bool { $this->stylesService->deleteExAppStyles($appId); $this->taskProcessingService->unregisterExAppTaskProcessingProviders($appId); $this->settingsService->unregisterExAppForms($appId); + $this->setupCheckService->optOut($appId); $this->exAppArchiveFetcher->removeExAppFolder($appId); $this->occService->unregisterExAppOccCommands($appId); $this->deployOptionsService->removeExAppDeployOptions($appId); diff --git a/lib/Service/ExAppSetupCheckRefreshService.php b/lib/Service/ExAppSetupCheckRefreshService.php new file mode 100644 index 000000000..f2c347dac --- /dev/null +++ b/lib/Service/ExAppSetupCheckRefreshService.php @@ -0,0 +1,204 @@ +setupCheckService->getState()['apps']; + $appsState = []; + $start = microtime(true); + $budgetHit = false; + foreach ($this->setupCheckService->getOptedInAppIds() as $appId) { + $exApp = $this->exAppService->getExApp($appId); + if ($exApp === null || $exApp->getEnabled() !== 1 || $this->isInitializing($exApp)) { + continue; // disabled / deploying -> dropped (down-ness shown on the management page) + } + if ($budgetHit || (microtime(true) - $start) > $this->totalBudgetSeconds) { + if (!$budgetHit) { + $this->logger->info('ExApp setup-check refresh budget exceeded; unvisited apps keep their previous results this run'); + $budgetHit = true; + } + if (isset($previous[$appId]) && is_array($previous[$appId])) { + $appsState[$appId] = $previous[$appId]; // stale-but-present beats vanishing + } + continue; + } + $issues = $this->probe($exApp); + if ($issues !== []) { + $appsState[$appId] = $issues; + } + } + $this->setupCheckService->storeState($appsState); + } catch (\Throwable $e) { + $this->logger->error('ExApp setup-check refresh failed', ['exception' => $e]); + } + } + + private function isInitializing(ExApp $exApp): bool { + $status = $exApp->getStatus(); + return ((int)($status['init'] ?? 100)) < 100 || (($status['action'] ?? '') === 'init'); + } + + /** + * @return list + */ + private function probe(ExApp $exApp): array { + try { + $result = $this->appAPIService->requestToExApp( + $exApp, + '/setup_checks', + null, + 'GET', + [], + ['timeout' => self::PER_APP_TIMEOUT_SECONDS], + ); + } catch (\Throwable $e) { + $this->logger->warning('ExApp setup-check: error probing ExApp ' . $exApp->getAppid(), ['exception' => $e]); + return [$this->notRespondingIssue($exApp)]; + } + + if (is_array($result)) { + return [$this->notRespondingIssue($exApp)]; // transport error -> ['error' => ...] + } + /** @var IResponse $result */ + $status = $result->getStatusCode(); + if ($status < 200 || $status >= 300) { + return [$this->notRespondingIssue($exApp)]; + } + // Require a sane, numeric Content-Length and refuse to read the body otherwise. A missing / + // forged / non-numeric length is treated as not-responding: it is the only way to keep an + // opted-in ExApp from streaming an unbounded (e.g. chunked) body that getBody() would buffer + // whole and OOM the cron worker. With a declared length the HTTP client reads at most that + // many bytes, so the materialization below is bounded. + $contentLength = $result->getHeader('Content-Length'); + // ctype_digit (not is_numeric, which accepts -1 / 12.5 / 1e3, and not '' which is false here): + // Content-Length must be digits only. + if (!ctype_digit($contentLength) || (int)$contentLength > self::MAX_RESPONSE_BYTES) { + $this->logger->warning('ExApp setup-check: missing or oversized Content-Length from ExApp ' . $exApp->getAppid()); + return [$this->notRespondingIssue($exApp)]; + } + $bodyStr = (string)$result->getBody(); + if (strlen($bodyStr) > self::MAX_RESPONSE_BYTES) { + $this->logger->warning('ExApp setup-check: oversized response body from ExApp ' . $exApp->getAppid()); + return [$this->notRespondingIssue($exApp)]; + } + $body = json_decode($bodyStr, true); + if (!is_array($body)) { + return [$this->notRespondingIssue($exApp)]; + } + return $this->parseResponse($exApp, $body); + } + + /** + * @param array $body map of `{checkId: {status, text, link_url?, link_label?}}` + * @return list + */ + private function parseResponse(ExApp $exApp, array $body): array { + $issues = []; + $scanned = 0; + foreach ($body as $result) { + if (count($issues) >= ExAppSetupCheckService::MAX_CHECKS || $scanned >= self::MAX_RESPONSE_ENTRIES) { + break; + } + $scanned++; + if (!is_array($result)) { + continue; + } + $severity = $this->mapSeverity(is_string($result['status'] ?? null) ? $result['status'] : ''); + if ($severity === null) { + continue; // success / unknown -> not an issue + } + $text = $this->capLength(is_string($result['text'] ?? null) ? $result['text'] : ''); + $issues[] = [ + 'severity' => $severity, + 'appName' => $this->appName($exApp), + 'text' => $text !== '' ? $text : $this->l10n->t('reported a problem'), + 'linkUrl' => $this->safeUrl(is_string($result['link_url'] ?? null) ? $result['link_url'] : ''), + 'linkLabel' => $this->capLength(is_string($result['link_label'] ?? null) ? $result['link_label'] : ''), + ]; + } + return $issues; + } + + /** + * @return array{severity: string, appName: string, text: string, linkUrl: string, linkLabel: string} + */ + private function notRespondingIssue(ExApp $exApp): array { + return [ + 'severity' => 'warning', + 'appName' => $this->appName($exApp), + 'text' => $this->l10n->t('not responding'), + 'linkUrl' => '', + 'linkLabel' => '', + ]; + } + + private function appName(ExApp $exApp): string { + $name = $exApp->getName(); + return $name !== '' ? $name : $exApp->getAppid(); + } + + private function mapSeverity(string $status): ?string { + return match (strtolower(trim($status))) { + 'warning' => 'warning', + 'error' => 'error', + default => null, // success / info / ok / empty / unknown -> not surfaced + }; + } + + private function safeUrl(string $url): string { + if (preg_match('#^https?://#i', $url) !== 1) { + return ''; + } + if (preg_match('/[\x00-\x20\x7f"\'<>]/', $url) === 1) { + return ''; + } + return $url; + } + + private function capLength(string $text): string { + return mb_strlen($text) > self::MAX_TEXT_LENGTH ? mb_substr($text, 0, self::MAX_TEXT_LENGTH) : $text; + } +} diff --git a/lib/Service/ExAppSetupCheckService.php b/lib/Service/ExAppSetupCheckService.php new file mode 100644 index 000000000..0a30025a3 --- /dev/null +++ b/lib/Service/ExAppSetupCheckService.php @@ -0,0 +1,110 @@ +isValidAppId($appId)) { + return; + } + $this->appConfig->setValueString(Application::APP_ID, self::KEY_PREFIX . $appId, '1'); + } + + public function optOut(string $appId): void { + if (!$this->isValidAppId($appId)) { + return; + } + if ($this->appConfig->hasKey(Application::APP_ID, self::KEY_PREFIX . $appId)) { + $this->appConfig->deleteKey(Application::APP_ID, self::KEY_PREFIX . $appId); + } + // Also drop the app's last computed results, so a re-opt-in (or reinstall of the same appid) + // before the next refresh cannot momentarily show stale issues from the previous registration. + $state = $this->getState(); + if (isset($state['apps'][$appId])) { + unset($state['apps'][$appId]); + $this->storeState($state['apps']); + } + } + + private function isValidAppId(string $appId): bool { + return $appId !== '' && strlen(self::KEY_PREFIX . $appId) <= self::MAX_KEY_LENGTH; + } + + /** + * App ids of every ExApp opted in to setup checks. + * + * Enabled-state is intentionally NOT filtered here - the caller filters live against the current + * ExApp state, so there is no cached "enabled" snapshot to go stale. + * + * @return list + */ + public function getOptedInAppIds(): array { + $appIds = []; + foreach ($this->appConfig->getKeys(Application::APP_ID) as $key) { + if (!str_starts_with($key, self::KEY_PREFIX)) { + continue; + } + $appId = substr($key, strlen(self::KEY_PREFIX)); + if ($appId !== '') { + $appIds[] = $appId; + } + } + return $appIds; + } + + /** + * The last computed results, written by the background refresh and read (never recomputed) by the + * SetupCheck on the admin page. + * + * @return array{apps: array>>, updatedAt: int} + */ + public function getState(): array { + $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::STATE_KEY, ''), true); + $apps = (is_array($decoded) && isset($decoded['apps']) && is_array($decoded['apps'])) ? $decoded['apps'] : []; + $updatedAt = (is_array($decoded) && isset($decoded['updatedAt'])) ? (int)$decoded['updatedAt'] : 0; + return ['apps' => $apps, 'updatedAt' => $updatedAt]; + } + + /** + * @param array>> $apps appId => list of issues + */ + public function storeState(array $apps): void { + $this->appConfig->setValueString(Application::APP_ID, self::STATE_KEY, json_encode(['apps' => $apps, 'updatedAt' => time()])); + } +} diff --git a/lib/SetupChecks/AbstractExAppsSetupCheck.php b/lib/SetupChecks/AbstractExAppsSetupCheck.php new file mode 100644 index 000000000..5dfb07dc5 --- /dev/null +++ b/lib/SetupChecks/AbstractExAppsSetupCheck.php @@ -0,0 +1,199 @@ +` stripped (it reaches the TTY verbatim and is + * html-escaped only by the web renderer); the link url is re-validated to http(s) without control + * chars. Response size/length/count are bounded upstream in the refresh service. + */ +abstract class AbstractExAppsSetupCheck implements ISetupCheck { + public function __construct( + protected readonly IL10N $l10n, + protected readonly LoggerInterface $logger, + protected readonly ExAppSetupCheckService $setupCheckService, + protected readonly ExAppService $exAppService, + protected readonly IJobList $jobList, + ) { + } + + /** The single severity ('error' | 'warning') this check surfaces. */ + abstract protected function severity(): string; + + public function getCategory(): string { + return 'system'; + } + + public function run(): SetupResult { + // Warm the results for the next view. add() is idempotent, so this is a no-op when a refresh + // is already queued or running - exactly the "only if not already running" guard we want. + try { + $this->jobList->add(ExAppSetupChecksRefreshOnceJob::class); + } catch (\Throwable $e) { + $this->logger->warning('ExAppsSetupCheck: could not enqueue refresh job', ['exception' => $e]); + } + + // Pure read of the stored results - never blocks, can't be slowed by an ExApp. + try { + $issues = array_values(array_filter( + $this->collectIssues(), + fn (array $issue): bool => (is_string($issue['severity'] ?? null) ? $issue['severity'] : 'warning') === $this->severity(), + )); + if ($issues === []) { + return SetupResult::success(); + } + return $this->buildResult($issues); + } catch (\Throwable $e) { + $this->logger->error('ExAppsSetupCheck: failed to read stored state', ['exception' => $e]); + return SetupResult::success(); + } + } + + /** + * All stored issues across the currently opted-in + enabled ExApps (every severity). + * + * @return list> + */ + private function collectIssues(): array { + $optedIn = $this->setupCheckService->getOptedInAppIds(); + if ($optedIn === []) { + return []; + } + $state = $this->setupCheckService->getState(); + $issues = []; + foreach ($state['apps'] as $appId => $appIssues) { + $appId = (string)$appId; // json_decode can coerce a numeric-looking key to int + if (!in_array($appId, $optedIn, true) || !is_array($appIssues)) { + continue; // opted out since the last refresh + } + $exApp = $this->exAppService->getExApp($appId); + if ($exApp === null || $exApp->getEnabled() !== 1) { + continue; // gone / disabled since the last refresh + } + foreach ($appIssues as $issue) { + if (is_array($issue)) { + $issues[] = $issue; + } + } + } + return $issues; + } + + /** + * @param list> $issues issues of this check's severity (untrusted - escaped here) + */ + private function buildResult(array $issues): SetupResult { + $lines = []; + $parameters = []; + $linkIndex = 0; + foreach ($issues as $issue) { + $appName = is_string($issue['appName'] ?? null) ? $issue['appName'] : ''; + $text = is_string($issue['text'] ?? null) ? $issue['text'] : ''; + $line = $this->sanitizeText($appName) . ': ' . $this->sanitizeText($text); + $linkUrl = $this->safeUrl(is_string($issue['linkUrl'] ?? null) ? $issue['linkUrl'] : ''); + if ($linkUrl !== '') { + $placeholder = 'link' . $linkIndex; + $linkLabel = is_string($issue['linkLabel'] ?? null) ? $issue['linkLabel'] : ''; + $parameters[$placeholder] = [ + 'type' => 'highlight', + 'id' => $placeholder, + 'name' => $this->sanitizeLabel($linkLabel !== '' ? $linkLabel : $linkUrl), + 'link' => $linkUrl, + ]; + $line .= ' {' . $placeholder . '}'; + $linkIndex++; + } + $lines[] = $line; + } + $description = implode('
', $lines); + + try { + return $this->makeResult($description, $parameters !== [] ? $parameters : null); + } catch (\Throwable $e) { + // A malformed rich object would otherwise blank the whole check (the manager replaces a + // throwing check with a generic error). Fall back to a plain, param-free description. + $this->logger->error('ExAppsSetupCheck: failed to build rich result, falling back to plain text', ['exception' => $e]); + $plain = implode('
', array_map( + fn (array $issue): string => $this->sanitizeText(is_string($issue['appName'] ?? null) ? $issue['appName'] : '') + . ': ' . $this->sanitizeText(is_string($issue['text'] ?? null) ? $issue['text'] : ''), + $issues, + )); + return $this->makeResult($plain, null); + } + } + + /** + * Neutralize anything that could break a renderer or the rich-object validator. Control chars are + * stripped (an `occ setupchecks` TTY would otherwise interpret ANSI escapes); `{`/`}` are dropped + * (the description is scanned for `{placeholder}` tokens); html is escaped (with ENT_SUBSTITUTE so + * invalid UTF-8 degrades instead of blanking the line) because the web renderer injects the base + * description with unescaped v-html. + */ + private function sanitizeText(string $text): string { + $text = preg_replace('/[\x00-\x1f\x7f]/', '', $text) ?? ''; + $text = str_replace(['{', '}'], '', $text); + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * The rich-object `name` (link label) is html-escaped by the web renderer but rendered VERBATIM by + * `occ setupchecks`, so strip control chars and the `<`/`>` that would form console tags. The link + * url itself is already validated by {@see safeUrl}. + */ + private function sanitizeLabel(string $label): string { + return preg_replace('/[\x00-\x1f\x7f<>]/', '', $label) ?? ''; + } + + /** Defence in depth: re-validate the stored URL before it reaches the rendered href. */ + private function safeUrl(string $url): string { + if ($url === '' || strlen($url) > 2048) { + return ''; + } + if (preg_match('#^https?://#i', $url) !== 1) { + return ''; + } + if (preg_match('/[\x00-\x20\x7f"\'<>]/', $url) === 1) { + return ''; + } + return $url; + } + + private function makeResult(string $description, ?array $parameters): SetupResult { + return $this->severity() === 'error' + ? SetupResult::error($description, null, $parameters) + : SetupResult::warning($description, null, $parameters); + } +} diff --git a/lib/SetupChecks/ExAppsErrorSetupCheck.php b/lib/SetupChecks/ExAppsErrorSetupCheck.php new file mode 100644 index 000000000..5f5368f47 --- /dev/null +++ b/lib/SetupChecks/ExAppsErrorSetupCheck.php @@ -0,0 +1,23 @@ +l10n->t('External Apps (Errors)'); + } + + protected function severity(): string { + return 'error'; + } +} diff --git a/lib/SetupChecks/ExAppsWarningSetupCheck.php b/lib/SetupChecks/ExAppsWarningSetupCheck.php new file mode 100644 index 000000000..d19cfff87 --- /dev/null +++ b/lib/SetupChecks/ExAppsWarningSetupCheck.php @@ -0,0 +1,24 @@ +l10n->t('External Apps (Warnings)'); + } + + protected function severity(): string { + return 'warning'; + } +} diff --git a/openapi-full.json b/openapi-full.json index 98a6ed6a2..39787bbf4 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -9116,6 +9116,139 @@ } } } + }, + "/ocs/v2.php/apps/app_api/api/v1/setup_check": { + "post": { + "operationId": "setup_check-register-checks", + "summary": "Opt the calling ExApp in to setup checks", + "description": "The ExApp is identified from the authenticated `ex-app-id` header, so it can only ever opt itself in. Its live results are then fetched from the ExApp's `/setup_checks` endpoint by a background job and surfaced in the admin \"Security & setup warnings\" panel.", + "tags": [ + "setup_check" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "ExApp opted in to setup checks", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "setup_check-unregister-checks", + "summary": "Opt the calling ExApp out of setup checks", + "tags": [ + "setup_check" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "ExApp opted out of setup checks", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [ diff --git a/openapi.json b/openapi.json index 678b8ed5f..a520c71a2 100644 --- a/openapi.json +++ b/openapi.json @@ -5267,6 +5267,139 @@ } } } + }, + "/ocs/v2.php/apps/app_api/api/v1/setup_check": { + "post": { + "operationId": "setup_check-register-checks", + "summary": "Opt the calling ExApp in to setup checks", + "description": "The ExApp is identified from the authenticated `ex-app-id` header, so it can only ever opt itself in. Its live results are then fetched from the ExApp's `/setup_checks` endpoint by a background job and surfaced in the admin \"Security & setup warnings\" panel.", + "tags": [ + "setup_check" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "ExApp opted in to setup checks", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "setup_check-unregister-checks", + "summary": "Opt the calling ExApp out of setup checks", + "tags": [ + "setup_check" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "ExApp opted out of setup checks", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [ diff --git a/tests/php/Service/ExAppSetupCheckRefreshServiceTest.php b/tests/php/Service/ExAppSetupCheckRefreshServiceTest.php new file mode 100644 index 000000000..8f6ff3a10 --- /dev/null +++ b/tests/php/Service/ExAppSetupCheckRefreshServiceTest.php @@ -0,0 +1,320 @@ +l10n = $this->createMock(IL10N::class); + $this->l10n->method('t')->willReturnCallback(fn (string $text, array $p = []): string => $text); + $this->logger = $this->createMock(LoggerInterface::class); + $this->setupCheckService = $this->createMock(ExAppSetupCheckService::class); + $this->exAppService = $this->createMock(ExAppService::class); + $this->appAPIService = $this->createMock(AppAPIService::class); + + $this->refreshService = $this->makeRefreshService(); + } + + private function makeRefreshService(float $budget = 120.0): ExAppSetupCheckRefreshService { + return new ExAppSetupCheckRefreshService( + $this->l10n, $this->logger, $this->setupCheckService, $this->exAppService, $this->appAPIService, $budget, + ); + } + + private function makeExApp(string $appId, int $enabled = 1, array $status = ['init' => 100, 'action' => '']): ExApp { + $exApp = new ExApp(); + $exApp->setAppid($appId); + $exApp->setName('App ' . $appId); + $exApp->setEnabled($enabled); + $exApp->setStatus($status); + return $exApp; + } + + private function response(int $statusCode, string $body, ?int $contentLength = null): IResponse&MockObject { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn($statusCode); + $response->method('getBody')->willReturn($body); + // default to a realistic Content-Length matching the body + $response->method('getHeader')->willReturn((string)($contentLength ?? strlen($body))); + return $response; + } + + private function responseNoContentLength(int $statusCode, string $body): IResponse&MockObject { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn($statusCode); + $response->method('getBody')->willReturn($body); + $response->method('getHeader')->willReturn(''); // no Content-Length header + return $response; + } + + private function down(): array { + return ['error' => 'connection refused']; + } + + private function responseWithHeader(int $statusCode, string $body, string $contentLengthHeader): IResponse&MockObject { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn($statusCode); + $response->method('getBody')->willReturn($body); + $response->method('getHeader')->willReturn($contentLengthHeader); + return $response; + } + + /** + * @param list $optedIn opted-in app ids + * @param array $appsById + * @param array $resultsByAppId + * @param array>> $previousState the prior stored state (carry-over source) + * @return array>> the state passed to storeState() + */ + private function runRefresh(array $optedIn, array $appsById, array $resultsByAppId, array $previousState = [], ?ExAppSetupCheckRefreshService $service = null): array { + $stored = []; + $this->setupCheckService->method('getOptedInAppIds')->willReturn($optedIn); + $this->setupCheckService->method('getState')->willReturn(['apps' => $previousState, 'updatedAt' => 1]); + $this->setupCheckService->method('storeState')->willReturnCallback(function (array $apps) use (&$stored): void { + $stored = $apps; + }); + $this->exAppService->method('getExApp')->willReturnCallback(fn (string $id): ?ExApp => $appsById[$id] ?? null); + $this->appAPIService->method('requestToExApp')->willReturnCallback( + fn (ExApp $exApp) => $resultsByAppId[$exApp->getAppid()] + ); + ($service ?? $this->refreshService)->refresh(); + return $stored; + } + + public function testWarningIsStored(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'warning', 'text' => 'bad config']]))], + ); + self::assertArrayHasKey('a', $state); + self::assertSame('warning', $state['a'][0]['severity']); + self::assertSame('bad config', $state['a'][0]['text']); + } + + public function testSuccessIsNotStored(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'success', 'text' => 'ok']]))], + ); + self::assertArrayNotHasKey('a', $state); + } + + public function testInfoStatusIsNotStored(): void { + // only error/warning are surfaced; info is ignored like success/unknown. + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'info', 'text' => 'fyi']]))], + ); + self::assertArrayNotHasKey('a', $state); + } + + public function testDownIsStoredAsNotResponding(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->down()], + ); + self::assertSame('warning', $state['a'][0]['severity']); + self::assertSame('not responding', $state['a'][0]['text']); + } + + public function testNon2xxIsNotResponding(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(500, 'oops')], + ); + self::assertSame('not responding', $state['a'][0]['text']); + } + + public function testOversizedResponseIsNotResponding(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'warning', 'text' => 'x']]), 999999999)], + ); + self::assertSame('not responding', $state['a'][0]['text']); + } + + public function testDisabledAppIsNotProbed(): void { + $this->appAPIService->expects(self::never())->method('requestToExApp'); + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a', 0)], + [], + ); + self::assertArrayNotHasKey('a', $state); + } + + /** Each initializing predicate independently must exclude the app (guards against `||` -> `&&`). */ + #[DataProvider('initializingStatusProvider')] + public function testInitializingAppIsNotProbed(array $status): void { + $this->appAPIService->expects(self::never())->method('requestToExApp'); + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a', 1, $status)], + [], + ); + self::assertArrayNotHasKey('a', $state); + } + + public static function initializingStatusProvider(): array { + return [ + 'init below 100 only' => [['init' => 40, 'action' => '']], + 'action is init only' => [['init' => 100, 'action' => 'init']], + 'both' => [['init' => 40, 'action' => 'init']], + ]; + } + + public function testEmptyTextFallsBackToReportedAProblem(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'error', 'text' => '']]))], + ); + self::assertSame('reported a problem', $state['a'][0]['text']); + } + + public function testLongTextIsTruncated(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'warning', 'text' => str_repeat('A', 20000)]]))], + ); + self::assertLessThanOrEqual(4096, mb_strlen($state['a'][0]['text'])); + } + + public function testValidHttpsLinkIsStored(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'warning', 'text' => 'fix', 'link_url' => 'https://x.test/d', 'link_label' => 'Docs']]))], + ); + self::assertSame('https://x.test/d', $state['a'][0]['linkUrl']); + self::assertSame('Docs', $state['a'][0]['linkLabel']); + } + + public function testJavascriptLinkIsDropped(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'warning', 'text' => 'x', 'link_url' => 'javascript:alert(1)']]))], + ); + self::assertSame('', $state['a'][0]['linkUrl']); + } + + public function testControlCharUrlIsDropped(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['c1' => ['status' => 'warning', 'text' => 'x', 'link_url' => "https://x.test/a\nb"]]))], + ); + self::assertSame('', $state['a'][0]['linkUrl']); + } + + public function testMalformedItemIsSkipped(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode(['bad' => 'not-an-array', 'good' => ['status' => 'warning', 'text' => 'real']]))], + ); + self::assertCount(1, $state['a']); + self::assertSame('real', $state['a'][0]['text']); + } + + public function testMissingContentLengthIsNotResponding(): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->responseNoContentLength(200, json_encode(['c1' => ['status' => 'warning', 'text' => 'x']]))], + ); + self::assertSame('not responding', $state['a'][0]['text']); + } + + public function testMaxChecksCapPerApp(): void { + $checks = []; + for ($i = 0; $i < 25; $i++) { + $checks['c' . $i] = ['status' => 'warning', 'text' => 'w' . $i]; + } + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->response(200, json_encode($checks))], + ); + self::assertCount(ExAppSetupCheckService::MAX_CHECKS, $state['a']); + } + + /** Content-Length must be digits only; is_numeric would have accepted these. */ + #[DataProvider('malformedContentLengthProvider')] + public function testMalformedContentLengthIsNotResponding(string $contentLength): void { + $state = $this->runRefresh( + ['a'], + ['a' => $this->makeExApp('a')], + ['a' => $this->responseWithHeader(200, json_encode(['c1' => ['status' => 'warning', 'text' => 'x']]), $contentLength)], + ); + self::assertSame('not responding', $state['a'][0]['text']); + } + + public static function malformedContentLengthProvider(): array { + return [['-1'], ['12.5'], ['1e3'], ['0x10'], [' 12'], ['abc'], ['']]; + } + + public function testBudgetBreakKeepsPreviousResultsForUnvisitedApps(): void { + // negative budget -> the break fires before any probe (deterministic, no wall-clock dependency): + // every opted-in app is "unvisited" and must keep its previous stored issues, while an app with + // no previous entry just gets none. + $service = $this->makeRefreshService(-1.0); + $previous = [ + 'a' => [['severity' => 'error', 'appName' => 'A', 'text' => 'old error', 'linkUrl' => '', 'linkLabel' => '']], + 'b' => [['severity' => 'warning', 'appName' => 'B', 'text' => 'old warning', 'linkUrl' => '', 'linkLabel' => '']], + ]; + // budget hit before any probe; carried-over (old) values prove nothing was freshly fetched. + $state = $this->runRefresh( + ['a', 'b', 'c'], + ['a' => $this->makeExApp('a'), 'b' => $this->makeExApp('b'), 'c' => $this->makeExApp('c')], + [], + $previous, + $service, + ); + self::assertSame('old error', $state['a'][0]['text']); // carried over + self::assertSame('old warning', $state['b'][0]['text']); // carried over + self::assertArrayNotHasKey('c', $state); // no previous -> no entry + } + + public function testBudgetBreakDropsDisabledAppEvenWithPreviousResults(): void { + // a disabled app must NOT be carried over (down-ness belongs on the management page) + $service = $this->makeRefreshService(-1.0); + $previous = ['a' => [['severity' => 'error', 'appName' => 'A', 'text' => 'old error', 'linkUrl' => '', 'linkLabel' => '']]]; + $state = $this->runRefresh(['a'], ['a' => $this->makeExApp('a', 0)], [], $previous, $service); + self::assertArrayNotHasKey('a', $state); + } +} diff --git a/tests/php/Service/ExAppSetupCheckServiceTest.php b/tests/php/Service/ExAppSetupCheckServiceTest.php new file mode 100644 index 000000000..dc28ac1b6 --- /dev/null +++ b/tests/php/Service/ExAppSetupCheckServiceTest.php @@ -0,0 +1,99 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->service = new ExAppSetupCheckService($this->appConfig); + } + + public function testOptInStoresMarker(): void { + $this->appConfig->expects(self::once())->method('setValueString') + ->with('app_api', 'setup_checks_myapp', '1')->willReturn(true); + $this->service->optIn('myapp'); + } + + public function testOptInIgnoresOverlongAppId(): void { + // KEY_PREFIX (13) + appId must stay within IAppConfig's 64-char key limit. + $this->appConfig->expects(self::never())->method('setValueString'); + $this->service->optIn(str_repeat('a', 60)); + } + + public function testOptOutDeletesWhenPresent(): void { + $this->appConfig->method('hasKey')->willReturn(true); + $this->appConfig->expects(self::once())->method('deleteKey')->with('app_api', 'setup_checks_myapp'); + $this->service->optOut('myapp'); + } + + public function testOptOutNoopWhenAbsent(): void { + $this->appConfig->method('hasKey')->willReturn(false); + $this->appConfig->expects(self::never())->method('deleteKey'); + // state is empty -> no rewrite either + $this->appConfig->method('getValueString')->willReturn(''); + $this->appConfig->expects(self::never())->method('setValueString'); + $this->service->optOut('myapp'); + } + + public function testOptOutEvictsAppFromState(): void { + $this->appConfig->method('hasKey')->willReturn(true); + $this->appConfig->method('getValueString')->willReturn(json_encode([ + 'apps' => ['myapp' => [['severity' => 'error', 'text' => 'x']], 'other' => [['severity' => 'warning', 'text' => 'y']]], + 'updatedAt' => 1, + ])); + $stored = null; + $this->appConfig->method('setValueString')->willReturnCallback(function (string $a, string $k, string $v) use (&$stored): bool { + $stored = json_decode($v, true); + return true; + }); + $this->service->optOut('myapp'); + self::assertArrayNotHasKey('myapp', $stored['apps']); + self::assertArrayHasKey('other', $stored['apps']); + } + + public function testGetOptedInAppIdsFiltersPrefixAndExtractsAppId(): void { + $this->appConfig->method('getKeys')->with('app_api') + ->willReturn(['version', 'loglevel', 'setup_checks_appA', 'setup_checks_live_transcription', 'setupchecks_state']); + // state key ('setupchecks_state') deliberately does NOT match the prefix and must be excluded. + self::assertSame(['appA', 'live_transcription'], $this->service->getOptedInAppIds()); + } + + public function testStoreAndGetStateRoundTrip(): void { + $stored = ''; + $this->appConfig->method('setValueString')->willReturnCallback(function (string $a, string $k, string $v) use (&$stored): bool { + $stored = $v; + return true; + }); + $this->appConfig->method('getValueString')->willReturnCallback(function (string $a, string $k, string $d = '') use (&$stored): string { + return $stored !== '' ? $stored : $d; + }); + + $this->service->storeState(['a' => [['severity' => 'warning', 'text' => 'x']]]); + $state = $this->service->getState(); + self::assertSame(['a' => [['severity' => 'warning', 'text' => 'x']]], $state['apps']); + self::assertGreaterThan(0, $state['updatedAt']); + } + + public function testGetStateIsDefensiveOnGarbage(): void { + $this->appConfig->method('getValueString')->willReturn('not-json{'); + $state = $this->service->getState(); + self::assertSame([], $state['apps']); + self::assertSame(0, $state['updatedAt']); + } +} diff --git a/tests/php/SetupChecks/ExAppsSetupCheckTest.php b/tests/php/SetupChecks/ExAppsSetupCheckTest.php new file mode 100644 index 000000000..b06063b69 --- /dev/null +++ b/tests/php/SetupChecks/ExAppsSetupCheckTest.php @@ -0,0 +1,226 @@ +l10n = $this->createMock(IL10N::class); + $this->l10n->method('t')->willReturnCallback(fn (string $text, array $p = []): string => $text); + $this->setupCheckService = $this->createMock(ExAppSetupCheckService::class); + $this->exAppService = $this->createMock(ExAppService::class); + $this->jobList = $this->createMock(IJobList::class); + } + + private function makeCheck(string $class): AbstractExAppsSetupCheck { + return new $class($this->l10n, $this->createMock(LoggerInterface::class), $this->setupCheckService, $this->exAppService, $this->jobList); + } + + private function makeExApp(string $appId, int $enabled = 1): ExApp { + $exApp = new ExApp(); + $exApp->setAppid($appId); + $exApp->setName('App ' . $appId); + $exApp->setEnabled($enabled); + return $exApp; + } + + private function issue(string $severity, string $text, string $appName = 'App a', string $linkUrl = '', string $linkLabel = ''): array { + return ['severity' => $severity, 'appName' => $appName, 'text' => $text, 'linkUrl' => $linkUrl, 'linkLabel' => $linkLabel]; + } + + /** + * @param class-string $class + * @param list $optedIn + * @param array>> $stateApps + * @param array $appsById + */ + private function runCheck(string $class, array $optedIn, array $stateApps, array $appsById): SetupResult { + $this->setupCheckService->method('getOptedInAppIds')->willReturn($optedIn); + $this->setupCheckService->method('getState')->willReturn(['apps' => $stateApps, 'updatedAt' => 123]); + $this->exAppService->method('getExApp')->willReturnCallback(fn (string $id): ?ExApp => $appsById[$id] ?? null); + return $this->makeCheck($class)->run(); + } + + /** Mixed state: each check must surface ONLY its own severity. */ + private function mixedState(): array { + return ['apps' => [ + 'a' => [$this->issue('error', 'E broke', 'App a'), $this->issue('warning', 'W warn', 'App a')], + 'b' => [$this->issue('info', 'I note', 'App b')], + ], 'byId' => ['a' => $this->makeExApp('a'), 'b' => $this->makeExApp('b')], 'optedIn' => ['a', 'b']]; + } + + public function testErrorCheckSurfacesOnlyErrors(): void { + $s = $this->mixedState(); + $r = $this->runCheck(ExAppsErrorSetupCheck::class, $s['optedIn'], $s['apps'], $s['byId']); + self::assertSame('error', $r->getSeverity()); + $desc = (string)$r->getDescription(); + self::assertStringContainsString('E broke', $desc); + self::assertStringNotContainsString('W warn', $desc); + self::assertStringNotContainsString('I note', $desc); + } + + public function testWarningCheckSurfacesOnlyWarnings(): void { + $s = $this->mixedState(); + $r = $this->runCheck(ExAppsWarningSetupCheck::class, $s['optedIn'], $s['apps'], $s['byId']); + self::assertSame('warning', $r->getSeverity()); + $desc = (string)$r->getDescription(); + self::assertStringContainsString('W warn', $desc); + self::assertStringNotContainsString('E broke', $desc); + self::assertStringNotContainsString('I note', $desc); + } + + public function testInfoIssueIsIgnoredByBothChecks(): void { + // info is no longer surfaced; an info issue in the state must be dropped by error AND warning. + $state = ['a' => [$this->issue('info', 'just an info note', 'App a')]]; + self::assertSame('success', $this->runCheck(ExAppsErrorSetupCheck::class, ['a'], $state, ['a' => $this->makeExApp('a')])->getSeverity()); + self::assertSame('success', $this->runCheck(ExAppsWarningSetupCheck::class, ['a'], $state, ['a' => $this->makeExApp('a')])->getSeverity()); + } + + public function testErrorCheckIsSuccessWhenNoErrors(): void { + $r = $this->runCheck( + ExAppsErrorSetupCheck::class, + ['a'], + ['a' => [$this->issue('warning', 'only a warning', 'App a')]], + ['a' => $this->makeExApp('a')], + ); + self::assertSame('success', $r->getSeverity()); + } + + public function testNoOptedInAppsIsSuccess(): void { + self::assertSame('success', $this->runCheck(ExAppsWarningSetupCheck::class, [], [], [])->getSeverity()); + } + + public function testNoStoredIssuesIsSuccess(): void { + $r = $this->runCheck(ExAppsWarningSetupCheck::class, ['a'], [], ['a' => $this->makeExApp('a')]); + self::assertSame('success', $r->getSeverity()); + } + + public function testEnqueuesRefreshOnRun(): void { + $this->jobList->expects(self::once())->method('add')->with(ExAppSetupChecksRefreshOnceJob::class); + $this->setupCheckService->method('getOptedInAppIds')->willReturn([]); + $this->makeCheck(ExAppsErrorSetupCheck::class)->run(); + } + + public function testNotOptedInAppInStateIsIgnored(): void { + $r = $this->runCheck( + ExAppsErrorSetupCheck::class, + ['a'], + ['gone' => [$this->issue('error', 'stale', 'App gone')]], + ['gone' => $this->makeExApp('gone')], + ); + self::assertSame('success', $r->getSeverity()); + } + + public function testDisabledAppInStateIsIgnored(): void { + $r = $this->runCheck( + ExAppsErrorSetupCheck::class, + ['a'], + ['a' => [$this->issue('error', 'was bad', 'App a')]], + ['a' => $this->makeExApp('a', 0)], + ); + self::assertSame('success', $r->getSeverity()); + } + + public function testXssTextIsEscapedAtRender(): void { + $r = $this->runCheck( + ExAppsWarningSetupCheck::class, + ['a'], + ['a' => [$this->issue('warning', '', 'App a')]], + ['a' => $this->makeExApp('a')], + ); + $desc = (string)$r->getDescription(); + self::assertStringNotContainsString('