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('