Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions public/assets/swagger-ui/swagger-ui-bundle.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions public/assets/swagger-ui/swagger-ui.css

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions scripts/create-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ CRITICAL_FILES=(
"public/assets/tinymce/themes/silver/theme.min.js"
"public/assets/tinymce/skins/ui/oxide/skin.min.css"
"public/assets/tinymce/icons/default/icons.min.js"
"public/assets/swagger-ui/swagger-ui-bundle.js"
"public/assets/swagger-ui/swagger-ui.css"
"public/index.php"
"app/Support/Updater.php"
"version.json"
Expand Down
65 changes: 32 additions & 33 deletions storage/plugins/mobile-api/src/Controllers/SwaggerUiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,16 @@
/**
* Serves the Swagger UI page at GET /api/v1/docs.
*
* Asset strategy (spec: "self-hosted, no external CDN if avoidable"):
* 1. If public/assets/swagger-ui/swagger-ui-bundle.js exists (pre-downloaded),
* all three asset references point to the local copy.
* 2. Otherwise, falls back to jsDelivr CDN (swagger-ui v5 latest).
* Run npm install swagger-ui-dist and copy the dist files to
* public/assets/swagger-ui/ to avoid the CDN dependency.
* Asset strategy: **self-hosted only, never a CDN**. The Swagger UI dist files
* are vendored in public/assets/swagger-ui/ and shipped in the release ZIP, so
* the page works offline / behind an egress firewall. To refresh them, run
* `npm pack swagger-ui-dist@5.18.2` and copy swagger-ui-bundle.js +
* swagger-ui.css into public/assets/swagger-ui/ (current vendored version: 5.18.2).
*
* Public endpoint — no bearer token required.
*/
final class SwaggerUiController
{
/** Swagger UI version pinned for the CDN fallback. */
private const SWAGGER_UI_VERSION = '5.18.2';

public function page(
ServerRequestInterface $request,
ResponseInterface $response
Expand Down Expand Up @@ -63,10 +59,15 @@ private function buildHtml(string $openApiUrl, string $assetsBaseUrl): string
ENT_QUOTES,
'UTF-8'
);
$cssUrl = $assetsBaseUrl . '/swagger-ui.css';
$jsUrl = $assetsBaseUrl . '/swagger-ui-bundle.js';
// openApiUrl is a URL built from trusted server state, escape for HTML context only.
$docUrl = htmlspecialchars($openApiUrl, ENT_QUOTES, 'UTF-8');
// assetsBaseUrl is derived from the request Host (baseUrl()); escape it for the
// href/src HTML-attribute context, exactly like $title/$docUrl below.
$cssUrl = htmlspecialchars($assetsBaseUrl . '/swagger-ui.css', ENT_QUOTES, 'UTF-8');
$jsUrl = htmlspecialchars($assetsBaseUrl . '/swagger-ui-bundle.js', ENT_QUOTES, 'UTF-8');
// openApiUrl is consumed inside a JS string literal (SwaggerUIBundle url:), so
// JSON-encode it — the correct escaper for a JS context (htmlspecialchars is for
// HTML, and leaves backslashes untouched). JSON_HEX_TAG blocks a </script> breakout;
// json_encode already emits the surrounding quotes, so the heredoc drops them below.
$docUrl = json_encode($openApiUrl, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?: '""';

return <<<HTML
<!DOCTYPE html>
Expand Down Expand Up @@ -110,8 +111,21 @@ private function buildHtml(string $openApiUrl, string $assetsBaseUrl): string
(function () {
'use strict';
window.onload = function () {
// Self-hosted, no CDN fallback: if the vendored bundle didn't load, show a
// diagnostic instead of a blank page (the #swagger-ui div would stay empty).
if (typeof SwaggerUIBundle === 'undefined') {
document.getElementById('swagger-ui').innerHTML =
'<div style="padding:24px;font-family:sans-serif;color:#b91c1c">' +
'<h2 style="margin:0 0 8px">API docs could not load</h2>' +
'<p style="margin:0;color:#334155">The Swagger UI assets failed to load. This page is ' +
'self-hosted with no CDN fallback — verify that ' +
'<code>public/assets/swagger-ui/</code> shipped with this install. ' +
'The raw OpenAPI spec is still available at <a href="../openapi.json">openapi.json</a>.</p>' +
'</div>';
return;
}
var ui = SwaggerUIBundle({
url: "{$docUrl}",
url: {$docUrl},
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
Expand All @@ -137,29 +151,14 @@ private function buildHtml(string $openApiUrl, string $assetsBaseUrl): string
}

/**
* Returns the base URL for Swagger UI static assets.
* Base URL for the locally-vendored Swagger UI assets.
*
* Local copy (preferred): public/assets/swagger-ui/ — served via the normal
* web server; check by testing for the bundle file on disk.
* CDN fallback: jsDelivr pinned to a specific Swagger UI version.
* Always the local `public/assets/swagger-ui/` copy — never a CDN.
* $siteBaseUrl already includes BASE_PATH (from baseUrl()).
*/
private function assetsBaseUrl(string $siteBaseUrl): string
{
$localBundle = $this->docRoot() . '/assets/swagger-ui/swagger-ui-bundle.js';

if (is_file($localBundle)) {
// Local copy available — use it. $siteBaseUrl already includes
// BASE_PATH (from baseUrl()), so do not append it again.
return rtrim($siteBaseUrl, '/') . '/assets/swagger-ui';
}

// Fallback: jsDelivr CDN (pinned version).
return 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@' . self::SWAGGER_UI_VERSION;
}

private function docRoot(): string
{
return defined('PUBLIC_PATH') ? (string) PUBLIC_PATH : (string) ($_SERVER['DOCUMENT_ROOT'] ?? '');
return rtrim($siteBaseUrl, '/') . '/assets/swagger-ui';
}

private function baseUrl(ServerRequestInterface $request): string
Expand Down
66 changes: 66 additions & 0 deletions tests/swagger-docs.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @ts-check
// Regression for the Mobile API docs page (`GET /api/v1/docs`). It must be
// self-hosted: Swagger UI is served from public/assets/swagger-ui/ and NEVER
// from a CDN, so it renders behind an egress firewall. Guards against the bug
// where only `.gitkeep` shipped and the page fell back to jsDelivr → blank.
const { test, expect } = require('@playwright/test');

const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';

// Public endpoints (no bearer token needed) but they only exist when the
// bundled Mobile API plugin is active. Probe once and skip cleanly otherwise.
let apiAvailable = true;

test.describe.serial('Mobile API — Swagger UI docs (self-hosted, no CDN)', () => {
test.beforeAll(async ({ request }) => {
try {
const res = await request.get(`${BASE}/api/v1/openapi.json`);
apiAvailable = res.status() === 200;
} catch {
apiAvailable = false;
}
});

test.beforeEach(() => {
test.skip(!apiAvailable, 'Mobile API plugin not active / app not installed');
});
Comment on lines +15 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Non usare l’endpoint sotto test come condizione di skip.

Se la regressione rompe GET /api/v1/openapi.json su Line 17, apiAvailable diventa false e da Line 25 in poi l’intero spec viene saltato invece di fallire. Così il test non protegge più proprio il bug che dovrebbe intercettare. Il gate deve dipendere solo dai segnali d’ambiente che CI inietta per gli E2E, oppure questo probe va rimosso del tutto.

Diff proposto
 const { test, expect } = require('`@playwright/test`');

 const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
-
-// Public endpoints (no bearer token needed) but they only exist when the
-// bundled Mobile API plugin is active. Probe once and skip cleanly otherwise.
-let apiAvailable = true;
+const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL;
+const ADMIN_PASS = process.env.E2E_ADMIN_PASS;

 test.describe.serial('Mobile API — Swagger UI docs (self-hosted, no CDN)', () => {
-  test.beforeAll(async ({ request }) => {
-    try {
-      const res = await request.get(`${BASE}/api/v1/openapi.json`);
-      apiAvailable = res.status() === 200;
-    } catch {
-      apiAvailable = false;
-    }
-  });
-
   test.beforeEach(() => {
-    test.skip(!apiAvailable, 'Mobile API plugin not active / app not installed');
+    test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'Credenziali E2E admin non configurate');
   });

Based on learnings, i Playwright E2E in questo repo devono essere gated da variabili d’ambiente che CI inietta, e negli UI-only spec il guard deve usare solo E2E_ADMIN_EMAIL e E2E_ADMIN_PASS.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test.beforeAll(async ({ request }) => {
try {
const res = await request.get(`${BASE}/api/v1/openapi.json`);
apiAvailable = res.status() === 200;
} catch {
apiAvailable = false;
}
});
test.beforeEach(() => {
test.skip(!apiAvailable, 'Mobile API plugin not active / app not installed');
});
const { test, expect } = require('`@playwright/test`');
const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL;
const ADMIN_PASS = process.env.E2E_ADMIN_PASS;
test.describe.serial('Mobile API — Swagger UI docs (self-hosted, no CDN)', () => {
test.beforeEach(() => {
test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'Credenziali E2E admin non configurate');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/swagger-docs.spec.js` around lines 15 - 26, The skip gate in the
swagger-docs Playwright spec currently depends on probing GET
/api/v1/openapi.json in test.beforeAll, which can hide regressions by skipping
the whole spec when that endpoint breaks. Remove this availability probe from
the beforeAll/beforeEach flow and make the guard rely only on CI-injected E2E
environment signals, consistent with the other Playwright specs using
E2E_ADMIN_EMAIL and E2E_ADMIN_PASS; keep the logic in test.beforeEach and the
apiAvailable-related setup aligned with that pattern.

Source: Learnings


test('openapi.json is a valid OpenAPI 3 spec with paths', async ({ request }) => {
const res = await request.get(`${BASE}/api/v1/openapi.json`);
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('json');
const spec = await res.json();
expect(String(spec.openapi)).toMatch(/^3\./);
expect(Object.keys(spec.paths || {}).length).toBeGreaterThan(0);
});

test('docs page references LOCAL assets and never a CDN', async ({ request }) => {
const res = await request.get(`${BASE}/api/v1/docs`);
expect(res.status()).toBe(200);
const html = await res.text();
// Loads the vendored bundle + css from the app's own asset path
expect(html).toContain('/assets/swagger-ui/swagger-ui-bundle.js');
expect(html).toContain('/assets/swagger-ui/swagger-ui.css');
// No external CDN host anywhere in the page
expect(html).not.toMatch(/cdn\.jsdelivr\.net|cdnjs\.cloudflare\.com|unpkg\.com/);
});

test('the local Swagger UI assets are actually served', async ({ request }) => {
for (const f of ['swagger-ui-bundle.js', 'swagger-ui.css']) {
const res = await request.get(`${BASE}/assets/swagger-ui/${f}`);
expect(res.status(), f).toBe(200);
const body = await res.body();
expect(body.length, `${f} should be a real file, not empty`).toBeGreaterThan(1000);
}
});

test('Swagger UI renders the API operations from the local bundle', async ({ page }) => {
await page.goto(`${BASE}/api/v1/docs`);
// The bundle must load and SwaggerUIBundle must be defined (proves no broken CDN)
await page.waitForFunction(() => typeof window.SwaggerUIBundle !== 'undefined', null, { timeout: 15000 });
// The spec must render at least one operation block
await page.waitForSelector('#swagger-ui .opblock', { timeout: 15000 });
const ops = await page.locator('#swagger-ui .opblock').count();
expect(ops).toBeGreaterThan(0);
});
});
Loading