diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c41d06b..39a34a3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -18,6 +18,10 @@ module.exports = { 'js/', 'node_modules/', 'vendor/', + // Centrally-maintained, byte-synced eXe-core embed/media bridge mirrors + // (see scripts/check-embed-sync.mjs). They keep upstream's code style and + // module wrapper, so our lint rules do not apply to them. + 'src/embed/exe_*.js', ], rules: { // `void promise` is the canonical TypeScript way of marking a diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc2200c..65c426f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,3 +177,94 @@ jobs: # that the file actually got staged into the app tree. test -f apps/exelearning/src/sw/exelearning-sw.js \ || test -f custom_apps/exelearning/src/sw/exelearning-sw.js + + preview-e2e: + name: Editor-preview API E2E (real Nextcloud) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo_sqlite, gd, zip, intl, mbstring, ctype, curl, dom, fileinfo, simplexml, xml, xmlreader, xmlwriter + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install JS dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Set up Nextcloud server + uses: SMillerDev/nextcloud-actions/setup-nextcloud@main + with: + version: stable33 + database-type: sqlite + cron: false + + - name: Install eXeLearning app into the running Nextcloud + uses: SMillerDev/nextcloud-actions/setup-nextcloud-app@main + with: + app: exelearning + check-code: false + + # Serve the installed Nextcloud over HTTP (the built-in PHP server routes + # everything through the front controller, `/index.php/...`), enable the + # app, create the owner + a second user, then drive the real management + # and serving routes end to end. Cookie/session auth is used on purpose so + # the CSRF middleware is genuinely exercised. + - name: Preview API end-to-end against the running Nextcloud + working-directory: ../server + env: + WORKSPACE: ${{ github.workspace }} + run: | + set -euo pipefail + php occ app:enable exelearning + php occ config:system:set trusted_domains 1 --value=127.0.0.1 + # Treat the origin as plain HTTP end-to-end so the login session cookie + # and the SameSite cookies are NOT marked Secure — curl over http never + # sends a Secure cookie back, which silently breaks the login POST's + # own CSRF/strict-cookie check and leaves every request unauthenticated. + # Use a webroot WITHOUT /index.php so cookies get Path=/ (routing still + # goes through the /index.php front controller under php -S). + php occ config:system:set overwrite.cli.url --value="http://127.0.0.1:8080" + php occ config:system:set overwriteprotocol --value=http + php occ config:system:delete forcessl 2>/dev/null || true + php occ config:system:delete overwritehost 2>/dev/null || true + + export OC_PASS='Preview-e2e-owner-1!' + php occ user:add --password-from-env previewe2e1 + export OC_PASS='Preview-e2e-other-2!' + php occ user:add --password-from-env previewe2e2 + + # The built-in server's document root is the Nextcloud tree, so the + # top-level status.php is served directly (routed app URLs go through + # /index.php/... instead). + php -S 127.0.0.1:8080 >"${RUNNER_TEMP}/nc-server.log" 2>&1 & + echo $! > "${RUNNER_TEMP}/nc-server.pid" + ready= + for _ in $(seq 1 30); do + if curl -sf "http://127.0.0.1:8080/status.php" | grep -q '"installed":true'; then ready=1; break; fi + sleep 1 + done + [ "$ready" = "1" ] || { echo "Nextcloud did not come up"; curl -s "http://127.0.0.1:8080/status.php" || true; cat "${RUNNER_TEMP}/nc-server.log"; exit 1; } + + EXE_E2E_BASE="http://127.0.0.1:8080/index.php" \ + EXE_E2E_USER1=previewe2e1 EXE_E2E_PASS1='Preview-e2e-owner-1!' \ + EXE_E2E_USER2=previewe2e2 EXE_E2E_PASS2='Preview-e2e-other-2!' \ + bash "${WORKSPACE}/tests/e2e/preview-api-e2e.sh" \ + || { echo '--- nextcloud.log (tail) ---'; tail -n 80 data/nextcloud.log 2>/dev/null || true; exit 1; } + + kill "$(cat "${RUNNER_TEMP}/nc-server.pid")" 2>/dev/null || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a9aaa..08e9f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - CI matrix (`.github/workflows/ci.yml`) covering NC 31/32/33 × PHP 8.2/8.3/8.4 with rotated databases (sqlite/mysql/pgsql), plus an experimental PHP 8.5 cell. Each cell installs the app into a real - Nextcloud server and verifies the Service Worker route responds. + Nextcloud server and verifies it enables cleanly, and an API-level + end-to-end job exercises the editor-preview management and serving routes + against a live Nextcloud (CSRF enforcement, ownership, revision publish, + sandbox CSP, session deletion). - `make ci-matrix` reproduces the matrix locally with Docker. - README "Compatibility" table backed by the CI matrix and a CI badge. - Initial Nextcloud app scaffold (`appinfo/info.xml`, routes, bootstrap). diff --git a/README.md b/README.md index 9e5c180..13d3f5d 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,24 @@ playground link (posted as a PR comment) built from that branch — see When a user clicks a `.elpx` file in Nextcloud Files: 1. A Nextcloud Viewer modal opens. -2. The package is downloaded for the current user. -3. The browser extracts the ZIP archive in memory. -4. The internal `index.html` renders inside a **sandboxed iframe**. -5. Relative assets (`html/`, `content/`, `libs/`, `theme/`, `idevices/`, - images, CSS, JS, audio, video, …) are served by a scoped Service Worker - from the in-memory extraction — no second request to the server. +2. The package is downloaded and validated for the current user. +3. The internal `index.html` and its relative assets (`html/`, `content/`, + `libs/`, `theme/`, `idevices/`, images, CSS, JS, audio, video, …) render + inside an **opaque-origin sandboxed iframe** — a sandbox **without** + `allow-same-origin`, so the untrusted author scripts cannot reach the + Nextcloud session. The bytes are served over real HTTP from a cookieless, + capability-token route (`/content/{token}/{path}`), because a Service Worker + cannot back an opaque origin. External media (YouTube/Vimeo/PDF/…) is relayed + to the trusted parent page. See [docs/secure-iframe-viewer.md](docs/secure-iframe-viewer.md). + +The bundled static editor ("Edit with eXeLearning") renders its live **editor +preview** the same way — an opaque-origin iframe served over the HTTP preview +transport (serving contract v2), never the Service Worker. See +[docs/preview-serving-contract.md](docs/preview-serving-contract.md). + +> The legacy Service Worker viewer remains only as an explicit, dev-only opt-in +> (a trusted-content compatibility mode, **not** a security boundary); the opaque +> HTTP path is the default. Optional features: @@ -84,8 +96,9 @@ maintenance ends mid-2026; NC 32 and 33 are the reference targets. | npm | 10 or 11 | | Bun (optional) | latest stable, only for `build-editor` | -Browsers must support Service Workers in the Nextcloud origin. The viewer -will refuse to start otherwise. +The default opaque-origin viewer and the HTTP editor preview are served over +plain HTTP and do **not** require Service Workers. Only the legacy, opt-in +Service Worker viewer needs Service Worker support in the Nextcloud origin. ## Quick start with Docker diff --git a/appinfo/info.xml b/appinfo/info.xml index 791c955..d5c65f8 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -6,11 +6,18 @@ Preview and edit eXeLearning .elpx packages in Nextcloud + + + OCA\ExeLearning\BackgroundJob\PreviewCleanupJob + diff --git a/appinfo/routes.php b/appinfo/routes.php index ded2661..41efaa9 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -21,6 +21,54 @@ 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + [ + 'name' => 'content#serve', + 'url' => '/content/{token}/{path}', + 'verb' => 'GET', + 'requirements' => ['token' => '[A-Za-z0-9._~-]+', 'path' => '.+'], + 'defaults' => ['path' => 'index.html'], + ], + // Editor-preview serving contract v2. The serving route is an authless, + // cookieless capability URL gated solely on the unguessable previewId + // UUID; the management routes are authenticated + owner-scoped (CSRF on). + [ + 'name' => 'preview#serve', + 'url' => '/preview/{previewId}/{path}', + 'verb' => 'GET', + 'requirements' => [ + 'previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', + 'path' => '.+', + ], + ], + [ + 'name' => 'preview#serveRoot', + 'url' => '/preview/{previewId}', + 'verb' => 'GET', + 'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'], + ], + [ + 'name' => 'previewSession#create', + 'url' => '/api/preview-session', + 'verb' => 'POST', + ], + [ + 'name' => 'previewSession#uploadAssets', + 'url' => '/api/preview-session/{previewId}/assets', + 'verb' => 'POST', + 'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'], + ], + [ + 'name' => 'previewSession#publishRevision', + 'url' => '/api/preview-session/{previewId}/revisions', + 'verb' => 'POST', + 'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'], + ], + [ + 'name' => 'previewSession#delete', + 'url' => '/api/preview-session/{previewId}', + 'verb' => 'DELETE', + 'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'], + ], [ 'name' => 'thumbnail#byFileId', 'url' => '/thumbnail/by-file-id/{fileId}', diff --git a/biome.json b/biome.json index 48e4ede..2276ae5 100644 --- a/biome.json +++ b/biome.json @@ -16,7 +16,8 @@ "!**/coverage/**", "!**/js/**", "!**/exelearning/**", - "!**/*.vue" + "!**/*.vue", + "!**/src/embed/exe_*.js" ] }, "formatter": { diff --git a/blueprint.json b/blueprint.json index c7c8346..7798b25 100644 --- a/blueprint.json +++ b/blueprint.json @@ -30,6 +30,13 @@ "url": "https://github-proxy.exelearning.dev/?repo=exelearning/exelearning&release=v4.0.2&asset=exelearning-static-v4.0.2.zip", "destination": "apps/exelearning/js/editor" }, + { + "step": "setConfig", + "comment": "DEV-ONLY Playground demo hatch — NEVER production. The viewer defaults to the secure opaque-origin /content/{token} route, which a Service Worker CANNOT serve (it 404s in the php-wasm Playground). This app-config value is read by the envReader wired in lib/AppInfo/Application.php and flips lib/Service/IframeSandbox::resolveMode() to 'legacy', so ViewController hands the viewer the same-origin Service-Worker/AssetController path that DOES render in the browser. Mirrors the wp-exelearning mu-plugin / mod_exelearning phpConstants demo hatch. Maps to `occ config:app:set exelearning unsafe_legacy_iframe --value 1`; re-introduces allow-same-origin and drops opaque-origin isolation, so it must never be set on a real deployment.", + "app": "exelearning", + "key": "unsafe_legacy_iframe", + "value": "1" + }, { "step": "writeFile", "path": "config/mimetypemapping.json", diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md new file mode 100644 index 0000000..5d3c365 --- /dev/null +++ b/docs/preview-serving-contract.md @@ -0,0 +1,294 @@ +# Host-served opaque HTTP preview — serving contract v2 + +This Nextcloud app is a **preview host** for the eXeLearning editor: it serves +the *editor preview* of untrusted, in-progress author content over HTTP from an +**opaque origin**, isolated from the Nextcloud session. + +This document is the host-side companion to the canonical contract in eXe core: +**`doc/development/preview-serving-contract.md`** (repo `exelearning/exelearning`). +Core is authoritative; if the two disagree, core wins — in particular the sandbox +CSP string must stay **byte-identical** to core's `previewCspHeader()`. + +**Protocol version: 2.** v1 (a full SHA-256 manifest + content-addressed blob +diff) is removed, not kept alongside. A create-session response without +`protocolVersion: 2` must surface an error (no silent fallback). + +## Why an HTTP transport (and not the Service Worker) + +For *published* `.elpx` content this app serves package bytes same-origin (the +Service Worker `src/sw/exelearning-sw.js` and the authenticated +`AssetController::fetch`) — fine for content the owner has committed. + +The **editor preview** is different: it renders *untrusted, unsaved* author HTML +and SVG that can contain arbitrary scripts. Rendering that same-origin — or via a +Service Worker in our scope — would let it read the Nextcloud session, call our +APIs and pivot. A Service Worker **cannot** back an opaque origin (its +subresources bypass the SW). So the editor preview is served from a **separate, +authless, cookieless origin** and never through the Service Worker. + +## The three layers (why v2) + +v2 splits a preview into three layers with different lifecycles so a refresh +costs `O(changed documents + new assets)` instead of `O(whole project)`: + +| Layer | Contents | Lifecycle | Transferred | +|---|---|---|---| +| **Fixed installation resources** (1) | official libraries, base iDevice runtimes, base theme files, PDF.js, content CSS, logo, fonts | immutable per installed editor version | **never** — served from the installed static editor distribution, gated by a build manifest | +| **Session project assets** (2) | author images/audio/video/PDF — anything with an asset identity | immutable per `assetKey`, whole session | **once per session** | +| **Generated documents** (3) | page HTML, navigation, generated CSS/JS, user theme/iDevice files | change every edit | **only the changed files**, as an atomic revision delta | + +Classification is by **provenance, not name**: a resource is *fixed* only when the +client resolved it from the installation-immutable editor distribution. Custom +themes, user-installed iDevices and anything embedded in an `.elpx` ride the +session layers. + +## Implementation map + +All protocol logic is OCP-free and unit-testable; the controllers are thin +Nextcloud adapters. + +| Concern | Class | +|---|---| +| Isolation policy (CSP, scriptable set, Permissions-Policy, MIME, path normalization) | `lib/Service/Preview/PreviewPolicy.php` | +| Session store (three layers, atomic revisions, budgets, TTL, immutability) | `lib/Service/Preview/PreviewSessionStore.php` | +| Fixed-resource layer (manifest lookup + containment) | `lib/Service/Preview/FixedResourceManifest.php` | +| Serving HTTP policy (headers/CSP/cache tiers/Range/304) | `lib/Service/Preview/PreviewServer.php` | +| Management HTTP policy (ownership gate, status/body mapping) | `lib/Service/Preview/PreviewSessionApi.php` | +| Authless serving controller | `lib/Controller/PreviewController.php` | +| Authenticated management controller | `lib/Controller/PreviewSessionController.php` | +| Idle-TTL cleanup job | `lib/BackgroundJob/PreviewCleanupJob.php` | + +## A. Management API (AUTHENTICATED, owner-scoped) + +Served under the normal authenticated app routes (CSRF **on** — these are called +by the embedded editor same-origin, mirroring `editor#save`; they are **not** +`#[NoCSRFRequired]`). Ownership is scoped to the `IUserSession` user id. + +| Method & path | Body | Success | +|---|---|---| +| `POST /apps/exelearning/api/preview-session` | – | `201 { previewId, protocolVersion: 2, revision: 0, limits }` | +| `POST …/preview-session/{previewId}/assets` | multipart: `assets` (JSON `[{key,size}]`), `files[]` index-aligned | `200 { stored, alreadyStored, rejected }` | +| `POST …/preview-session/{previewId}/revisions` | multipart: `revision` (JSON, below), `files[]` aligned with `writes` | `200 { revision, active: true }` | +| `DELETE …/preview-session/{previewId}` | – | `200 { success: true }` | + +- **Asset keys** match `^[0-9a-fA-F-]{36}@[0-9a-f]{8,64}$` and are **immutable**: + re-uploading an existing key returns it in `alreadyStored` and never replaces + bytes (a replaced author file gets a new key). The server treats the key as an + opaque token — it never hashes asset bytes. Enforced on the buffered bytes; a + declared/actual size mismatch rejects that entry. +- **Revision JSON** (`writes` = paths, index-aligned with `files[]`): + + ```jsonc + { + "baseRevision": 17, // the revision the client believes is active + "nextRevision": 18, // must be baseRevision + 1 + "writes": ["index.html"], // aligned with files[] + "deletes": ["html/old.html"], + "assetRefs": { "content/photo.png": "3f2a…@9c41d2e8" }, // FULL map served path → assetKey + "fixedRefs": { "libs/jquery/jquery.min.js": "libs/jquery/jquery.min.js" } // FULL map served path → fixedResourceId + } + ``` + + Validation order: session exists (`404`) → `baseRevision`/`nextRevision` + consistent else `409 { reason: "revision-conflict", currentRevision }` → every + path normalized/safe else `400` → every `assetRefs` value stored else + `422 { reason: "missing-assets", missing }` → every `fixedRefs` value in the + fixed manifest else `422 { reason: "unknown-fixed-resources", resources }` → + file-count/byte budgets else `413`. +- **Atomicity.** A revision is published by writing its content-addressed blobs, + writing the revision manifest (temp+rename), then swapping the `current` + pointer (temp+rename). A concurrent `GET` reads `current` once then an + immutable manifest, so it observes revision *N* or *N+1*, never a mixture. +- **Budgets & TTL.** 30-min idle TTL, 4 sessions/user, 5000 files/session, 200 + MiB/session, 128 MiB/asset, 2 GiB global (per-user and global caps evict LRU). + +## B. Serving route (AUTHLESS capability URL) + +``` +GET /apps/exelearning/preview/{previewId}/{path} +``` + +- **Authless, cookieless.** The opaque iframe sends no SameSite cookies, so this + route does not depend on the session. It is gated solely on the unguessable + server-minted `previewId` UUID + idle TTL (Nextcloud's cookieless serving + primitive, `#[PublicPage]` + `#[NoCSRFRequired]`). It never emits a session + cookie and always answers `Access-Control-Allow-Origin: *` (sound only because + it is cookieless — never paired with credentials). +- `previewId` must match + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`; else 404. +- The bare `/preview/{previewId}` **302-redirects** to `{previewId}/index.html` + (a relative `Location`, correct under any webroot). It never serves index.html + bytes inline — a document served from the bare URL would resolve its relative + subresource references against `preview/` (dropping the id segment) and every + asset would 404. +- **Resolution order** (exact-key against the active revision only): + `documents[path]` → `assets[assetRefs[path]]` → `fixed[fixedRefs[path]]` → 404. + A path only ever names a stored entry; only the server-controlled manifest + `path` reaches the filesystem, contained under the distribution root. +- **Range** on session assets: `Accept-Ranges: bytes`, a satisfiable single + range → `206`, a syntactically valid but unsatisfiable single range (e.g. + `bytes=99-` past EOF) → `416`. A malformed, multi-range or non-`bytes` header + is **ignored** — the server serves a normal `200` full body. **Conditional**: + `ETag: ""`, `If-None-Match` → `304`. + +### Required response headers (on EVERY response, including 404) + +| Header | Value | +| --- | --- | +| `X-Content-Type-Options` | `nosniff` | +| `Referrer-Policy` | `no-referrer` | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` | +| `Access-Control-Allow-Origin` | `*` (never with credentials) | +| `Content-Type` | the served file's real MIME (always set explicitly — Nextcloud serves unknown extensions as `text/plain`) | + +`Cache-Control` is **tiered by layer**: + +| Response | Cache-Control | +| --- | --- | +| Generated document (layer 3) | `no-store` | +| Session asset (layer 2) | `no-cache` (+ `ETag`, `If-None-Match` → 304) | +| Fixed resource (layer 1) | `private, max-age=31536000` | +| 404 / errors | `no-store` | + +### Sandbox CSP (every scriptable document type, from every layer) + +On `text/html`, `image/svg+xml`, `application/xml`, `text/xml` and +`application/xhtml+xml` — whether the response resolves from the session or the +fixed layer — add this `Content-Security-Policy` **verbatim** (byte-identical to +eXe core `previewCspHeader()`; `PreviewPolicy::CSP`): + +``` +sandbox allow-scripts allow-popups allow-forms; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self' +``` + +The leading `sandbox` directive drops the document into an opaque, unique origin +even when opened top-level (a raw `*.html` **or** `*.svg` in a new tab stays +opaque — an author SVG served without it runs its inline `'; // The resilience shim must be installed before any editor script @@ -292,6 +298,65 @@ public function iframe(): DataDisplayResponse|DataResponse { return $response; } + /** + * The `previewHttp` block handed to the embedded editor so it can drive the + * HTTP editor-preview transport (serving contract v2). Shape frozen by the + * normalized contract: `{ protocolVersion, managementBaseUrl, servingBaseUrl, + * managementHeaders }`. + * + * - Both URLs are generated server-side through the router so they carry the + * correct webroot and front-controller prefix under a sub-path install, + * mirroring the client's `@nextcloud/router` `generateUrl`. The serving + * routes are parameterised, so the base is derived by generating the bare + * capability-root URL for a placeholder id and stripping that id segment. + * - `managementHeaders.requesttoken` is the current Nextcloud CSRF token — + * the same encrypted value the standard template layer exposes as + * `data-requesttoken`. It is required because the management routes keep + * CSRF ON (they are NOT `#[NoCSRFRequired]`); the editor replays it on every + * management request. + * + * Token lifetime (audited — the injected-token approach is durable, no + * refresh path needed now): a Nextcloud CSRF token is bound to a per-session + * secret. `getEncryptedValue()` returns a per-call randomized encoding, but + * every value minted from the same session validates against that one secret + * (`isTokenValid()` decrypts and compares), so the injected value stays valid + * for the whole editing session — hours — not just one request. The secret is + * regenerated only when the session id itself is regenerated (login / logout / + * re-auth), never on ordinary navigation; and whenever that happens the parent + * Nextcloud page hosting this iframe is itself invalidated and reloads, which + * re-runs iframe() and injects a fresh token. The parent page also keeps the + * session (and `OC.requestToken`) alive with its keepalive heartbeat. The only + * residual staleness — the parent's token rotating without a reload — cannot + * happen in practice, so a postMessage CONFIGURE refresh into the iframe is a + * future nicety, not a correctness requirement. + * + * @return array{protocolVersion:int,managementBaseUrl:string,servingBaseUrl:string,managementHeaders:object} + */ + private function previewHttpConfig(): array { + $managementBaseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.previewSession.create'); + + // The serving routes take a `previewId` (and `path`); generate the bare + // capability-root URL for a placeholder id, then strip the trailing + // `/{id}` so the client can append `/{previewId}/index.html` itself. + $sampleId = '00000000-0000-4000-8000-000000000000'; + $sampleUrl = $this->urlGenerator->linkToRoute( + Application::APP_ID . '.preview.serveRoot', + ['previewId' => $sampleId], + ); + $servingBaseUrl = str_ends_with($sampleUrl, '/' . $sampleId) + ? substr($sampleUrl, 0, -(strlen($sampleId) + 1)) + : $sampleUrl; + + return [ + 'protocolVersion' => 2, + 'managementBaseUrl' => $managementBaseUrl, + 'servingBaseUrl' => $servingBaseUrl, + 'managementHeaders' => (object)[ + 'requesttoken' => $this->csrfTokenManager->getToken()->getEncryptedValue(), + ], + ]; + } + /** * Resilience shim injected into the editor before any editor * script runs. The static eXeLearning editor's ResourceFetcher rejects diff --git a/lib/Controller/PreviewController.php b/lib/Controller/PreviewController.php new file mode 100644 index 0000000..aa7e765 --- /dev/null +++ b/lib/Controller/PreviewController.php @@ -0,0 +1,89 @@ +server->serve( + $previewId, + $path, + $this->headerOrNull('If-None-Match'), + $this->headerOrNull('Range'), + ); + return $this->toDataDisplay($response); + } + + /** + * GET /apps/exelearning/preview/{previewId} + * + * The bare capability root 302-redirects to `{previewId}/index.html` rather + * than serving index.html bytes inline — otherwise the document's relative + * subresource references resolve against `preview/` (dropping the id segment) + * and every asset 404s. See {@see PreviewServer::serveRoot()}. + */ + #[PublicPage] + #[NoCSRFRequired] + public function serveRoot(string $previewId): DataDisplayResponse { + return $this->toDataDisplay($this->server->serveRoot($previewId)); + } + + /** Map the framework-agnostic response onto a DataDisplayResponse. */ + private function toDataDisplay(PreviewResponse $response): DataDisplayResponse { + $headers = $response->headers; + $contentType = $headers['Content-Type'] ?? 'application/octet-stream'; + unset($headers['Content-Type']); + $result = new DataDisplayResponse($response->body, $response->status, ['Content-Type' => $contentType]); + foreach ($headers as $name => $value) { + $result->addHeader($name, $value); + } + return $result; + } + + /** A request header value, or null when it is absent/empty. */ + private function headerOrNull(string $name): ?string { + $value = (string)$this->request->getHeader($name); + return $value === '' ? null : $value; + } +} diff --git a/lib/Controller/PreviewSessionController.php b/lib/Controller/PreviewSessionController.php new file mode 100644 index 0000000..bd69bd7 --- /dev/null +++ b/lib/Controller/PreviewSessionController.php @@ -0,0 +1,220 @@ +currentUserId(); + if ($userId === null) { + return $this->unauthenticated(); + } + $result = $this->api->create($userId); + return new DataResponse($result->body, $result->status); + } + + /** + * POST /apps/exelearning/api/preview-session/{previewId}/assets + * + * multipart: `assets` = JSON `[{ key, size }]`, `files[]` index-aligned. + */ + #[NoAdminRequired] + public function uploadAssets(string $previewId): DataResponse { + $userId = $this->currentUserId(); + if ($userId === null) { + return $this->unauthenticated(); + } + $declared = $this->decodeJsonParam('assets'); + $files = $this->uploadedFileBytes('files'); + $entries = []; + foreach ($declared as $index => $meta) { + if (!is_array($meta)) { + continue; + } + $entries[] = [ + 'key' => (string)($meta['key'] ?? ''), + 'declaredSize' => (int)($meta['size'] ?? -1), + 'bytes' => $files[$index] ?? '', + ]; + } + $result = $this->api->uploadAssets($previewId, $userId, $entries); + return new DataResponse($result->body, $result->status); + } + + /** + * POST /apps/exelearning/api/preview-session/{previewId}/revisions + * + * multipart: `revision` = JSON `{ baseRevision, nextRevision, writes:[paths], + * deletes, assetRefs, fixedRefs }`, `files[]` index-aligned with `writes`. + */ + #[NoAdminRequired] + public function publishRevision(string $previewId): DataResponse { + $userId = $this->currentUserId(); + if ($userId === null) { + return $this->unauthenticated(); + } + $revision = $this->decodeJsonParam('revision'); + $parts = $this->uploadedFileParts('files'); + $writePaths = is_array($revision['writes'] ?? null) ? array_values($revision['writes']) : []; + + // Strict alignment: every declared write MUST have a healthy uploaded part + // (present, UPLOAD_ERR_OK, readable). A dropped/failed/unreadable part — or + // a part/write count mismatch — rejects the ENTIRE batch with 400 before + // any staging, so a silently-empty document (`sha1('')`, size 0) can never + // be published. The revision pointer is untouched; a subsequent GET keeps + // serving the previous revision. + if (count($parts) !== count($writePaths)) { + return new DataResponse( + ['error' => 'Revision upload incomplete: ' . count($writePaths) + . ' write(s) declared but ' . count($parts) . ' file part(s) received'], + Http::STATUS_BAD_REQUEST, + ); + } + $writes = []; + foreach ($writePaths as $index => $path) { + if (!$parts[$index]['ok']) { + return new DataResponse( + ['error' => 'Revision upload incomplete: missing or unreadable file part for write #' . $index], + Http::STATUS_BAD_REQUEST, + ); + } + $writes[] = ['path' => (string)$path, 'bytes' => $parts[$index]['bytes']]; + } + $meta = [ + 'baseRevision' => (int)($revision['baseRevision'] ?? -1), + 'nextRevision' => (int)($revision['nextRevision'] ?? -1), + 'writes' => $writes, + 'deletes' => is_array($revision['deletes'] ?? null) ? array_values($revision['deletes']) : [], + 'assetRefs' => is_array($revision['assetRefs'] ?? null) ? $revision['assetRefs'] : [], + 'fixedRefs' => is_array($revision['fixedRefs'] ?? null) ? $revision['fixedRefs'] : [], + ]; + $result = $this->api->publishRevision($previewId, $userId, $meta); + return new DataResponse($result->body, $result->status); + } + + /** DELETE /apps/exelearning/api/preview-session/{previewId} */ + #[NoAdminRequired] + public function delete(string $previewId): DataResponse { + $userId = $this->currentUserId(); + if ($userId === null) { + return $this->unauthenticated(); + } + $result = $this->api->delete($previewId, $userId); + return new DataResponse($result->body, $result->status); + } + + private function currentUserId(): ?string { + $user = $this->userSession->getUser(); + return $user === null ? null : $user->getUID(); + } + + private function unauthenticated(): DataResponse { + return new DataResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + /** + * Decode a JSON-string form field into an array (empty array on absence or + * malformed JSON). + * + * @return array + */ + private function decodeJsonParam(string $name): array { + $raw = $this->request->getParam($name); + if (!is_string($raw) || $raw === '') { + return []; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * Read the `files[]` multipart parts (index-aligned) into a list of byte + * strings, mapping a missing/failed/unreadable part to an empty string. Used + * by the asset path, where the store rejects a dropped part via its + * declared/actual size guard. + * + * @return list + */ + private function uploadedFileBytes(string $field): array { + return array_map( + static fn (array $part): string => $part['bytes'], + $this->uploadedFileParts($field), + ); + } + + /** + * Read the `files[]` multipart parts (index-aligned) with their upload + * health, so a caller can reject a batch when a part is missing, errored or + * unreadable. Handles both the array shape (`files[]`, multiple parts) and the + * scalar shape (a single part). + * + * A part is `ok` only when `$_FILES[...]['error']` is `UPLOAD_ERR_OK`, the + * `tmp_name` is a genuine uploaded file, and its bytes read back. A genuinely + * empty but successfully uploaded file is `ok` with empty bytes; a + * dropped/failed/unreadable part is `ok=false` — the distinction the revision + * path needs and that a plain byte string cannot carry. + * + * @return list + */ + private function uploadedFileParts(string $field): array { + $uploaded = $this->request->getUploadedFile($field); + if (!is_array($uploaded) || !isset($uploaded['tmp_name'])) { + return []; + } + $tmpNames = $uploaded['tmp_name']; + $errors = $uploaded['error'] ?? UPLOAD_ERR_NO_FILE; + if (!is_array($tmpNames)) { + $tmpNames = [$tmpNames]; + $errors = [$errors]; + } + $parts = []; + foreach ($tmpNames as $index => $tmpName) { + $error = is_array($errors) ? ($errors[$index] ?? UPLOAD_ERR_NO_FILE) : $errors; + if ((int)$error !== UPLOAD_ERR_OK || !is_string($tmpName) || $tmpName === '' || !is_uploaded_file($tmpName)) { + $parts[] = ['ok' => false, 'bytes' => '']; + continue; + } + $content = file_get_contents($tmpName); + if ($content === false) { + $parts[] = ['ok' => false, 'bytes' => '']; + continue; + } + $parts[] = ['ok' => true, 'bytes' => $content]; + } + return $parts; + } +} diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 87ac8a1..2d29d85 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -5,7 +5,9 @@ namespace OCA\ExeLearning\Controller; use OCA\ExeLearning\AppInfo\Application; +use OCA\ExeLearning\Service\ContentTokenService; use OCA\ExeLearning\Service\ElpxPackageService; +use OCA\ExeLearning\Service\IframeSandbox; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -39,6 +41,8 @@ public function __construct( private readonly ElpxPackageService $packageService, private readonly IInitialState $initialState, private readonly IURLGenerator $urlGenerator, + private readonly ContentTokenService $contentTokens, + private readonly IframeSandbox $sandbox, ) { parent::__construct($appName, $request); } @@ -66,6 +70,7 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode = } } + $secureIframe = $this->sandbox->resolveMode() === IframeSandbox::MODE_SECURE; if ($file !== null) { $this->initialState->provideInitialState('file', [ 'id' => $file->getId(), @@ -75,7 +80,25 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode = 'etag' => $file->getEtag(), 'writable' => $file->isUpdateable(), ]); + // In secure mode the package is served into an opaque-origin iframe + // over the cookieless /content/{token}/… capability route. The user's + // read permission was just checked above (getForUser*), so mint the + // bearer token here. Legacy (same-origin Service Worker) mode leaves + // this null and the viewer keeps its /runtime SW path. + $this->initialState->provideInitialState( + 'contentToken', + $secureIframe ? $this->contentTokens->mint((int)$file->getId(), $user->getUID()) : null, + ); } + $this->initialState->provideInitialState('secureIframe', $secureIframe); + // External-media relay config for the trusted parent (see relay-host.ts): + // 'strict' mode overlays only the maintained provider hosts. + $this->initialState->provideInitialState( + 'embedRelay', + $secureIframe + ? ['mode' => $this->sandbox->embedMode(), 'whitelist' => $this->sandbox->providerWhitelist()] + : null, + ); $this->initialState->provideInitialState( 'editorAvailable', is_file(__DIR__ . '/../../js/editor/index.html'), diff --git a/lib/Service/ContentTokenService.php b/lib/Service/ContentTokenService.php new file mode 100644 index 0000000..47ec09a --- /dev/null +++ b/lib/Service/ContentTokenService.php @@ -0,0 +1,102 @@ +clock = $clock ?? static fn (): int => time(); + } + + /** + * Mint a capability token binding $fileId + $userId valid for $ttlSeconds. + * The user id is carried so the cookieless serving route can mount that + * user's storage (getUserFolder) — IRootFolder::getById alone resolves + * nothing without a filesystem context on a #[PublicPage]. + */ + public function mint(int $fileId, string $userId, int $ttlSeconds = 3600): string { + $expiry = ($this->clock)() + $ttlSeconds; + $payload = $fileId . '.' . $expiry . '.' . $this->b64($userId); + return $this->b64($payload) . '.' . $this->b64($this->sign($payload)); + } + + /** + * Verify a token: returns `[fileId, userId]` when the signature is valid and + * the token has not expired, otherwise null. + * + * @return array{0: int, 1: string}|null + */ + public function verify(string $token): ?array { + $parts = explode('.', $token); + if (count($parts) !== 2) { + return null; + } + $payload = $this->unb64($parts[0]); + $signature = $this->unb64($parts[1]); + if ($payload === null || $signature === null) { + return null; + } + if (!hash_equals($this->sign($payload), $signature)) { + return null; + } + $segments = explode('.', $payload); + if (count($segments) !== 3) { + return null; + } + [$fileId, $expiry, $encodedUser] = $segments; + if (!ctype_digit($fileId) || !ctype_digit($expiry)) { + return null; + } + if ((int)$expiry <= ($this->clock)()) { + return null; + } + $userId = $this->unb64($encodedUser); + if ($userId === null || $userId === '') { + return null; + } + return [(int)$fileId, $userId]; + } + + private function sign(string $payload): string { + return hash_hmac('sha256', $payload, $this->secret(), true); + } + + private function secret(): string { + return $this->secret . '|exelearning-content-v1'; + } + + private function b64(string $raw): string { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + private function unb64(string $encoded): ?string { + $decoded = base64_decode(strtr($encoded, '-_', '+/'), true); + return $decoded === false ? null : $decoded; + } +} diff --git a/lib/Service/EmbedShimInjector.php b/lib/Service/EmbedShimInjector.php new file mode 100644 index 0000000..372f0bc --- /dev/null +++ b/lib/Service/EmbedShimInjector.php @@ -0,0 +1,40 @@ + as early as possible: before + * when present (it must run before the package's own media bridge), + * otherwise right after , otherwise prepended. + */ + public function injectIntoHead(string $html, string $scriptSource): string { + $tag = ''; + + $headClose = stripos($html, ''); + if ($headClose !== false) { + return substr($html, 0, $headClose) . $tag . substr($html, $headClose); + } + + if (preg_match('~]*>~i', $html, $matches, PREG_OFFSET_CAPTURE) === 1) { + $at = $matches[0][1] + strlen($matches[0][0]); + return substr($html, 0, $at) . $tag . substr($html, $at); + } + + return $tag . $html; + } +} diff --git a/lib/Service/IframeSandbox.php b/lib/Service/IframeSandbox.php new file mode 100644 index 0000000..0b38e3d --- /dev/null +++ b/lib/Service/IframeSandbox.php @@ -0,0 +1,150 @@ +envReader = $envReader ?? static function (string $name): ?string { + $value = getenv($name); + return $value === false ? null : $value; + }; + } + + /** + * Always 'secure' unless the dev escape hatch is explicitly on. + */ + public function resolveMode(): string { + $raw = ($this->envReader)(self::LEGACY_ENV); + $on = $raw !== null && in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'on'], true); + return $on ? self::MODE_LEGACY : self::MODE_SECURE; + } + + /** + * External-media relay mode. 'strict' (default, fail-safe) overlays only the + * maintained provider hosts; 'open' (opt-in via EXELEARNING_EMBED_OPEN) + * overlays any cross-origin https player the shim reports. + */ + public function embedMode(): string { + $raw = ($this->envReader)(self::EMBED_OPEN_ENV); + $on = $raw !== null && in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'on'], true); + return $on ? self::EMBED_OPEN : self::EMBED_STRICT; + } + + /** + * The iframe `sandbox` attribute token list for the given (or resolved) mode. + */ + public function sandboxTokens(?string $mode = null): string { + $mode ??= $this->resolveMode(); + return $mode === self::MODE_LEGACY ? self::LEGACY_TOKENS : self::SECURE_TOKENS; + } + + /** + * Response-level CSP for served HTML package documents. + * + * 'strict' (default) keeps script/img/media/frame-src pinned to 'self' + the + * maintained providers so a URL-borne capability token can never be + * exfiltrated to an arbitrary host; the trailing `sandbox` directive keeps + * the document opaque even if opened top-level. 'compatible' re-opens https: + * for deployments that need arbitrary external assets. + */ + public function contentSecurityPolicy(string $profile = 'strict'): string { + $scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval'"; + $imgSrc = "img-src 'self' data: blob:"; + $mediaSrc = "media-src 'self' data: blob:"; + $frameSrc = "frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com " + . 'https://www.dailymotion.com https://mediateca.educa.madrid.org'; + if ($profile === 'compatible') { + $scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval' https:"; + $imgSrc = "img-src 'self' data: blob: https:"; + $mediaSrc = "media-src 'self' data: blob: https:"; + $frameSrc = "frame-src 'self' https:"; + } + return implode('; ', [ + "default-src 'self'", + $scriptSrc, + "style-src 'self' 'unsafe-inline'", + $imgSrc, + $mediaSrc, + "font-src 'self' data:", + "connect-src 'self'", + $frameSrc, + "frame-ancestors 'self'", + "form-action 'self'", + "base-uri 'self'", + "object-src 'none'", + 'sandbox ' . self::SECURE_TOKENS, + ]); + } + + /** + * Locked, script-free CSP for SVG/XML documents opened top-level. + */ + public function svgCsp(): string { + return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none'; sandbox"; + } + + /** + * Deny hardware access; deliberately does NOT deny fullscreen (video needs it). + */ + public function permissionsPolicy(): string { + return 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), ' + . 'bluetooth=(), hid=(), magnetometer=(), accelerometer=(), gyroscope=(), ' + . 'midi=(), display-capture=()'; + } + + /** + * @return string[] + */ + public function providerWhitelist(): array { + return self::PROVIDER_WHITELIST; + } +} diff --git a/lib/Service/PackageMimeService.php b/lib/Service/PackageMimeService.php new file mode 100644 index 0000000..06fd84e --- /dev/null +++ b/lib/Service/PackageMimeService.php @@ -0,0 +1,69 @@ + 'text/html; charset=utf-8', + 'htm' => 'text/html; charset=utf-8', + 'xhtml' => 'application/xhtml+xml; charset=utf-8', + 'xml' => 'application/xml; charset=utf-8', + 'css' => 'text/css; charset=utf-8', + 'js' => 'text/javascript; charset=utf-8', + 'mjs' => 'text/javascript; charset=utf-8', + 'json' => 'application/json; charset=utf-8', + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'webm' => 'video/webm', + 'vtt' => 'text/vtt', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + ]; + + public function detect(string $entry): string { + $extension = strtolower(pathinfo($entry, PATHINFO_EXTENSION)); + return self::MIME_MAP[$extension] ?? 'application/octet-stream'; + } + + /** + * A "page" document that runs the eXe engine — gets the full published CSP + * and the injected media shim. + */ + public function isHtmlDocument(string $mime): bool { + $type = $this->baseType($mime); + return $type === 'text/html' || $type === 'application/xhtml+xml'; + } + + /** + * A data document (SVG/XML) that must be locked script-free when opened + * top-level (an author SVG runs inline ', $out); + } + + public function testPreservesRegexSpecialCharsInSource(): void { + $source = 'var a = "$1"; var b = "\\\\n"; // $0 \\1'; + $out = $this->injector->injectIntoHead('', $source); + self::assertStringContainsString($source, $out); + } + + public function testFallsBackToAfterBodyWhenNoHead(): void { + $out = $this->injector->injectIntoHead('hi', 'Y'); + self::assertStringContainsString('', $out); + } + + public function testPrependsWhenNoHeadNoBody(): void { + $out = $this->injector->injectIntoHead('

bare

', 'Z'); + self::assertStringStartsWith('', $out); + } +} diff --git a/tests/Unit/Service/IframeSandboxTest.php b/tests/Unit/Service/IframeSandboxTest.php new file mode 100644 index 0000000..8043e0e --- /dev/null +++ b/tests/Unit/Service/IframeSandboxTest.php @@ -0,0 +1,102 @@ + $env Environment values keyed by variable name. + */ + private function service(array $env = []): IframeSandbox { + return new IframeSandbox(static fn (string $name): ?string => $env[$name] ?? null); + } + + public function testDefaultsToSecureMode(): void { + self::assertSame(IframeSandbox::MODE_SECURE, $this->service()->resolveMode()); + } + + public function testLegacyModeOnlyViaEscapeHatch(): void { + $hatch = 'EXELEARNING_UNSAFE_LEGACY_IFRAME'; + self::assertSame(IframeSandbox::MODE_LEGACY, $this->service([$hatch => '1'])->resolveMode()); + self::assertSame(IframeSandbox::MODE_LEGACY, $this->service([$hatch => 'true'])->resolveMode()); + self::assertSame(IframeSandbox::MODE_SECURE, $this->service([$hatch => '0'])->resolveMode()); + self::assertSame(IframeSandbox::MODE_SECURE, $this->service([$hatch => ''])->resolveMode()); + } + + public function testEmbedModeDefaultsToStrict(): void { + self::assertSame(IframeSandbox::EMBED_STRICT, $this->service()->embedMode()); + } + + public function testEmbedModeOpensOnlyViaEnv(): void { + $env = 'EXELEARNING_EMBED_OPEN'; + self::assertSame(IframeSandbox::EMBED_OPEN, $this->service([$env => '1'])->embedMode()); + self::assertSame(IframeSandbox::EMBED_STRICT, $this->service([$env => 'off'])->embedMode()); + } + + public function testSecureSandboxTokensNeverAllowSameOrigin(): void { + $tokens = $this->service()->sandboxTokens(IframeSandbox::MODE_SECURE); + self::assertSame('allow-scripts allow-popups allow-forms', $tokens); + self::assertStringNotContainsString('allow-same-origin', $tokens); + self::assertStringNotContainsString('allow-popups-to-escape-sandbox', $tokens); + self::assertStringNotContainsString('allow-top-navigation', $tokens); + } + + public function testLegacySandboxTokensReintroduceSameOrigin(): void { + $tokens = $this->service()->sandboxTokens(IframeSandbox::MODE_LEGACY); + self::assertStringContainsString('allow-same-origin', $tokens); + } + + public function testStrictPublishedCspShape(): void { + $csp = $this->service()->contentSecurityPolicy(); + // The opaque sandbox rides in the CSP too (keeps the doc opaque if opened top-level). + self::assertStringContainsString('sandbox allow-scripts allow-popups allow-forms', $csp); + self::assertStringContainsString("object-src 'none'", $csp); + self::assertStringContainsString("frame-ancestors 'self'", $csp); + // Only the maintained providers, never bare https: in frame-src. + self::assertStringContainsString('https://www.youtube-nocookie.com', $csp); + self::assertStringContainsString('https://mediateca.educa.madrid.org', $csp); + self::assertStringNotContainsString('frame-src \'self\' https:;', $csp); + // No token-exfiltrating bare https: in script/img/media in the strict profile. + self::assertDoesNotMatchRegularExpression('~script-src[^;]*\bhttps:(?!//)~', $csp); + } + + public function testCompatibleProfileReopensHttps(): void { + $csp = $this->service()->contentSecurityPolicy('compatible'); + self::assertStringContainsString("script-src 'self' 'unsafe-inline' 'unsafe-eval' https:", $csp); + self::assertStringContainsString('frame-src \'self\' https:', $csp); + // Sandbox stays even in the weaker profile. + self::assertStringContainsString('sandbox allow-scripts allow-popups allow-forms', $csp); + } + + public function testSvgCspIsScriptFree(): void { + $csp = $this->service()->svgCsp(); + self::assertStringContainsString("script-src 'none'", $csp); + self::assertStringContainsString("default-src 'none'", $csp); + self::assertStringContainsString('sandbox', $csp); + } + + public function testPermissionsPolicyDeniesHardwareButNotFullscreen(): void { + $pp = $this->service()->permissionsPolicy(); + self::assertStringContainsString('camera=()', $pp); + self::assertStringContainsString('microphone=()', $pp); + self::assertStringContainsString('geolocation=()', $pp); + self::assertStringNotContainsString('fullscreen', $pp); + } + + public function testProviderWhitelistIsLowercaseAndDeduped(): void { + $hosts = $this->service()->providerWhitelist(); + self::assertContains('www.youtube-nocookie.com', $hosts); + self::assertContains('player.vimeo.com', $hosts); + self::assertContains('mediateca.educa.madrid.org', $hosts); + self::assertSame(array_values(array_unique($hosts)), $hosts); + self::assertSame(array_map('strtolower', $hosts), $hosts); + } +} diff --git a/tests/Unit/Service/PackageMimeServiceTest.php b/tests/Unit/Service/PackageMimeServiceTest.php new file mode 100644 index 0000000..850fcfc --- /dev/null +++ b/tests/Unit/Service/PackageMimeServiceTest.php @@ -0,0 +1,42 @@ +service = new PackageMimeService(); + } + + public function testDetectsKnownExtensions(): void { + self::assertSame('text/html; charset=utf-8', $this->service->detect('index.html')); + self::assertSame('image/svg+xml', $this->service->detect('img/logo.svg')); + self::assertSame('text/javascript; charset=utf-8', $this->service->detect('a.js')); + self::assertSame('application/xml; charset=utf-8', $this->service->detect('content.xml')); + } + + public function testUnknownExtensionFallsBack(): void { + self::assertSame('application/octet-stream', $this->service->detect('weird.qux')); + self::assertSame('application/octet-stream', $this->service->detect('README')); + } + + public function testHtmlDocumentsGetTheEnginePolicy(): void { + self::assertTrue($this->service->isHtmlDocument('text/html; charset=utf-8')); + self::assertTrue($this->service->isHtmlDocument('application/xhtml+xml')); + self::assertFalse($this->service->isHtmlDocument('image/svg+xml')); + self::assertFalse($this->service->isHtmlDocument('text/css')); + } + + public function testXmlDocumentsAreLocked(): void { + self::assertTrue($this->service->isLockedXml('image/svg+xml')); + self::assertTrue($this->service->isLockedXml('application/xml; charset=utf-8')); + self::assertFalse($this->service->isLockedXml('text/html')); + self::assertFalse($this->service->isLockedXml('image/png')); + } +} diff --git a/tests/Unit/Service/Preview/FixedResourceManifestTest.php b/tests/Unit/Service/Preview/FixedResourceManifestTest.php new file mode 100644 index 0000000..249cbb8 --- /dev/null +++ b/tests/Unit/Service/Preview/FixedResourceManifestTest.php @@ -0,0 +1,108 @@ +distRoot = sys_get_temp_dir() . '/exe-fixed-' . bin2hex(random_bytes(6)); + mkdir($this->distRoot . '/bundles', 0770, true); + mkdir($this->distRoot . '/libs/jquery', 0770, true); + } + + protected function tearDown(): void { + $this->removeTree($this->distRoot); + $this->removeTree(dirname($this->distRoot) . '/exe-outside-' . basename($this->distRoot)); + } + + private function writeManifest(array $resources): void { + file_put_contents( + $this->distRoot . '/bundles/preview-fixed-resources.json', + json_encode(['schemaVersion' => 1, 'buildVersion' => 'test', 'resources' => $resources]), + ); + } + + public function testResolvesEnumeratedResource(): void { + $body = 'window.jQuery=function(){};'; + file_put_contents($this->distRoot . '/libs/jquery/jquery.min.js', $body); + $this->writeManifest([ + 'libs/jquery/jquery.min.js' => ['path' => 'libs/jquery/jquery.min.js', 'size' => strlen($body)], + ]); + $manifest = new FixedResourceManifest($this->distRoot); + + self::assertTrue($manifest->has('libs/jquery/jquery.min.js')); + $resource = $manifest->get('libs/jquery/jquery.min.js'); + self::assertNotNull($resource); + self::assertSame($body, $resource['bytes']); + // get() reports the ACTUAL byte length read from disk, not the manifest's + // declared size (which the server never trusts for serving). + self::assertSame(strlen($body), $resource['size']); + } + + public function testUnknownIdIsNeitherHadNorResolved(): void { + $this->writeManifest(['libs/jquery/jquery.min.js' => ['path' => 'libs/jquery/jquery.min.js']]); + $manifest = new FixedResourceManifest($this->distRoot); + + self::assertFalse($manifest->has('libs/does/not/exist.js')); + self::assertNull($manifest->get('libs/does/not/exist.js')); + } + + public function testAbsentManifestDisablesTheLayer(): void { + // No bundles/preview-fixed-resources.json written. + $manifest = new FixedResourceManifest($this->distRoot); + self::assertFalse($manifest->has('anything')); + self::assertNull($manifest->get('anything')); + } + + public function testMalformedManifestDisablesTheLayer(): void { + file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', '{ not json'); + $manifest = new FixedResourceManifest($this->distRoot); + self::assertFalse($manifest->has('anything')); + } + + public function testManifestPathEscapingRootIsRejected(): void { + $outside = dirname($this->distRoot) . '/exe-outside-' . basename($this->distRoot); + file_put_contents($outside, 'SECRET'); + $this->writeManifest(['evil' => ['path' => '../exe-outside-' . basename($this->distRoot)]]); + $manifest = new FixedResourceManifest($this->distRoot); + + // The id is enumerated, but the containment check refuses to read it. + self::assertTrue($manifest->has('evil')); + self::assertNull($manifest->get('evil')); + } + + public function testMissingBackingFileYieldsNull(): void { + $this->writeManifest(['ghost' => ['path' => 'libs/jquery/ghost.js']]); + $manifest = new FixedResourceManifest($this->distRoot); + self::assertTrue($manifest->has('ghost')); + self::assertNull($manifest->get('ghost')); + } + + private function removeTree(string $dir): void { + if (is_file($dir)) { + @unlink($dir); + return; + } + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/Unit/Service/Preview/PreviewContractConformanceTest.php b/tests/Unit/Service/Preview/PreviewContractConformanceTest.php new file mode 100644 index 0000000..2dedf7c --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewContractConformanceTest.php @@ -0,0 +1,252 @@ +root = sys_get_temp_dir() . '/exe-vec-' . bin2hex(random_bytes(6)); + $this->distRoot = sys_get_temp_dir() . '/exe-vec-dist-' . bin2hex(random_bytes(6)); + mkdir($this->distRoot, 0770, true); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + $this->removeTree($this->distRoot); + } + + public function testReplayVectors(): void { + $vectors = json_decode( + (string)file_get_contents(__DIR__ . '/../../../fixtures/preview-contract/vectors.json'), + true, + ); + self::assertSame(2, $vectors['protocolVersion']); + + $fixed = $this->materializeFixedResources($vectors['fixedResources']); + $store = new PreviewSessionStore($this->root, new PreviewSessionLimits()); + $this->api = new PreviewSessionApi($store, $fixed); + $this->server = new PreviewServer($store, $fixed); + + foreach ($vectors['steps'] as $step) { + $this->runStep($step); + } + } + + /** + * Write each fixed resource under a throwaway distribution root and register + * it in the host's manifest copy, then resolve fixed refs through that + * manifest only (harness semantics step 1). + * + * @param array $resources + */ + private function materializeFixedResources(array $resources): FixedResourceManifest { + $manifest = []; + foreach ($resources as $id => $resource) { + $target = $this->distRoot . '/' . $resource['path']; + @mkdir(dirname($target), 0770, true); + file_put_contents($target, $resource['content']); + $manifest[$id] = ['path' => $resource['path'], 'size' => strlen($resource['content'])]; + } + @mkdir($this->distRoot . '/bundles', 0770, true); + file_put_contents( + $this->distRoot . '/bundles/preview-fixed-resources.json', + json_encode(['schemaVersion' => 1, 'resources' => $manifest]), + ); + return new FixedResourceManifest($this->distRoot); + } + + private function runStep(array $step): void { + $id = $step['id']; + $method = $step['request']['method']; + $path = str_replace('{previewId}', $this->previewId, $step['request']['path']); + $expect = $step['expect']; + + if ($method === 'GET') { + $response = $this->dispatchServe($path, $step['request']['headers'] ?? []); + $this->assertServe($id, $response, $expect); + return; + } + $result = $this->dispatchManagement($method, $path, $step['request']['body'] ?? null); + $this->assertManagement($id, $result, $expect); + if ($id === 'create-session') { + $this->previewId = $result->body['previewId']; + } + } + + private function dispatchServe(string $path, array $headers): PreviewResponse { + self::assertSame(1, preg_match('#^/preview/([^/]+)/(.*)$#', $path, $m), "unexpected serve path: $path"); + return $this->server->serve( + $m[1], + $m[2], + $headers['If-None-Match'] ?? null, + $headers['Range'] ?? null, + ); + } + + private function dispatchManagement(string $method, string $path, ?array $body): ApiResult { + if ($method === 'POST' && $path === '/api/preview-session') { + return $this->api->create(self::OWNER); + } + if ($method === 'POST' && str_ends_with($path, '/assets')) { + return $this->api->uploadAssets($this->previewId, self::OWNER, $this->assetEntries($body)); + } + if ($method === 'POST' && str_ends_with($path, '/revisions')) { + return $this->api->publishRevision($this->previewId, self::OWNER, $this->revisionMeta($body)); + } + if ($method === 'DELETE') { + return $this->api->delete($this->previewId, self::OWNER); + } + self::fail("unhandled management request: $method $path"); + } + + /** + * `body.kind === "assets"`: each entry becomes `{ key, size, bytes }` (the + * harness's `size` = byte length of `content`). + * + * @return list + */ + private function assetEntries(?array $body): array { + $entries = []; + foreach ($body['entries'] ?? [] as $entry) { + $entries[] = [ + 'key' => $entry['key'], + 'declaredSize' => strlen($entry['content']), + 'bytes' => $entry['content'], + ]; + } + return $entries; + } + + /** + * `body.kind === "revision"`: `meta` plus `writes` mapped to `{ path, bytes }` + * (the harness sends `writes` as paths + index-aligned file parts). + * + * @return array + */ + private function revisionMeta(?array $body): array { + $meta = $body['meta']; + $writes = []; + foreach ($body['writes'] ?? [] as $write) { + $writes[] = ['path' => $write['path'], 'bytes' => $write['content']]; + } + $meta['writes'] = $writes; + return $meta; + } + + private function assertManagement(string $id, ApiResult $result, array $expect): void { + self::assertSame($expect['status'], $result->status, "status for step $id"); + if (isset($expect['body'])) { + $this->assertBodySubset($expect['body'], $result->body, $id); + } + } + + private function assertServe(string $id, PreviewResponse $response, array $expect): void { + self::assertSame($expect['status'], $response->status, "status for step $id"); + if (isset($expect['bodyText'])) { + self::assertSame($expect['bodyText'], $response->body, "bodyText for step $id"); + } + foreach ($expect['headers'] ?? [] as $name => $spec) { + $this->assertHeader($id, $response->headers, $name, $spec); + } + } + + /** + * @param array $headers + * @param string|array $spec + */ + private function assertHeader(string $id, array $headers, string $name, string|array $spec): void { + $value = null; + $present = false; + foreach ($headers as $key => $headerValue) { + if (strcasecmp($key, $name) === 0) { + $value = $headerValue; + $present = true; + break; + } + } + if (is_string($spec)) { + self::assertTrue($present, "header $name present for step $id"); + self::assertSame($spec, $value, "header $name for step $id"); + return; + } + if (isset($spec['absent'])) { + self::assertFalse($present, "header $name must be absent for step $id"); + return; + } + self::assertTrue($present, "header $name present for step $id"); + if (isset($spec['startsWith'])) { + self::assertStringStartsWith($spec['startsWith'], (string)$value, "header $name startsWith for step $id"); + } + if (isset($spec['contains'])) { + self::assertStringContainsString($spec['contains'], (string)$value, "header $name contains for step $id"); + } + } + + /** + * Recursive subset match: JSON arrays (PHP lists) must match exactly, JSON + * objects (assoc) may carry extra keys. + * + * @param array $expected + * @param array $actual + */ + private function assertBodySubset(array $expected, array $actual, string $id): void { + foreach ($expected as $key => $value) { + self::assertArrayHasKey($key, $actual, "body.$key for step $id"); + if (is_array($value)) { + if (array_is_list($value)) { + self::assertSame($value, $actual[$key], "body.$key (array) for step $id"); + } else { + self::assertIsArray($actual[$key], "body.$key (object) for step $id"); + $this->assertBodySubset($value, $actual[$key], $id); + } + } else { + self::assertSame($value, $actual[$key], "body.$key for step $id"); + } + } + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/Unit/Service/Preview/PreviewPolicyTest.php b/tests/Unit/Service/Preview/PreviewPolicyTest.php new file mode 100644 index 0000000..2a33903 --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewPolicyTest.php @@ -0,0 +1,155 @@ + + */ + public static function scriptableTypes(): iterable { + yield ['text/html']; + yield ['text/html; charset=utf-8']; + yield ['image/svg+xml']; + yield ['image/svg+xml; charset=utf-8']; + yield ['application/xml']; + yield ['application/xhtml+xml; charset=utf-8']; + } + + /** + * @dataProvider nonScriptableTypes + */ + public function testNonScriptableTypesAreNotScriptable(string $mime): void { + self::assertFalse(PreviewPolicy::isScriptable($mime)); + } + + /** + * @return iterable + */ + public static function nonScriptableTypes(): iterable { + yield ['image/png']; + yield ['text/css; charset=utf-8']; + yield ['application/json; charset=utf-8']; + yield ['video/mp4']; + yield ['application/octet-stream']; + } + + /** + * @dataProvider mimeCases + */ + public function testMimeForPath(string $path, string $expected): void { + self::assertSame($expected, PreviewPolicy::mimeForPath($path)); + } + + /** + * @return iterable + */ + public static function mimeCases(): iterable { + yield 'html carries charset' => ['index.html', 'text/html; charset=utf-8']; + yield 'png is bare' => ['content/resources/photo.png', 'image/png']; + yield 'svg is charset-tagged (scriptable-tab hole)' => ['theme/icon.svg', 'image/svg+xml; charset=utf-8']; + yield 'js carries charset' => ['libs/jquery/jquery.min.js', 'text/javascript; charset=utf-8']; + yield 'json carries charset' => ['data/index.json', 'application/json; charset=utf-8']; + yield 'mp4 is bare' => ['media/clip.mp4', 'video/mp4']; + yield 'unknown falls back to octet-stream' => ['blob.bin', 'application/octet-stream']; + } + + /** + * @dataProvider safePaths + */ + public function testNormalizePathAcceptsSafePaths(string $raw, string $expected): void { + self::assertSame($expected, PreviewPolicy::normalizePath($raw)); + } + + /** + * @return iterable + */ + public static function safePaths(): iterable { + yield 'plain file' => ['index.html', 'index.html']; + yield 'empty defaults to index' => ['', 'index.html']; + yield 'leading slash stripped' => ['/html/page-2.html', 'html/page-2.html']; + yield 'dot segments collapsed' => ['a/./b.css', 'a/b.css']; + yield 'double slash collapsed' => ['a//b.css', 'a/b.css']; + yield 'backslash folded to slash' => ['a\\b.css', 'a/b.css']; + yield 'query stripped' => ['page.html?v=3', 'page.html']; + } + + /** + * @dataProvider unsafePaths + */ + public function testNormalizePathRejectsUnsafePaths(string $raw): void { + self::assertNull(PreviewPolicy::normalizePath($raw)); + } + + /** + * @return iterable + */ + public static function unsafePaths(): iterable { + yield 'literal parent' => ['../secret']; + yield 'percent-encoded parent' => ['%2e%2e%2fsecret']; + yield 'internal parent escape' => ['a/../../secret']; + yield 'bare dotdot' => ['..']; + yield 'trailing parent collapses to empty root' => ['foo/..']; + yield 'nul byte' => ["a\0b"]; + } + + public function testPreviewIdValidation(): void { + self::assertTrue(PreviewPolicy::isValidPreviewId('3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506')); + self::assertFalse(PreviewPolicy::isValidPreviewId('not-a-uuid')); + self::assertFalse(PreviewPolicy::isValidPreviewId('3F2A1B4C-5D6E-4F70-8A90-B1C2D3E4F506')); + self::assertFalse(PreviewPolicy::isValidPreviewId('../../etc/passwd')); + } + + public function testAssetKeyValidation(): void { + self::assertTrue(PreviewPolicy::isValidAssetKey('aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57')); + self::assertFalse(PreviewPolicy::isValidAssetKey('too-short@9c41')); + self::assertFalse(PreviewPolicy::isValidAssetKey('aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@NOTHEX!!')); + self::assertFalse(PreviewPolicy::isValidAssetKey('no-at-sign-1234567890123456789012')); + } +} diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php new file mode 100644 index 0000000..4b7cefd --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -0,0 +1,260 @@ +root = sys_get_temp_dir() . '/exe-srv-' . bin2hex(random_bytes(6)); + $this->distRoot = sys_get_temp_dir() . '/exe-srv-dist-' . bin2hex(random_bytes(6)); + mkdir($this->distRoot . '/bundles', 0770, true); + mkdir($this->distRoot . '/base', 0770, true); + file_put_contents($this->distRoot . '/base/jquery.min.js', 'window.jQuery=function(){};'); + file_put_contents( + $this->distRoot . '/base/icon.svg', + '', + ); + file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', json_encode([ + 'resources' => [ + 'libs/jquery/jquery.min.js' => ['path' => 'base/jquery.min.js'], + 'theme:base/icon.svg' => ['path' => 'base/icon.svg'], + ], + ])); + $this->fixed = new FixedResourceManifest($this->distRoot); + $this->store = new PreviewSessionStore($this->root, new PreviewSessionLimits()); + + $this->previewId = $this->store->create('alice'); + $this->store->storeAssets($this->previewId, [ + ['key' => self::PHOTO_KEY, 'declaredSize' => 14, 'bytes' => 'PHOTO-BYTES-v1'], + ['key' => self::CLIP_KEY, 'declaredSize' => 10, 'bytes' => '0123456789'], + ]); + $this->store->applyRevision($this->previewId, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [ + ['path' => 'index.html', 'bytes' => 'hi'], + ['path' => 'img/inline.svg', 'bytes' => ''], + ], + 'deletes' => [], + 'assetRefs' => [ + 'content/resources/photo.png' => self::PHOTO_KEY, + 'media/clip.mp4' => self::CLIP_KEY, + ], + 'fixedRefs' => [ + 'libs/jquery/jquery.min.js' => 'libs/jquery/jquery.min.js', + 'theme/icon.svg' => 'theme:base/icon.svg', + ], + ], $this->fixed); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + $this->removeTree($this->distRoot); + } + + private function server(): PreviewServer { + return new PreviewServer($this->store, $this->fixed); + } + + private function assertBaseHardening(array $headers): void { + self::assertSame('nosniff', $headers['X-Content-Type-Options']); + self::assertSame('no-referrer', $headers['Referrer-Policy']); + self::assertSame('camera=(), microphone=(), geolocation=(), payment=()', $headers['Permissions-Policy']); + self::assertSame('*', $headers['Access-Control-Allow-Origin']); + } + + public function testServeDocument(): void { + $response = $this->server()->serve($this->previewId, 'index.html'); + self::assertSame(200, $response->status); + self::assertSame('hi', $response->body); + self::assertSame('text/html; charset=utf-8', $response->headers['Content-Type']); + self::assertSame('no-store', $response->headers['Cache-Control']); + self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']); + $this->assertBaseHardening($response->headers); + } + + public function testServeAssetHasEtagNoCacheAndNoCsp(): void { + $response = $this->server()->serve($this->previewId, 'content/resources/photo.png'); + self::assertSame(200, $response->status); + self::assertSame('PHOTO-BYTES-v1', $response->body); + self::assertSame('image/png', $response->headers['Content-Type']); + self::assertSame('no-cache', $response->headers['Cache-Control']); + self::assertSame('"' . self::PHOTO_KEY . '"', $response->headers['ETag']); + self::assertSame('bytes', $response->headers['Accept-Ranges']); + self::assertArrayNotHasKey('Content-Security-Policy', $response->headers); + } + + public function testConditionalRequestReturns304(): void { + $response = $this->server()->serve( + $this->previewId, + 'content/resources/photo.png', + '"' . self::PHOTO_KEY . '"', + ); + self::assertSame(304, $response->status); + self::assertSame('', $response->body); + self::assertSame('nosniff', $response->headers['X-Content-Type-Options']); + self::assertSame('"' . self::PHOTO_KEY . '"', $response->headers['ETag']); + } + + public function testSingleRangeReturns206(): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=2-4'); + self::assertSame(206, $response->status); + self::assertSame('234', $response->body); + self::assertSame('bytes 2-4/10', $response->headers['Content-Range']); + self::assertSame('3', $response->headers['Content-Length']); + } + + /** + * 416 is reserved for a valid single range that is unsatisfiable: a + * first-byte-pos at/after EOF (`bytes=99-`) or a zero-length suffix + * (`bytes=-0`). + * + * @dataProvider unsatisfiableRangeProvider + */ + public function testUnsatisfiableRangeReturns416(string $range): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, $range); + self::assertSame(416, $response->status, $range . ' must be 416'); + self::assertSame('bytes */10', $response->headers['Content-Range']); + } + + /** @return array */ + public static function unsatisfiableRangeProvider(): array { + return [ + 'first-byte-pos past EOF' => ['bytes=99-'], + 'zero-length suffix' => ['bytes=-0'], + ]; + } + + /** + * A malformed, multi-range or non-bytes Range header is IGNORED — the server + * serves a normal 200 full body, never 416. (The previous behaviour of 416 + * on any parse failure was wrong per the serving contract.) + * + * @dataProvider ignoredRangeProvider + */ + public function testIgnoredRangeServesFull200(string $range): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, $range); + self::assertSame(200, $response->status, $range . ' must be ignored, not 416'); + self::assertSame('0123456789', $response->body); + self::assertSame('bytes', $response->headers['Accept-Ranges']); + self::assertSame('"' . self::CLIP_KEY . '"', $response->headers['ETag']); + self::assertArrayNotHasKey('Content-Range', $response->headers); + } + + /** @return array */ + public static function ignoredRangeProvider(): array { + return [ + 'non-numeric' => ['bytes=abc'], + 'multi-range' => ['bytes=0-1,2-3'], + 'non-bytes unit' => ['items=0-1'], + 'empty range' => ['bytes=-'], + 'garbage' => ['not-a-range'], + 'double dash' => ['bytes=1-2-3'], + // last-byte-pos < first-byte-pos is an invalid spec (RFC 9110 + // §14.1.2) → ignore the header, not 416. + 'last before first' => ['bytes=5-2'], + ]; + } + + public function testBareRootRedirectsToIndexHtml(): void { + $response = $this->server()->serveRoot($this->previewId); + self::assertSame(302, $response->status); + // Relative Location so it stays correct under any webroot: it resolves + // against the request URI `.../preview/{previewId}`. + self::assertSame($this->previewId . '/index.html', $response->headers['Location']); + self::assertSame('no-store', $response->headers['Cache-Control']); + $this->assertBaseHardening($response->headers); + } + + public function testBareRootInvalidPreviewIdIs404(): void { + $response = $this->server()->serveRoot('not-a-uuid'); + self::assertSame(404, $response->status); + self::assertArrayNotHasKey('Location', $response->headers); + $this->assertBaseHardening($response->headers); + } + + public function testServeFixedResourceIsAggressivelyCacheable(): void { + $response = $this->server()->serve($this->previewId, 'libs/jquery/jquery.min.js'); + self::assertSame(200, $response->status); + self::assertSame('window.jQuery=function(){};', $response->body); + self::assertSame('private, max-age=31536000', $response->headers['Cache-Control']); + self::assertSame('*', $response->headers['Access-Control-Allow-Origin']); + } + + public function testScriptableSvgFromFixedLayerCarriesCsp(): void { + $response = $this->server()->serve($this->previewId, 'theme/icon.svg'); + self::assertSame(200, $response->status); + self::assertSame('image/svg+xml; charset=utf-8', $response->headers['Content-Type']); + self::assertSame('private, max-age=31536000', $response->headers['Cache-Control']); + self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']); + } + + public function testScriptableSvgFromSessionLayerCarriesCsp(): void { + $response = $this->server()->serve($this->previewId, 'img/inline.svg'); + self::assertSame(200, $response->status); + self::assertSame('image/svg+xml; charset=utf-8', $response->headers['Content-Type']); + self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']); + } + + public function testUnknownPathIs404WithHardening(): void { + $response = $this->server()->serve($this->previewId, 'nope.css'); + self::assertSame(404, $response->status); + self::assertSame('no-store', $response->headers['Cache-Control']); + $this->assertBaseHardening($response->headers); + } + + public function testEncodedTraversalIs404(): void { + $response = $this->server()->serve($this->previewId, '%2e%2e%2fsecret'); + self::assertSame(404, $response->status); + self::assertSame('no-store', $response->headers['Cache-Control']); + } + + public function testInvalidPreviewIdIs404(): void { + $response = $this->server()->serve('not-a-uuid', 'index.html'); + self::assertSame(404, $response->status); + $this->assertBaseHardening($response->headers); + } + + public function testDeletedSessionStopsServing(): void { + $this->store->delete($this->previewId); + $response = $this->server()->serve($this->previewId, 'index.html'); + self::assertSame(404, $response->status); + self::assertSame('no-store', $response->headers['Cache-Control']); + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/Unit/Service/Preview/PreviewSessionApiTest.php b/tests/Unit/Service/Preview/PreviewSessionApiTest.php new file mode 100644 index 0000000..9a01681 --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewSessionApiTest.php @@ -0,0 +1,151 @@ +root = sys_get_temp_dir() . '/exe-api-' . bin2hex(random_bytes(6)); + $this->store = new PreviewSessionStore($this->root, new PreviewSessionLimits()); + $this->api = new PreviewSessionApi($this->store, new FixedResourceManifest($this->root . '/absent')); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + } + + public function testCreateNegotiatesProtocolV2WithLimits(): void { + $result = $this->api->create('alice'); + self::assertSame(201, $result->status); + self::assertSame(2, $result->body['protocolVersion']); + self::assertSame(0, $result->body['revision']); + self::assertTrue(PreviewSessionApi::PROTOCOL_VERSION === 2); + self::assertArrayHasKey('previewId', $result->body); + self::assertArrayHasKey('recommendedBatchBytes', $result->body['limits']); + self::assertSame(5000, $result->body['limits']['maxFilesPerSession']); + } + + public function testMissingSessionIs404(): void { + $absent = '3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506'; + self::assertSame(404, $this->api->uploadAssets($absent, 'alice', [])->status); + self::assertSame(404, $this->api->delete($absent, 'alice')->status); + self::assertSame(404, $this->api->publishRevision($absent, 'alice', $this->emptyMeta())->status); + } + + public function testForeignOwnerIs403(): void { + $id = $this->api->create('alice')->body['previewId']; + self::assertSame(403, $this->api->uploadAssets($id, 'bob', [])->status); + self::assertSame(403, $this->api->delete($id, 'bob')->status); + self::assertSame(403, $this->api->publishRevision($id, 'bob', $this->emptyMeta())->status); + } + + public function testUploadAssetsReturnsStoreResult(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->uploadAssets($id, 'alice', [ + ['key' => self::KEY, 'declaredSize' => 5, 'bytes' => 'PHOTO'], + ]); + self::assertSame(200, $result->status); + self::assertSame([self::KEY], $result->body['stored']); + self::assertSame([], $result->body['alreadyStored']); + self::assertSame([], $result->body['rejected']); + } + + public function testPublishRevisionSuccessBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'x']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ]); + self::assertSame(200, $result->status); + self::assertSame(['revision' => 1, 'active' => true], $result->body); + } + + public function testRevisionConflictBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'v1']], + 'deletes' => [], 'assetRefs' => [], 'fixedRefs' => [], + ]); + $stale = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'stale']], + 'deletes' => [], 'assetRefs' => [], 'fixedRefs' => [], + ]); + self::assertSame(409, $stale->status); + self::assertSame(['reason' => 'revision-conflict', 'currentRevision' => 1], $stale->body); + } + + public function testMissingAssetBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $ghost = '99999999-9999-4999-8999-999999999999@deadbeef'; + $result = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, 'writes' => [], 'deletes' => [], + 'assetRefs' => ['content/ghost.png' => $ghost], 'fixedRefs' => [], + ]); + self::assertSame(422, $result->status); + self::assertSame(['reason' => 'missing-assets', 'missing' => [$ghost]], $result->body); + } + + public function testUnknownFixedBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, 'writes' => [], 'deletes' => [], + 'assetRefs' => [], 'fixedRefs' => ['theme/x.css' => 'theme:unknown'], + ]); + self::assertSame(422, $result->status); + self::assertSame(['reason' => 'unknown-fixed-resources', 'resources' => ['theme:unknown']], $result->body); + } + + public function testDeleteBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->delete($id, 'alice'); + self::assertSame(200, $result->status); + self::assertSame(['success' => true], $result->body); + self::assertFalse($this->store->exists($id)); + } + + /** @return array */ + private function emptyMeta(): array { + return [ + 'baseRevision' => 0, 'nextRevision' => 1, 'writes' => [], + 'deletes' => [], 'assetRefs' => [], 'fixedRefs' => [], + ]; + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/Unit/Service/Preview/PreviewSessionStoreTest.php b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php new file mode 100644 index 0000000..44902bd --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php @@ -0,0 +1,551 @@ +root = sys_get_temp_dir() . '/exe-store-' . bin2hex(random_bytes(6)); + $this->distRoot = sys_get_temp_dir() . '/exe-store-dist-' . bin2hex(random_bytes(6)); + mkdir($this->distRoot . '/bundles', 0770, true); + mkdir($this->distRoot . '/libs/jquery', 0770, true); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + $this->removeTree($this->distRoot); + } + + private function store(?PreviewSessionLimits $limits = null): PreviewSessionStore { + return new PreviewSessionStore( + $this->root, + $limits ?? new PreviewSessionLimits(), + fn (): int => $this->now, + ); + } + + /** Manifest with two fixed resources (a JS lib and a scriptable SVG). */ + private function fixedManifest(): FixedResourceManifest { + file_put_contents($this->distRoot . '/libs/jquery/jquery.min.js', 'window.jQuery=function(){};'); + file_put_contents( + $this->distRoot . '/libs/jquery/icon.svg', + '', + ); + file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', json_encode([ + 'resources' => [ + 'libs/jquery/jquery.min.js' => ['path' => 'libs/jquery/jquery.min.js'], + 'theme:base/icon.svg' => ['path' => 'libs/jquery/icon.svg'], + ], + ])); + return new FixedResourceManifest($this->distRoot); + } + + /** An empty (disabled) fixed layer. */ + private function noFixed(): FixedResourceManifest { + return new FixedResourceManifest($this->distRoot . '/absent'); + } + + private function asset(string $key, string $bytes): array { + return ['key' => $key, 'declaredSize' => strlen($bytes), 'bytes' => $bytes]; + } + + // -- Lifecycle ----------------------------------------------------------- + + public function testCreateAndOwnership(): void { + $store = $this->store(); + $id = $store->create('alice'); + + self::assertTrue($store->exists($id)); + self::assertSame('alice', $store->ownerOf($id)); + self::assertSame(0, $store->activeRevision($id)); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $id, + ); + } + + public function testDeleteRemovesSession(): void { + $store = $this->store(); + $id = $store->create('alice'); + self::assertTrue($store->delete($id)); + self::assertFalse($store->exists($id)); + self::assertFalse($store->delete($id)); + self::assertNull($store->ownerOf($id)); + } + + public function testPerUserSessionCapEvictsLeastRecentlyUsed(): void { + $store = $this->store(new PreviewSessionLimits(maxSessionsPerUser: 2)); + $this->now = 1000; + $first = $store->create('alice'); + $this->now = 1001; + $second = $store->create('alice'); + $this->now = 1002; + $third = $store->create('alice'); + + self::assertFalse($store->exists($first), 'oldest owned session evicted'); + self::assertTrue($store->exists($second)); + self::assertTrue($store->exists($third)); + // A different user is unaffected by alice's cap. + $bob = $store->create('bob'); + self::assertTrue($store->exists($bob)); + } + + // -- Assets -------------------------------------------------------------- + + public function testStoreAssetsAndImmutability(): void { + $store = $this->store(); + $id = $store->create('alice'); + $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + + $first = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES-v1')]); + self::assertSame([$key], $first['stored']); + self::assertSame([], $first['alreadyStored']); + + // Re-upload the SAME key with DIFFERENT bytes → alreadyStored, not replaced. + $second = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES-v2-DIFFERENT')]); + self::assertSame([], $second['stored']); + self::assertSame([$key], $second['alreadyStored']); + + // Publish a revision referencing it and prove the ORIGINAL bytes serve. + $this->publish($store, $id, 0, 1, [], ['content/photo.png' => $key], []); + $file = $store->resolve($id, 'content/photo.png', $this->noFixed()); + self::assertNotNull($file); + self::assertSame($key, $file['etag']); + self::assertSame('PHOTO-BYTES-v1', file_get_contents($file['filePath'])); + } + + public function testStoreAssetsRejections(): void { + $store = $this->store(new PreviewSessionLimits(maxAssetBytes: 8, maxBytesPerSession: 20)); + $id = $store->create('alice'); + $goodKey = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + $bigKey = 'bbbbbbbb-cccc-4ddd-8eee-ffff00001111@0011223344'; + + $result = $store->storeAssets($id, [ + ['key' => 'not-a-valid-key', 'declaredSize' => 3, 'bytes' => 'abc'], + ['key' => $goodKey, 'declaredSize' => 99, 'bytes' => 'abc'], + $this->asset($bigKey, 'TOO-LONG-BYTES'), + ]); + + $reasons = []; + foreach ($result['rejected'] as $entry) { + $reasons[$entry['key']] = $entry['reason']; + } + self::assertSame('invalid-key', $reasons['not-a-valid-key']); + self::assertSame('size-mismatch', $reasons[$goodKey]); + self::assertSame('asset-too-large', $reasons[$bigKey]); + self::assertSame([], $result['stored']); + } + + public function testBlobWriteFailureIsRejectedNotAlreadyStored(): void { + $store = $this->store(); + $id = $store->create('alice'); + $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + + // Force the underlying blob write to fail deterministically (and + // root-independently): replace the session's assets/ directory with a + // regular file, so any write under it fails with ENOTDIR. + $assetsPath = $this->root . '/' . $id . '/assets'; + $this->removeTree($assetsPath); + file_put_contents($assetsPath, 'not-a-directory'); + + $result = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES')]); + + // A write failure must be REJECTED, never stored/alreadyStored: reporting + // alreadyStored would make the client mark the key uploaded, stranding the + // asset as a permanent 404 that the missing-assets loop can never repair. + self::assertSame([], $result['stored']); + self::assertSame([], $result['alreadyStored']); + self::assertSame([['key' => $key, 'reason' => 'write-failed']], $result['rejected']); + + // The blob is genuinely absent... + self::assertFileDoesNotExist($assetsPath . '/' . sha1($key)); + + // ...so a revision referencing it returns 422 missing-assets (the client's + // recovery path then re-uploads it), instead of a silent permanent 404. + $revision = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [], + 'deletes' => [], + 'assetRefs' => ['content/photo.png' => $key], + 'fixedRefs' => [], + ], $this->noFixed()); + self::assertSame(422, $revision['status']); + self::assertSame('missing-assets', $revision['reason']); + self::assertSame([$key], $revision['missing']); + } + + // -- Revisions ----------------------------------------------------------- + + public function testPublishRevisionHappyPath(): void { + $store = $this->store(); + $fixed = $this->fixedManifest(); + $id = $store->create('alice'); + $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + $store->storeAssets($id, [$this->asset($key, 'PHOTO')]); + + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [ + ['path' => 'index.html', 'bytes' => 'hi'], + ['path' => 'img/inline.svg', 'bytes' => ''], + ], + 'deletes' => [], + 'assetRefs' => ['content/photo.png' => $key], + 'fixedRefs' => ['libs/jquery/jquery.min.js' => 'libs/jquery/jquery.min.js'], + ], $fixed); + + self::assertSame(200, $result['status']); + self::assertSame(1, $result['revision']); + self::assertSame(1, $store->activeRevision($id)); + + $doc = $store->resolve($id, 'index.html', $fixed); + self::assertSame('document', $doc['kind']); + self::assertSame('hi', $doc['bytes']); + + $fixedFile = $store->resolve($id, 'libs/jquery/jquery.min.js', $fixed); + self::assertSame('fixed', $fixedFile['kind']); + self::assertSame('window.jQuery=function(){};', $fixedFile['bytes']); + } + + public function testStaleBaseRevisionConflicts(): void { + $store = $this->store(); + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'v1']], [], []); + + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'stale']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + + self::assertSame(409, $result['status']); + self::assertSame(1, $result['currentRevision']); + // The active revision is untouched by the rejected apply (atomicity). + self::assertSame('v1', $store->resolve($id, 'index.html', $this->noFixed())['bytes']); + } + + public function testUnsafeWritePathRejected(): void { + $store = $this->store(); + $id = $store->create('alice'); + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [['path' => '../escape.html', 'bytes' => 'x']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + self::assertSame(400, $result['status']); + self::assertSame(0, $store->activeRevision($id)); + } + + public function testMissingAssetRefRejected(): void { + $store = $this->store(); + $id = $store->create('alice'); + $ghost = '99999999-9999-4999-8999-999999999999@deadbeef'; + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [], + 'deletes' => [], + 'assetRefs' => ['content/ghost.png' => $ghost], + 'fixedRefs' => [], + ], $this->noFixed()); + self::assertSame(422, $result['status']); + self::assertSame('missing-assets', $result['reason']); + self::assertSame([$ghost], $result['missing']); + } + + public function testUnknownFixedRefRejected(): void { + $store = $this->store(); + $id = $store->create('alice'); + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => ['theme/x.css' => 'theme:not-in-manifest'], + ], $this->noFixed()); + self::assertSame(422, $result['status']); + self::assertSame('unknown-fixed-resources', $result['reason']); + self::assertSame(['theme:not-in-manifest'], $result['resources']); + } + + public function testFileCountBudgetRejected(): void { + $store = $this->store(new PreviewSessionLimits(maxFilesPerSession: 1)); + $id = $store->create('alice'); + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [ + ['path' => 'a.html', 'bytes' => 'a'], + ['path' => 'b.html', 'bytes' => 'b'], + ], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + self::assertSame(413, $result['status']); + } + + public function testRevisionDocumentWriteFailureAbortsBeforePointerSwap(): void { + $store = $this->store(); + $id = $store->create('alice'); + // Establish revision 1. + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'v1']], [], []); + + // Force the next document blob write to fail deterministically: replace + // the documents/ directory with a regular file so any write under it + // fails with ENOTDIR. + $documentsPath = $this->root . '/' . $id . '/documents'; + $this->removeTree($documentsPath); + file_put_contents($documentsPath, 'not-a-directory'); + + $result = $store->applyRevision($id, [ + 'baseRevision' => 1, + 'nextRevision' => 2, + 'writes' => [['path' => 'index.html', 'bytes' => 'v2-NEW-UNIQUE-BYTES']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + + // The publish aborts with 500 and never advances the pointer, so the + // previously active revision stays intact (no silent 404 document). + self::assertSame(500, $result['status']); + self::assertSame(1, $store->activeRevision($id)); + self::assertFileDoesNotExist($this->root . '/' . $id . '/revisions/2.json'); + } + + public function testSupersededRevisionsArePrunedToBoundDisk(): void { + $store = $this->store(); + $id = $store->create('alice'); + // An asset that must survive every revision (shared/immutable across them). + $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + $store->storeAssets($id, [$this->asset($key, str_repeat('A', 4096))]); + + // Publish several revisions, each replacing index.html with UNIQUE content + // (unique hash => a new blob every time; content-addressing cannot mask the + // leak). Without pruning, physical disk grows with revision count even though + // the active-revision budget stays flat — the disk-fill DoS. + $docSize = 50000; + $revisions = 6; + for ($rev = 1; $rev <= $revisions; $rev++) { + $unique = str_repeat('x', $docSize) . '-rev-' . $rev; + $this->publish($store, $id, $rev - 1, $rev, [ + ['path' => 'index.html', 'bytes' => $unique], + ], ['content/photo.png' => $key], []); + } + + $sessionDir = $this->root . '/' . $id; + + // (a) Only the active revision's manifest and its single document blob remain. + $manifests = array_values(array_filter( + scandir($sessionDir . '/revisions') ?: [], + static fn (string $f): bool => preg_match('/^\d+\.json$/', $f) === 1, + )); + self::assertSame([$revisions . '.json'], $manifests, 'superseded revision manifests must be pruned'); + + $blobs = array_values(array_filter( + scandir($sessionDir . '/documents') ?: [], + static fn (string $f): bool => preg_match('/^[0-9a-f]{40}$/', $f) === 1, + )); + self::assertCount(1, $blobs, 'superseded document blobs must be pruned'); + + // The active revision still serves correctly after pruning. + $doc = $store->resolve($id, 'index.html', $this->noFixed()); + self::assertNotNull($doc); + self::assertSame($docSize + strlen('-rev-' . $revisions), strlen($doc['bytes'])); + + // (b) The asset persists across every revision. + $assetFile = $store->resolve($id, 'content/photo.png', $this->noFixed()); + self::assertNotNull($assetFile); + self::assertSame(str_repeat('A', 4096), file_get_contents($assetFile['filePath'])); + + // (c) On-disk bytes stay bounded (~one document + one asset), not revisions x docSize. + $onDisk = $this->diskBytes($sessionDir); + self::assertLessThan($store->limits()->maxBytesPerSession, $onDisk); + self::assertLessThan($docSize * 3, $onDisk, 'retained disk must not grow with revision count'); + } + + public function testIncrementalDeltaAndDeletes(): void { + $store = $this->store(); + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [ + ['path' => 'index.html', 'bytes' => 'home'], + ['path' => 'gone.html', 'bytes' => 'temp'], + ], [], []); + // Revision 2: rewrite index.html, delete gone.html. + $this->publish($store, $id, 1, 2, [['path' => 'index.html', 'bytes' => 'home-v2']], [], [], ['gone.html']); + + self::assertSame(2, $store->activeRevision($id)); + self::assertSame('home-v2', $store->resolve($id, 'index.html', $this->noFixed())['bytes']); + self::assertNull($store->resolve($id, 'gone.html', $this->noFixed())); + } + + // -- Resolution edge cases ---------------------------------------------- + + public function testResolveBeforeFirstRevisionIsNull(): void { + $store = $this->store(); + $id = $store->create('alice'); + self::assertNull($store->resolve($id, 'index.html', $this->noFixed())); + } + + public function testResolveUnknownPathIsNull(): void { + $store = $this->store(); + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'x']], [], []); + self::assertNull($store->resolve($id, 'nope.css', $this->noFixed())); + self::assertNull($store->resolve($id, '../escape', $this->noFixed())); + } + + public function testResolveOnMissingSessionIsNull(): void { + $store = $this->store(); + self::assertNull($store->resolve('3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506', 'index.html', $this->noFixed())); + } + + // -- TTL ----------------------------------------------------------------- + + public function testExpiredSessionIsDroppedOnAccessAndSwept(): void { + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'x']], [], []); + + // Within TTL: still served. + $this->now = 1050; + self::assertNotNull($store->resolve($id, 'index.html', $this->noFixed())); + + // Past TTL: opportunistically dropped on access. + $this->now = 2000; + self::assertNull($store->resolve($id, 'index.html', $this->noFixed())); + self::assertFalse($store->exists($id)); + } + + public function testSweepExpiredRemovesIdleSessions(): void { + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $stale = $store->create('alice'); + $this->now = 1900; + $fresh = $store->create('bob'); + + $this->now = 1950; // stale is 950s idle, fresh is 50s idle + self::assertSame(1, $store->sweepExpired()); + self::assertFalse($store->exists($stale)); + self::assertTrue($store->exists($fresh)); + } + + public function testMissingAccessedMarkerFallsBackToCreatedAtForTtl(): void { + // A lost `.accessed` marker (crash between touch and fsync, manual fs + // surgery, backup restore) must NOT make a session immortal: the age + // clock falls back to meta.json createdAt so the session still expires. + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => str_repeat('x', 128)]], [], []); + unlink($this->root . '/' . $id . '/.accessed'); + + // Within TTL of createdAt (1000): not yet expired. + $this->now = 1050; + self::assertSame(0, $store->sweepExpired()); + self::assertTrue($store->exists($id)); + + // Past TTL of createdAt: swept, and the directory is physically gone — so + // its bytes no longer contribute to the recomputed global byte budget. + $this->now = 1200; + self::assertSame(1, $store->sweepExpired()); + self::assertFalse($store->exists($id)); + self::assertDirectoryDoesNotExist($this->root . '/' . $id); + } + + public function testCorruptSessionDirectoryIsReclaimed(): void { + // A directory that looks like a session id but has neither `.accessed` + // nor a readable meta.json can never be owned or served — it must be + // reclaimed rather than lingering forever. + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $corruptId = '3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506'; + mkdir($this->root . '/' . $corruptId, 0770, true); + + self::assertTrue($store->isExpired($corruptId)); + self::assertSame(1, $store->sweepExpired()); + self::assertDirectoryDoesNotExist($this->root . '/' . $corruptId); + } + + // -- Helpers ------------------------------------------------------------- + + /** + * @param list $writes + * @param array $assetRefs + * @param array $fixedRefs + * @param list $deletes + */ + private function publish( + PreviewSessionStore $store, + string $id, + int $base, + int $next, + array $writes, + array $assetRefs, + array $fixedRefs, + array $deletes = [], + ): void { + $result = $store->applyRevision($id, [ + 'baseRevision' => $base, + 'nextRevision' => $next, + 'writes' => $writes, + 'deletes' => $deletes, + 'assetRefs' => $assetRefs, + 'fixedRefs' => $fixedRefs, + ], $this->noFixed()); + self::assertSame(200, $result['status'], 'publish helper expects success'); + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } + + /** Total physical bytes stored under a directory tree. */ + private function diskBytes(string $dir): int { + $total = 0; + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + $total += is_dir($path) ? $this->diskBytes($path) : (int)filesize($path); + } + return $total; + } +} diff --git a/tests/Unit/Service/ZipEntryServiceTest.php b/tests/Unit/Service/ZipEntryServiceTest.php index e0f792d..2bc43c7 100644 --- a/tests/Unit/Service/ZipEntryServiceTest.php +++ b/tests/Unit/Service/ZipEntryServiceTest.php @@ -44,4 +44,48 @@ public function testRejectsEmptyAndNulTaintedPaths(): void { self::assertNull($this->service->normalizeEntry('')); self::assertNull($this->service->normalizeEntry("a\0b")); } + + public function testReadEntryFallsBackToStreamWhenLocalPathCannotBeOpened(): void { + if (!class_exists('OCP\\Files\\File')) { + eval('namespace OCP\\Files; class File {}'); + } + + $archivePath = tempnam(sys_get_temp_dir(), 'elpx_test_'); + self::assertNotFalse($archivePath); + $zip = new \ZipArchive(); + self::assertTrue($zip->open($archivePath, \ZipArchive::OVERWRITE) === true); + $zip->addFromString('index.html', '

Playground

'); + $zip->close(); + $archive = file_get_contents($archivePath); + @unlink($archivePath); + self::assertIsString($archive); + + $file = new class($archive) extends \OCP\Files\File { + public function __construct( + private readonly string $archive, + ) { + } + + public function getStorage(): object { + return new class { + public function getLocalFile(string $path): string { + return '/virtual/php-wasm/package.elpx'; + } + }; + } + + public function getInternalPath(): string { + return 'files/package.elpx'; + } + + public function fopen(string $mode) { + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, $this->archive); + rewind($stream); + return $stream; + } + }; + + self::assertSame('

Playground

', $this->service->readEntry($file, 'index.html')); + } } diff --git a/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh new file mode 100755 index 0000000..2c6496a --- /dev/null +++ b/tests/e2e/preview-api-e2e.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# API-level end-to-end test of the editor-preview serving contract v2 against a +# REAL running Nextcloud (no browser). +# +# Authentication is HTTP Basic auth: it authenticates per request via the +# Authorization header and needs NO cookies, so it is reliable under the built-in +# php -S server (where the browser login form's SameSite/session cookies do not +# round-trip). CSRF is still exercised honestly, using Nextcloud's own rules +# (lib/private/AppFramework/Http/Request.php::passesCSRFCheck): +# +# * passesCSRFCheck() returns true when the OCS-APIRequest header is present, +# so the positive management calls send it (cookieless, no requesttoken). +# * Omitting that header on an AUTHENTICATED management POST leaves the CSRF +# check to fail (no token, no OCS-APIRequest) → the middleware answers 412 +# "CSRF check failed" — proving the route is NOT #[NoCSRFRequired]. That is +# the real CSRF-enforcement assertion. +# +# It asserts: CSRF enforcement (412), the create->asset->revision->serve +# round-trip, the authless opaque serving response (200 + sandbox CSP, no +# allow-same-origin), the bare-root 302, owner-scoping (403), dropped-part +# rejection (400) without advancing the revision, and 404 after delete. +# +# Full editor-iframe browser E2E (external video / interactive-video) stays +# blocked on a capable editor build and is documented as such, not faked here. +# +# Required environment: +# EXE_E2E_BASE base URL incl. the front controller, e.g. +# http://127.0.0.1:8080/index.php +# EXE_E2E_USER1 / EXE_E2E_PASS1 owner +# EXE_E2E_USER2 / EXE_E2E_PASS2 a second, non-owner user +set -euo pipefail + +BASE="${EXE_E2E_BASE:?EXE_E2E_BASE is required}" +# The host root (no front controller) for endpoints served as top-level scripts +# under php -S: the OCS entry point (/ocs/v2.php/...). +ROOT="${BASE%/index.php}" +TMP="${RUNNER_TEMP:-/tmp}" +U1="${EXE_E2E_USER1:?EXE_E2E_USER1 is required}" +P1="${EXE_E2E_PASS1:?EXE_E2E_PASS1 is required}" +U2="${EXE_E2E_USER2:?EXE_E2E_USER2 is required}" +P2="${EXE_E2E_PASS2:?EXE_E2E_PASS2 is required}" +MGMT="$BASE/apps/exelearning/api/preview-session" +SERVE="$BASE/apps/exelearning/preview" + +fail() { echo "E2E FAIL: $*" >&2; exit 1; } + +# Authenticated management request. The OCS-APIRequest header makes Nextcloud's +# passesCSRFCheck() pass without a session cookie or requesttoken (the reliable, +# cookieless way to drive an authenticated AppFramework route in CI). It does not +# change the response shape — these are plain (non-OCS) DataResponses. +api() { local u="$1" p="$2" m="$3" url="$4"; shift 4; curl -s -u "$u:$p" -H 'OCS-APIRequest: true' -X "$m" "$@" "$url"; } +api_code() { local u="$1" p="$2" m="$3" url="$4"; shift 4; curl -s -o /dev/null -w '%{http_code}' -u "$u:$p" -H 'OCS-APIRequest: true' -X "$m" "$@" "$url"; } + +# 0. Sanity — Basic auth actually authenticates user1 (so a later 412 is a real +# CSRF rejection, not an auth failure hiding behind it). +who="$(curl -s -u "$U1:$P1" -H 'OCS-APIRequest: true' "$ROOT/ocs/v2.php/cloud/user?format=json" | jq -r '.ocs.data.id // empty' 2>/dev/null || true)" +[ "$who" = "$U1" ] || fail "Basic auth did not authenticate $U1 (cloud/user id='$who')" +echo " ok: Basic auth authenticates $U1" + +# 1. CSRF enforced — an AUTHENTICATED management POST WITHOUT the OCS-APIRequest +# header (and no requesttoken) trips the CSRF middleware → 412. Proves the +# management route is not #[NoCSRFRequired]. The 412 happens in middleware, so +# no session is created. +csrf="$(curl -s -o "$TMP/csrf.txt" -w '%{http_code}' -u "$U1:$P1" -X POST "$MGMT")" +[ "$csrf" = "412" ] || fail "CSRF proof: expected 412 without the CSRF-safe header, got $csrf (body: $(head -c 200 "$TMP/csrf.txt"))" +grep -qi 'csrf' "$TMP/csrf.txt" || echo " note: 412 body did not explicitly mention CSRF: $(head -c 120 "$TMP/csrf.txt")" +echo " ok: management enforces CSRF (412 for an authenticated request without the CSRF-safe header/token)" + +# 2. Create → 201 { previewId, protocolVersion: 2 }. +resp="$(api "$U1" "$P1" POST "$MGMT")" +pid="$(printf '%s' "$resp" | jq -r '.previewId // empty')" +[ -n "$pid" ] || fail "create returned no previewId: $resp" +[ "$(printf '%s' "$resp" | jq -r '.protocolVersion')" = "2" ] || fail "protocolVersion != 2: $resp" +echo " ok: created session $pid" + +# 3. Upload an asset (multipart: assets JSON + index-aligned files[]). +KEY="aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8" +printf 'IMGBYTES!' > "$TMP/asset.bin" # 9 bytes +api "$U1" "$P1" POST "$MGMT/$pid/assets" \ + -F "assets=[{\"key\":\"$KEY\",\"size\":9}]" \ + -F "files[]=@$TMP/asset.bin" >/dev/null +echo " ok: uploaded asset" + +# 4. Publish revision 1. +printf 'preview-v1' > "$TMP/index.html" +resp="$(api "$U1" "$P1" POST "$MGMT/$pid/revisions" \ + -F "revision={\"baseRevision\":0,\"nextRevision\":1,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{\"content/photo.png\":\"$KEY\"},\"fixedRefs\":{}}" \ + -F "files[]=@$TMP/index.html")" +[ "$(printf '%s' "$resp" | jq -r '.revision')" = "1" ] || fail "publish revision != 1: $resp" +echo " ok: published revision 1" + +# 5. Serve (authless, NO auth): 200 + opaque sandbox CSP + no allow-same-origin. +hdrs="$(curl -s -D - -o "$TMP/served.html" "$SERVE/$pid/index.html")" +printf '%s' "$hdrs" | grep -q '^HTTP/[0-9.]* 200' || fail "serve not 200: $(printf '%s' "$hdrs" | head -1)" +csp="$(printf '%s' "$hdrs" | grep -i '^content-security-policy:' || true)" +printf '%s' "$csp" | grep -qi 'sandbox' || fail "serving CSP missing sandbox directive: $csp" +if printf '%s' "$csp" | grep -qi 'allow-same-origin'; then + fail "serving CSP must NOT contain allow-same-origin: $csp" +fi +grep -q 'preview-v1' "$TMP/served.html" || fail "served body missing revision content" +echo " ok: authless serve is 200 with opaque sandbox CSP (no allow-same-origin)" + +# 6. Bare capability root → 302 (never inline index.html bytes). +code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid")" +[ "$code" = "302" ] || fail "bare root expected 302, got $code" +echo " ok: bare root 302" + +# 7. Owner-scoping — user2 on user1's session → 403. +code="$(api_code "$U2" "$P2" DELETE "$MGMT/$pid")" +[ "$code" = "403" ] || fail "cross-user delete expected 403, got $code" +echo " ok: cross-user management is 403" + +# 8. Dropped multipart part — 1 write declared, 0 file parts → 400, revision unchanged. +code="$(api_code "$U1" "$P1" POST "$MGMT/$pid/revisions" \ + -F "revision={\"baseRevision\":1,\"nextRevision\":2,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{},\"fixedRefs\":{}}")" +[ "$code" = "400" ] || fail "dropped-part revision expected 400, got $code" +curl -s "$SERVE/$pid/index.html" | grep -q 'preview-v1' || fail "revision advanced despite dropped part" +echo " ok: dropped-part revision rejected (400), revision pointer unchanged" + +# 9. Owner DELETE → 200, then serve → 404. +code="$(api_code "$U1" "$P1" DELETE "$MGMT/$pid")" +[ "$code" = "200" ] || fail "owner delete expected 200, got $code" +code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid/index.html")" +[ "$code" = "404" ] || fail "serve after delete expected 404, got $code" +echo " ok: owner delete 200, serve after delete 404" + +echo "Preview API E2E: all checks passed." diff --git a/tests/fixtures/preview-contract/vectors.json b/tests/fixtures/preview-contract/vectors.json new file mode 100644 index 0000000..c98880d --- /dev/null +++ b/tests/fixtures/preview-contract/vectors.json @@ -0,0 +1,299 @@ +{ + "description": "Machine-readable conformance vectors for eXeLearning preview serving contract v2 (doc/development/preview-serving-contract.md). Replay every step, in order, against a host implementation. See README.md for harness semantics.", + "protocolVersion": 2, + "fixedResources": { + "libs/jquery/jquery.min.js": { + "path": "libs/jquery/jquery.min.js", + "content": "window.jQuery=function(){};" + }, + "theme:base/icon.svg": { + "path": "files/perm/themes/base/base/icon.svg", + "content": "" + } + }, + "constants": { + "photoKey": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "clipKey": "12345678-90ab-4cde-8f01-234567890abc@00112233" + }, + "steps": [ + { + "id": "create-session", + "request": { "method": "POST", "path": "/api/preview-session" }, + "expect": { + "status": 201, + "body": { "protocolVersion": 2, "revision": 0 } + } + }, + { + "id": "upload-assets", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/assets", + "body": { + "kind": "assets", + "entries": [ + { "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", "content": "PHOTO-BYTES-v1" }, + { "key": "12345678-90ab-4cde-8f01-234567890abc@00112233", "content": "0123456789" } + ] + } + }, + "expect": { + "status": 200, + "body": { + "stored": [ + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "12345678-90ab-4cde-8f01-234567890abc@00112233" + ], + "alreadyStored": [], + "rejected": [] + } + } + }, + { + "id": "reupload-asset-immutability", + "comment": "Same key, DIFFERENT bytes: reported alreadyStored, bytes NOT replaced (asserted later by serve-asset).", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/assets", + "body": { + "kind": "assets", + "entries": [ + { + "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "content": "PHOTO-BYTES-v2-DIFFERENT" + } + ] + } + }, + "expect": { + "status": 200, + "body": { + "stored": [], + "alreadyStored": ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57"], + "rejected": [] + } + } + }, + { + "id": "publish-revision-1", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 0, + "nextRevision": 1, + "deletes": [], + "assetRefs": { + "content/resources/photo.png": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "media/clip.mp4": "12345678-90ab-4cde-8f01-234567890abc@00112233" + }, + "fixedRefs": { + "libs/jquery/jquery.min.js": "libs/jquery/jquery.min.js", + "theme/icon.svg": "theme:base/icon.svg" + } + }, + "writes": [ + { "path": "index.html", "content": "conformance" }, + { + "path": "img/inline.svg", + "content": "" + } + ] + } + }, + "expect": { "status": 200, "body": { "revision": 1, "active": true } } + }, + { + "id": "serve-document", + "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, + "expect": { + "status": 200, + "bodyText": "conformance", + "headers": { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Access-Control-Allow-Origin": "*", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-asset", + "comment": "Bytes are the ORIGINAL upload — proves reupload-asset-immutability did not replace them.", + "request": { "method": "GET", "path": "/preview/{previewId}/content/resources/photo.png" }, + "expect": { + "status": 200, + "bodyText": "PHOTO-BYTES-v1", + "headers": { + "Content-Type": "image/png", + "Cache-Control": "no-cache", + "ETag": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"", + "Accept-Ranges": "bytes", + "Content-Security-Policy": { "absent": true } + } + } + }, + { + "id": "serve-asset-304", + "request": { + "method": "GET", + "path": "/preview/{previewId}/content/resources/photo.png", + "headers": { "If-None-Match": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"" } + }, + "expect": { + "status": 304, + "headers": { "X-Content-Type-Options": "nosniff" } + } + }, + { + "id": "serve-asset-range", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=2-4" } + }, + "expect": { + "status": 206, + "bodyText": "234", + "headers": { "Content-Range": "bytes 2-4/10", "Content-Length": "3" } + } + }, + { + "id": "serve-asset-range-unsatisfiable", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=99-" } + }, + "expect": { + "status": 416, + "headers": { "Content-Range": "bytes */10" } + } + }, + { + "id": "serve-fixed", + "request": { "method": "GET", "path": "/preview/{previewId}/libs/jquery/jquery.min.js" }, + "expect": { + "status": 200, + "bodyText": "window.jQuery=function(){};", + "headers": { + "Cache-Control": "private, max-age=31536000", + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*" + } + } + }, + { + "id": "serve-fixed-scriptable-svg", + "comment": "The sandbox CSP must be emitted on scriptable types from EVERY layer, fixed included.", + "request": { "method": "GET", "path": "/preview/{previewId}/theme/icon.svg" }, + "expect": { + "status": 200, + "headers": { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "private, max-age=31536000", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-session-scriptable-svg", + "request": { "method": "GET", "path": "/preview/{previewId}/img/inline.svg" }, + "expect": { + "status": 200, + "headers": { + "Content-Type": "image/svg+xml; charset=utf-8", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-unknown-404", + "request": { "method": "GET", "path": "/preview/{previewId}/nope.css" }, + "expect": { + "status": 404, + "headers": { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*" + } + } + }, + { + "id": "serve-traversal-raw", + "comment": "A literal ../ is normalized away by URL parsing before it reaches the server; whatever survives must still 404.", + "request": { "method": "GET", "path": "/preview/{previewId}/../secret" }, + "expect": { "status": 404 } + }, + { + "id": "serve-traversal-encoded", + "comment": "Percent-encoded traversal reaches the server verbatim and must be rejected by path normalization.", + "request": { "method": "GET", "path": "/preview/{previewId}/%2e%2e%2fsecret" }, + "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } + }, + { + "id": "conflicting-revision-409", + "comment": "Stale baseRevision (client believes 0, server is at 1).", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 0, + "nextRevision": 1, + "deletes": [], + "assetRefs": {}, + "fixedRefs": {} + }, + "writes": [{ "path": "index.html", "content": "stale" }] + } + }, + "expect": { + "status": 409, + "body": { "reason": "revision-conflict", "currentRevision": 1 } + } + }, + { + "id": "missing-asset-revision-422", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 1, + "nextRevision": 2, + "deletes": [], + "assetRefs": { + "content/resources/ghost.png": "99999999-9999-4999-8999-999999999999@deadbeef" + }, + "fixedRefs": {} + }, + "writes": [] + } + }, + "expect": { + "status": 422, + "body": { + "reason": "missing-assets", + "missing": ["99999999-9999-4999-8999-999999999999@deadbeef"] + } + } + }, + { + "id": "delete-session", + "request": { "method": "DELETE", "path": "/api/preview-session/{previewId}" }, + "expect": { "status": 200, "body": { "success": true } } + }, + { + "id": "serve-after-delete-404", + "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, + "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } + } + ] +} diff --git a/tests/js/exe-embed-relay.test.ts b/tests/js/exe-embed-relay.test.ts new file mode 100644 index 0000000..898b342 --- /dev/null +++ b/tests/js/exe-embed-relay.test.ts @@ -0,0 +1,122 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest' +// Side-effect import: the mirror attaches its API on window.exeEmbedRelay, +// exactly as relay-host.ts loads it in the browser. This exercises the raw +// eXe-core relay mirror (src/embed/exe_embed_relay.js) directly, not the +// relay-host.ts wrapper (which mocks the bridge). +import '../../src/embed/exe_embed_relay.js' + +interface RelayInstance { + onMessage: (event: { source: unknown; data: unknown }) => void + checkDrift: () => number + reflow: () => void + dispose: () => void +} + +interface EmbedRelayApi { + createRelay: (config: { mode: string }) => RelayInstance +} + +const relay = (window as unknown as { exeEmbedRelay: EmbedRelayApi }).exeEmbedRelay + +/** A full DOMRect from a box, so it can be assigned to getBoundingClientRect under strict TS. */ +function fakeRect(left: number, top: number, width: number, height: number): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}), + } +} + +describe('exe_embed_relay checkDrift (mirror of eXe core)', () => { + let iframe: HTMLIFrameElement + + beforeAll(() => { + // The relay overlays a real