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