summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-19 10:03:01 +0300
committerPaul Buetow <paul@buetow.org>2026-05-19 10:03:01 +0300
commit212e849475701d91d5f173c3638af540fab796dd (patch)
tree5cc6a4560c9e0785a1e6af7daf0482a7b1964933
parentb857fd43f21082eecd29a04693aa8411c2c1aaa7 (diff)
Round 4-7 tests: 7 LLM scenarios + 10 Playwright UI tests
LLM e2e scenarios (S19-S25): S19 — permissions matrix (viewer vs owner across two sets). Flags a design mismatch: viewer role currently permits tags/favorites/ notes via verifyAccess instead of verifyModifyAccess, contrary to the model.RoleViewer doc comment. Not fixed; documented. S20 — HTTP Range and HEAD on /stream, /download, /thumbnail. Flags no-ETag (cacheability gap) and locks in stdlib Range semantics (single, suffix, open, 416, multi-range). S21 — upload negatives (missing parts, bad extension, traversal, 404, 403, dedup collisions, 413 skip note). S22 — share expiry (sqlite UPDATE on expires_at, then verify 410 on all three /s/{token}/... routes) + 5-token uniqueness via crypto/rand audit. S23 — user deletion cascade with schema audit: every user FK has ON DELETE CASCADE; tags are global by design. S24 — soft-delete persistence across rescan. Surfaces TWO real bugs in scanner: (1) re-INSERT of soft-deleted media hits UNIQUE constraint and fails the scan; (2) files deleted from disk leave orphan media rows that never get reconciled. S25 — SQL injection + XSS + path-traversal probes. SQL surface fully parameterised (audited repository/media.go); XSS storage is API-correct (UI escapes); share path traversal blocked by Go ServeMux path cleaning. Playwright e2e-web extensions (Round 7, 10 new tests): share-page.test.ts (4) — share metadata payload, audio/video stage elements, invalid-token 404 page. search-filter.test.ts (3) — search filter, like:1 favourites syntax, clearing input restores full grid. admin-panel.test.ts (3) — user list, permissions section, rescan button + scan-progress UI. Round 8 audit (Android): 24/24 Flutter widget tests pass; the app is currently a stub with UnimplementedError-only API client, so no additional test scaffolding is justified until production code lands. Verified: 25/25 LLM scenarios pass; 22/22 Playwright tests pass; full Go unit-test suite green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
-rw-r--r--player-server/test/e2e-llm/scenarios/S19-permissions-matrix.md212
-rw-r--r--player-server/test/e2e-llm/scenarios/S20-range-head.md191
-rw-r--r--player-server/test/e2e-llm/scenarios/S21-upload-negatives.md247
-rw-r--r--player-server/test/e2e-llm/scenarios/S22-share-expiry-uniqueness.md152
-rw-r--r--player-server/test/e2e-llm/scenarios/S23-user-deletion-cascade.md186
-rw-r--r--player-server/test/e2e-llm/scenarios/S24-soft-delete-rescan.md186
-rw-r--r--player-server/test/e2e-llm/scenarios/S25-injection-probes.md260
-rw-r--r--player-server/test/e2e-web/tests/admin-panel.test.ts174
-rw-r--r--player-server/test/e2e-web/tests/search-filter.test.ts207
-rw-r--r--player-server/test/e2e-web/tests/share-page.test.ts164
10 files changed, 1979 insertions, 0 deletions
diff --git a/player-server/test/e2e-llm/scenarios/S19-permissions-matrix.md b/player-server/test/e2e-llm/scenarios/S19-permissions-matrix.md
new file mode 100644
index 0000000..95c8ca7
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S19-permissions-matrix.md
@@ -0,0 +1,212 @@
+---
+id: S19
+title: "Permissions matrix — viewer vs owner across two sets"
+tags: [admin, permissions, api, security, authorization]
+preconditions:
+ server_state: running # existing DB with admin account and at least two sets of distinct types
+ fixtures: []
+assertions:
+ - status_code: "GET /api/v1/sets 200"
+ - db: "SELECT count(*) FROM set_permissions"
+skip: false
+---
+
+# Purpose
+
+This scenario exercises the per-set permissions matrix end-to-end so the
+behaviour of `checkSetPermission`, `verifyAccess`, `verifyModifyAccess` and
+`browseService.ListSets` (see `internal/service/access.go` and
+`internal/service/browse.go`) cannot regress silently.
+
+Two non-admin users are created, each with a different role on a different
+set, and the scenario then asserts visibility, read access and modify access
+from each user's perspective. Mark anything that deviates from the assertions
+below as a real authorization defect.
+
+Note on viewer write surface: the codebase routes tag, favorite and note
+mutations through `verifyAccess` (NOT `verifyModifyAccess`). That means a
+viewer is currently permitted to add tags, favorites and notes on media they
+can see, even though `model.RoleViewer` is documented as "browsing and
+playback" only. The steps below capture the **actual** behaviour (200 for
+tag-add by a viewer). If this scenario later starts returning 403 there, the
+service has been tightened on purpose — update the step. If it ever flips
+back to 200 after a deliberate tightening, that is a regression.
+
+---
+
+## A) Admin setup
+
+1. Authenticate as the admin user: call `POST /api/v1/auth/login` with body
+ `{"username": "admin", "password": "TestPassw0rd!"}`. Confirm the response
+ is HTTP 200 and save the returned `session` cookie as `ADMIN_COOKIE`. All
+ admin operations below use `ADMIN_COOKIE`.
+
+2. Create the first non-admin user `U1`: call `POST /api/v1/admin/users` with
+ `ADMIN_COOKIE` and body
+ `{"username": "e2e-perm-u1", "password": "TestPassw0rd!", "is_admin": false}`.
+ Confirm the response is HTTP 200 and the returned JSON has a non-zero `id`,
+ `username` equal to `e2e-perm-u1`, and `is_admin` equal to `false`. Save
+ the `id` as `U1_ID`.
+
+3. Create the second non-admin user `U2`: call `POST /api/v1/admin/users` with
+ `ADMIN_COOKIE` and body
+ `{"username": "e2e-perm-u2", "password": "TestPassw0rd!", "is_admin": false}`.
+ Confirm the response is HTTP 200 and the returned JSON has a non-zero `id`,
+ `username` equal to `e2e-perm-u2`, and `is_admin` equal to `false`. Save
+ the `id` as `U2_ID`.
+
+4. As admin, list sets: call `GET /api/v1/sets` with `ADMIN_COOKIE`. Confirm
+ the response is HTTP 200 and the returned array contains at least two
+ distinct sets. Pick two sets with different `type` values if possible (for
+ example one of type `audiobook` and one of type `image`). Save the chosen
+ sets as `SET_A` (id `SET_A_ID`) and `SET_B` (id `SET_B_ID`). They must
+ satisfy `SET_A_ID != SET_B_ID`. If fewer than two sets exist, abort the
+ scenario with a clear message — the test environment is under-seeded.
+
+5. Pick one active media item from each set for later assertions. As admin
+ call `GET /api/v1/sets/{SET_A_ID}/browse` with `ADMIN_COOKIE`. Confirm the
+ response is HTTP 200 and pick any media entry; save its id as
+ `MEDIA_A_ID`. Do the same for `SET_B`: call
+ `GET /api/v1/sets/{SET_B_ID}/browse`, confirm HTTP 200, and save a media
+ id as `MEDIA_B_ID`. If either set has zero media items, pick a different
+ pair of sets in step 4.
+
+## B) Grant permissions
+
+6. Grant `U1` the `viewer` role on `SET_A`: call
+ `POST /api/v1/admin/permissions` with `ADMIN_COOKIE` and body
+ `{"user_id": <U1_ID>, "set_id": <SET_A_ID>, "role": "viewer"}`. Confirm the
+ response is HTTP 200 and the returned JSON body is `{"status": "ok"}`. `U1`
+ now has read-only access to `SET_A` and no access to `SET_B`.
+
+7. Grant `U2` the `owner` role on `SET_B`: call
+ `POST /api/v1/admin/permissions` with `ADMIN_COOKIE` and body
+ `{"user_id": <U2_ID>, "set_id": <SET_B_ID>, "role": "owner"}`. Confirm the
+ response is HTTP 200 and the returned JSON body is `{"status": "ok"}`.
+ `U2` now has full modify access to `SET_B` and no access to `SET_A`.
+
+8. Verify the matrix: call `GET /api/v1/admin/permissions` with
+ `ADMIN_COOKIE`. Confirm the response is HTTP 200 and the `permissions`
+ array contains both grants — one entry with
+ `{user_id: U1_ID, set_id: SET_A_ID, role: "viewer"}` and one with
+ `{user_id: U2_ID, set_id: SET_B_ID, role: "owner"}`.
+
+## C) U1 (viewer on SET_A) — visibility and read access
+
+9. Log in as `U1`: call `POST /api/v1/auth/login` with body
+ `{"username": "e2e-perm-u1", "password": "TestPassw0rd!"}` and no prior
+ cookie. Confirm the response is HTTP 200 and save the returned `session`
+ cookie as `U1_COOKIE`. Do NOT mix `U1_COOKIE` with `ADMIN_COOKIE`.
+
+10. As `U1`, list sets: call `GET /api/v1/sets` with `U1_COOKIE`. Confirm the
+ response is HTTP 200. Expected behaviour (per `browseService.ListSets`):
+ a non-admin user sees ONLY sets where they have an explicit permission
+ entry. The returned array MUST contain an entry with `id == SET_A_ID` and
+ MUST NOT contain any entry with `id == SET_B_ID`. If `SET_B` appears in
+ the list, flag this as an authorization leak — the codebase filters by
+ `ListPermissionsByUser` and never marks sets as "forbidden but visible".
+
+11. As `U1`, browse `SET_A`: call `GET /api/v1/sets/{SET_A_ID}/browse` with
+ `U1_COOKIE`. Confirm the response is HTTP 200 and the body contains a
+ media list including `MEDIA_A_ID`.
+
+12. As `U1`, browse `SET_B`: call `GET /api/v1/sets/{SET_B_ID}/browse` with
+ `U1_COOKIE`. Confirm the response is HTTP 403 — `checkSetPermission`
+ returns `ErrForbidden` for a non-admin without an entry, and `handleError`
+ maps that to 403.
+
+13. As `U1`, fetch a media item in the forbidden set: call
+ `GET /api/v1/media/{MEDIA_B_ID}` with `U1_COOKIE`. Confirm the response
+ is HTTP 403. Reasoning: `verifyAccess` finds the media, then calls
+ `checkSetPermission` which returns `ErrForbidden`; the handler maps that
+ to 403. If the response is 404 instead, that means the media was either
+ soft-deleted between steps or the access helper hid existence on
+ purpose — note which it is and flag the divergence from `verifyAccess`.
+
+14. As `U1`, fetch a media item in the permitted set: call
+ `GET /api/v1/media/{MEDIA_A_ID}` with `U1_COOKIE`. Confirm the response
+ is HTTP 200 and the JSON has an `id` equal to `MEDIA_A_ID`.
+
+## D) U1 — viewer write surface (verify actual behaviour)
+
+15. As `U1`, add a tag to a media item in `SET_A`: call
+ `POST /api/v1/media/{MEDIA_A_ID}/tags` with `U1_COOKIE`,
+ `Content-Type: application/json` and body `{"tag": "e2e-perm-test"}`.
+ Expected behaviour (per `tagService.AssignTag` which calls
+ `verifyAccess`, not `verifyModifyAccess`): HTTP 200. If the response is
+ 403, the service has been tightened to require owner role for tag
+ mutations — note this in the run output. If it is 200, leave the tag in
+ place for now; step 21 cleans it up.
+
+16. As `U1`, mark the media item as a favorite: call
+ `POST /api/v1/media/{MEDIA_A_ID}/favorite` with `U1_COOKIE` and an
+ appropriate JSON body if the handler requires one. Expected behaviour
+ (per `favService` which uses `verifyAccess`): HTTP 200. Flag any 403 as
+ a tightening of viewer privileges.
+
+17. As `U1`, upsert a note on the media item: call
+ `POST /api/v1/media/{MEDIA_A_ID}/notes` with `U1_COOKIE`,
+ `Content-Type: application/json` and body `{"text": "viewer note"}`.
+ Expected behaviour (per `noteService.UpsertNote` which uses
+ `verifyAccess`): HTTP 200. Flag any 403 as a tightening of viewer
+ privileges.
+
+18. As `U1`, attempt to soft-delete the media item in `SET_A`: call
+ `DELETE /api/v1/media/{MEDIA_A_ID}` with `U1_COOKIE`. Confirm the
+ response is HTTP 403. Reasoning: `writeService.SoftDeleteMedia` calls
+ `verifyModifyAccess`, which requires `RoleOwner` (or admin), and `U1`
+ is a viewer.
+
+## E) U2 (owner on SET_B) — modify access
+
+19. Log in as `U2`: call `POST /api/v1/auth/login` with body
+ `{"username": "e2e-perm-u2", "password": "TestPassw0rd!"}` and no prior
+ cookie. Confirm the response is HTTP 200 and save the returned `session`
+ cookie as `U2_COOKIE`.
+
+20. As `U2`, soft-delete the media item in `SET_B`: call
+ `DELETE /api/v1/media/{MEDIA_B_ID}` with `U2_COOKIE`. Confirm the
+ response is HTTP 200. Reasoning: `verifyModifyAccess` accepts an owner
+ role grant.
+
+21. As `U2`, attempt to soft-delete the media item in `SET_A` (no
+ permission): call `DELETE /api/v1/media/{MEDIA_A_ID}` with `U2_COOKIE`.
+ Confirm the response is HTTP 403. Reasoning: `verifyAccess` is checked
+ first and `U2` has no permission on `SET_A`, so `checkSetPermission`
+ returns `ErrForbidden`.
+
+22. As `U2`, attempt to browse `SET_A`: call
+ `GET /api/v1/sets/{SET_A_ID}/browse` with `U2_COOKIE`. Confirm the
+ response is HTTP 403.
+
+## F) Cleanup
+
+23. As admin, restore the media item deleted in step 20 so the test database
+ is unchanged after this scenario: call
+ `POST /api/v1/media/{MEDIA_B_ID}/restore` with `ADMIN_COOKIE`. Confirm
+ the response is HTTP 200.
+
+24. As admin, remove the tag added by `U1` in step 15 (only if step 15
+ returned HTTP 200): call
+ `DELETE /api/v1/media/{MEDIA_A_ID}/tags/e2e-perm-test` with
+ `ADMIN_COOKIE`. Confirm the response is HTTP 200. If step 15 returned
+ 403 (viewer not allowed to tag), skip this step.
+
+25. As admin, revoke `U1`'s grant on `SET_A`: call
+ `DELETE /api/v1/admin/permissions` with `ADMIN_COOKIE` and body
+ `{"user_id": <U1_ID>, "set_id": <SET_A_ID>}`. Confirm the response is
+ HTTP 200 and the body is `{"status": "ok"}`.
+
+26. As admin, revoke `U2`'s grant on `SET_B`: call
+ `DELETE /api/v1/admin/permissions` with `ADMIN_COOKIE` and body
+ `{"user_id": <U2_ID>, "set_id": <SET_B_ID>}`. Confirm the response is
+ HTTP 200 and the body is `{"status": "ok"}`.
+
+27. As admin, delete `U1`: call `DELETE /api/v1/admin/users/{U1_ID}` with
+ `ADMIN_COOKIE`. Confirm the response is HTTP 200 and the body is
+ `{"status": "ok"}`.
+
+28. As admin, delete `U2`: call `DELETE /api/v1/admin/users/{U2_ID}` with
+ `ADMIN_COOKIE`. Confirm the response is HTTP 200 and the body is
+ `{"status": "ok"}`. Do NOT skip cleanup — leftover users and grants
+ will pollute subsequent runs of S12, S13, S15 and re-runs of S19.
diff --git a/player-server/test/e2e-llm/scenarios/S20-range-head.md b/player-server/test/e2e-llm/scenarios/S20-range-head.md
new file mode 100644
index 0000000..e9364f1
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S20-range-head.md
@@ -0,0 +1,191 @@
+---
+id: S20
+title: "HTTP Range and HEAD requests for media stream, download and thumbnail"
+tags: [media, api, streaming, range, head, http]
+preconditions:
+ server_state: running # server running with admin account and at least one audio media item
+ fixtures: []
+assertions:
+ - status_code: "GET /api/v1/media 200"
+ - status_code: "HEAD /api/v1/media/{id}/stream 200"
+ - status_code: "GET /api/v1/media/{id}/stream 206"
+skip: false
+---
+
+# Purpose
+
+This scenario locks in HTTP-level Range and HEAD semantics for the three file
+endpoints (`/stream`, `/download`, `/thumbnail`). Correct Range handling is
+mandatory for iOS audio playback — Safari issues a `Range: bytes=0-1` probe
+before every audio element, and a server that returns 200 instead of 206, or
+omits `Accept-Ranges`, causes silent playback failure.
+
+# Server-side notes
+
+The relevant handler is `serveFileResult` in
+`player-server/internal/api/handlers.go`. For the non-remuxed (direct file)
+path, it does the following before calling `http.ServeContent`:
+
+- Sets `Content-Type` from the file extension via `mediatype.MIMETypeForExt`.
+- Sets `Accept-Ranges: bytes` explicitly.
+- For the `/download` path only, sets
+ `Content-Disposition: attachment; filename="..."`.
+- For `/thumbnail` it also sets `Cache-Control: no-cache`.
+
+It then calls `http.ServeContent(w, r, fileName, modTime, file)` which, per
+the Go standard library:
+
+- Honors the request method — `HEAD` returns headers (including
+ `Content-Length`) and no body, with status 200.
+- Sets `Last-Modified` from `stat.ModTime()`.
+- Parses the `Range` header. A valid single range returns 206 with
+ `Content-Range: bytes <start>-<end>/<size>` and a body of exactly
+ `end - start + 1` bytes.
+- Multi-range requests (e.g. `bytes=0-99,100-199`) return 206 with
+ `Content-Type: multipart/byteranges; boundary=...` and the requested
+ ranges concatenated as MIME parts.
+- An entirely unsatisfiable Range (start past EOF) returns 416 with
+ `Content-Range: bytes */<size>` and an empty body.
+- A syntactically malformed `Range` header (e.g. `bytes=garbage`, no `=`,
+ no digits) is treated as "no Range" — the server returns the full body
+ with status 200, NOT 416. This matches RFC 7233 §3.1.
+- `If-Modified-Since` matching the `Last-Modified` returns 304. The handler
+ does NOT set an explicit `ETag`, so `If-None-Match` cannot match and the
+ full body is returned with 200.
+
+If any of those server-side facts have changed when this scenario runs,
+flag it: every one of them affects iOS / podcast-app playback.
+
+---
+
+1. Authenticate as an admin user: call `POST /api/v1/auth/login` with body
+ `{"username": "admin", "password": "TestPassw0rd!"}`. Confirm the response
+ is HTTP 200 and save the `session` cookie returned in the response for all
+ subsequent authenticated requests.
+
+2. Find an audio media item: call `GET /api/v1/media?type=audio&limit=1` with
+ the session cookie. Confirm the response is HTTP 200 and the JSON body
+ contains at least one media object whose `type` field is `audio`. Save the
+ `id` of the first item as `media_id`. If the response is empty, fail with a
+ note that `MEDIA_ROOT` does not contain any audio fixtures — the rest of
+ the scenario cannot run.
+
+3. HEAD the stream: issue `HEAD /api/v1/media/{media_id}/stream` with the
+ session cookie. Confirm the response is HTTP 200, the `Content-Length`
+ response header is present and parses as an integer greater than zero,
+ the `Accept-Ranges` response header equals exactly `bytes`, the
+ `Content-Type` response header starts with `audio/`, and the response
+ body is empty (zero bytes). The presence of `Accept-Ranges: bytes` is
+ load-bearing: iOS Safari refuses to play media without it.
+
+4. HEAD the download: issue `HEAD /api/v1/media/{media_id}/download` with the
+ session cookie. Confirm the response is HTTP 200, the
+ `Content-Disposition` response header is present and contains the
+ substring `attachment` (typically `attachment; filename="..."`), the
+ `Content-Length` response header is present, and the response body is
+ empty. Save the integer value of `Content-Length` as `file_size` — it is
+ used in step 9 to construct an out-of-range request.
+
+5. HEAD the thumbnail: issue `HEAD /api/v1/media/{media_id}/thumbnail` with
+ the session cookie. Confirm the response is HTTP 200, the `Content-Type`
+ response header starts with `image/` (e.g. `image/jpeg`, `image/png`,
+ `image/webp`), the `Cache-Control` response header equals `no-cache`,
+ and the response body is empty.
+
+6. GET stream with a 100-byte head range: issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=0-99` request header. Confirm the response is
+ HTTP 206 (Partial Content), the `Content-Range` response header is
+ present and matches the pattern `bytes 0-99/<file_size>`, the
+ `Content-Length` response header equals `100`, and the response body is
+ exactly 100 bytes long. Save the body bytes as `head_bytes` for the next
+ step's offset cross-check.
+
+7. GET stream with a 100-byte mid range: issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=100-199` request header. Confirm the response is
+ HTTP 206, the `Content-Range` response header matches
+ `bytes 100-199/<file_size>`, the `Content-Length` response header equals
+ `100`, and the response body is exactly 100 bytes long and is NOT equal
+ to the `head_bytes` saved in step 6 (otherwise the server is returning
+ the file head regardless of the requested offset — a Range bug).
+
+8. GET stream with a suffix range (last 100 bytes): issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=-100` request header. Confirm the response is
+ HTTP 206, the `Content-Range` response header ends with
+ `/<file_size>` and the start offset equals `file_size - 100`
+ (i.e. matches `bytes <file_size-100>-<file_size-1>/<file_size>`), and
+ the response body is exactly 100 bytes long.
+
+9. GET stream with an open-ended range from offset 100: issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=100-` request header. Confirm the response is
+ HTTP 206, the `Content-Range` response header matches
+ `bytes 100-<file_size-1>/<file_size>`, and the response body length
+ equals `file_size - 100`.
+
+10. GET stream with a Range start past EOF: issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=999999999-` request header. Confirm the
+ response is HTTP 416 (Range Not Satisfiable) and the `Content-Range`
+ response header is present and equals `bytes */<file_size>`. This
+ proves Go's `http.ServeContent` rejects unsatisfiable ranges instead
+ of silently truncating.
+
+11. GET stream with a malformed Range header: issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=garbage` request header. Per RFC 7233 §3.1
+ and the Go stdlib implementation, an unparseable Range is treated as
+ "no Range" — confirm the response is HTTP 200, the `Content-Length`
+ response header equals the full `file_size`, the `Accept-Ranges`
+ response header equals `bytes`, and no `Content-Range` response
+ header is present. If the server returns 416 here instead, flag it as
+ a deviation from Go's `http.ServeContent` behaviour (an explicit
+ upstream change would have been required).
+
+12. GET stream with a multi-range request: issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=0-99,200-299` request header. Confirm the
+ response is HTTP 206 and the `Content-Type` response header starts
+ with `multipart/byteranges; boundary=`. The body is a MIME multipart
+ document containing two parts; full parsing is out of scope, but the
+ body MUST be larger than 200 bytes (two 100-byte ranges plus MIME
+ boundaries and per-part headers). If the server collapses this to a
+ single 200 OK with the full file, flag it — some clients (notably
+ iTunes / Music.app) emit multi-range requests.
+
+13. GET stream with `If-Modified-Since` matching the file's
+ `Last-Modified`: first issue a plain `GET /api/v1/media/{media_id}/stream`
+ with the session cookie and no `Range` header. Confirm the response is
+ HTTP 200 and save the `Last-Modified` response header verbatim as
+ `last_modified`. Then issue a second
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `If-Modified-Since: <last_modified>` request header.
+ Confirm the response is HTTP 304 (Not Modified) and the response body
+ is empty. This proves the conditional-GET path through
+ `http.ServeContent` works.
+
+14. GET stream with `If-None-Match`: issue
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `If-None-Match: "any-tag"` request header. The handler
+ does NOT set an explicit `ETag` response header, so this conditional
+ cannot match — confirm the response is HTTP 200 and the
+ `Content-Length` response header equals the full `file_size`. Also
+ confirm the response has no `ETag` response header. If a future
+ change adds ETag support, this step should be updated to assert 304
+ when the client sends back the server-emitted ETag.
+
+15. Negative case — HEAD on a nonexistent media id: issue
+ `HEAD /api/v1/media/999999999/stream` with the session cookie.
+ Confirm the response is HTTP 404. This verifies the
+ `service.ErrNotFound` → HTTP 404 mapping in `fileHandler`
+ (`internal/api/handlers_file.go`) fires for HEAD just as it does for
+ GET — `http.ServeContent` is never reached when the upstream service
+ returns `ErrNotFound`.
+
+16. Negative case — GET stream on a nonexistent media id: issue
+ `GET /api/v1/media/999999999/stream` with the session cookie.
+ Confirm the response is HTTP 404. This mirrors step 15 for the GET
+ method and confirms there is no method-specific divergence in the
+ error path.
diff --git a/player-server/test/e2e-llm/scenarios/S21-upload-negatives.md b/player-server/test/e2e-llm/scenarios/S21-upload-negatives.md
new file mode 100644
index 0000000..2287b24
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S21-upload-negatives.md
@@ -0,0 +1,247 @@
+---
+id: S21
+title: "Upload negatives — missing parts, bad extensions, traversal, 404/403/413"
+tags: [upload, api, security, negative-path]
+preconditions:
+ server_state: running # server running with admin account and at least one set
+ fixtures: []
+assertions:
+ - status_code: "POST /api/v1/sets/upload 400"
+ - db: "SELECT file_name FROM media WHERE file_name LIKE 'e2e-s21-dedupe%' OR file_name LIKE '%passwd%'"
+skip: false
+---
+
+# Purpose
+
+This scenario exercises the negative-path branches of the upload handler
+(`handleUpload` in `internal/api/handlers_media.go`, lines ~107-155) and the
+write service (`writeService.UploadMedia` in `internal/service/write.go`,
+lines ~59-99). It locks in the expected status codes for malformed multipart
+bodies, missing file fields, unsupported extensions, path-traversal-style
+filenames, missing sets, forbidden access, oversized payloads, and the
+filename-deduplication behaviour of `uniqueFilename` (`internal/service/filename.go`).
+
+Server-side details that shape the assertions below:
+
+- `handleUpload` wraps `r.Body` in `http.MaxBytesReader` with
+ `cfg.MaxUploadSizeMB` (default `100`, from `internal/config.go`). When the
+ body exceeds the limit, `ParseMultipartForm` returns a `*http.MaxBytesError`
+ and the handler responds with HTTP **413** (`StatusRequestEntityTooLarge`)
+ and JSON body `{"error":"file too large"}`.
+- Any other `ParseMultipartForm` error (malformed body, missing
+ `Content-Type: multipart/form-data` boundary, etc.) maps to **400** with
+ body `"invalid multipart form"`.
+- A successfully parsed multipart with no `file` part maps to **400** with
+ body `"missing file"`.
+- The service rejects unknown extensions (anything not in
+ `internal/mediatype.IsSupportedExt`) with `ErrUnsupportedExtension`, which
+ the handler maps to **400**. Plain `.txt` is NOT a supported extension.
+- For a nonexistent set, the service returns `ErrNotFound` → handler maps to
+ **404**.
+- For a non-admin user without `set_permissions.role` on the set, the access
+ helper returns `ErrForbidden` → handler maps to **403**.
+- Filenames are sanitized by `uniqueFilename` which calls `filepath.Base()`
+ first, so a traversal-style input like `../../../etc/passwd.mp3` collapses
+ to `passwd.mp3` and lands inside the set's `MEDIA_ROOT` directory. If the
+ saved row ends up with any path separator in `file_name`, or if a file
+ actually lands outside `MEDIA_ROOT`, treat that as a security defect and
+ file a task — the YAML `db` assertion above looks for the saved name.
+- Repeated uploads with the same filename are deduplicated by appending
+ `(1)`, `(2)`, … before the extension (see `uniqueFilename`); both rows
+ must exist in `media`.
+
+If `MAX_UPLOAD_SIZE_MB` cannot be lowered for the run, the actual 413 step
+(step 11) is skipped — see the inline note there.
+
+---
+
+## A) Setup
+
+1. Authenticate as the admin user: call `POST /api/v1/auth/login` with body
+ `{"username": "admin", "password": "TestPassw0rd!"}`. Confirm the response
+ is HTTP 200 and save the `session` cookie as `ADMIN_COOKIE` for all
+ subsequent admin requests.
+
+2. Find a set to upload into: call `GET /api/v1/sets` with `ADMIN_COOKIE`.
+ Confirm the response is HTTP 200 and the body contains at least one set.
+ Save the `id` of the first non-podcast set as `set_id`. (Any writable
+ filesystem-backed set will do; `musicvideos` from the seeded fixtures is
+ a good default.)
+
+3. Pick a clearly nonexistent set id for the 404 step: use `set_id_missing =
+ 999999999`. Do NOT pick a value that may collide with a real row.
+
+4. Prepare a tiny valid media payload for the steps that need a real body.
+ Use the fixture file at `player-server/testdata/media/podcast/` or create
+ a 1-second silent MP3 with
+ `ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 -acodec libmp3lame /tmp/s21-tiny.mp3`.
+ Confirm the file exists and is non-empty. This file is reused by the
+ traversal, empty-filename, and dedupe steps below.
+
+## B) Malformed / missing multipart bodies (HTTP 400)
+
+5. Empty body, no multipart header: call
+ `POST /api/v1/sets/{set_id}/upload` with `ADMIN_COOKIE`, no body, and no
+ `Content-Type` header. Confirm the response is HTTP **400** and the body
+ contains the text `invalid multipart form`. (Go's `mime/multipart` parser
+ refuses to start without a boundary.)
+
+6. Multipart envelope but no `file` field: build a multipart body that
+ contains a single text field (e.g. `note=hello`) and POST it to
+ `/api/v1/sets/{set_id}/upload` with `ADMIN_COOKIE` and the matching
+ `Content-Type: multipart/form-data; boundary=…` header. Confirm the
+ response is HTTP **400** and the body contains the text `missing file`.
+ The form must parse cleanly — only the `file` part is absent.
+
+## C) Unsupported extension (HTTP 400)
+
+7. Plain-text payload with a `.txt` filename: build a multipart body whose
+ `file` part has `filename="e2e-s21.txt"` and content `hello world`. POST
+ it to `/api/v1/sets/{set_id}/upload` with `ADMIN_COOKIE`. Confirm the
+ response is HTTP **400** and the body contains `unsupported extension`
+ (the wrapped `ErrUnsupportedExtension` message includes the rejected
+ extension, e.g. `.txt`). Confirm via `GET /api/v1/media?set_id={set_id}&search=e2e-s21`
+ that NO row with `file_name = "e2e-s21.txt"` exists — the service must
+ reject before the file is written or the DB row inserted.
+
+8. Double-extension probe (defensive — flag any bug): build a multipart body
+ whose `file` part has `filename="e2e-s21.mp3.txt"` and short text
+ content. POST it. The expected behaviour is HTTP **400** because
+ `IsSupportedExt` calls `filepath.Ext(name)` which only returns the LAST
+ extension (`.txt`). If the response is HTTP 200, that means the
+ extension validator was bypassed by a double-extension trick — file a
+ bug task and confirm by checking the DB for a row with `file_name LIKE
+ 'e2e-s21.mp3.txt%'`.
+
+## D) Path-traversal filename (must be sanitized)
+
+9. Traversal-style filename, valid extension: build a multipart body whose
+ `file` part has `filename="../../../etc/passwd.mp3"` and contains the
+ bytes of `/tmp/s21-tiny.mp3` from step 4. POST it to
+ `/api/v1/sets/{set_id}/upload` with `ADMIN_COOKIE`. Confirm the response
+ is HTTP **200** and the returned JSON `file_name` field is exactly
+ `passwd.mp3` (no slashes, no `..`). Save the returned `id` as
+ `traversal_media_id`. Then:
+ - Run a DB check: `SELECT file_name FROM media WHERE id =
+ <traversal_media_id>`. The value MUST equal `passwd.mp3` and MUST NOT
+ contain any path separator. The `rel_path` value MUST also be
+ `passwd.mp3` (no traversal).
+ - Run `GET /api/v1/media/{traversal_media_id}` and confirm `abs_path`
+ starts with the configured `MEDIA_ROOT` (or the test's media root) —
+ i.e. the file did NOT actually land in `/etc/`. If `abs_path` resolves
+ outside `MEDIA_ROOT`, treat this as a critical security defect and
+ file a task.
+
+## E) Missing set (HTTP 404)
+
+10. Upload to a nonexistent set: build a normal multipart body with a valid
+ `.mp3` `file` part (filename `e2e-s21-missing.mp3`, the bytes from
+ `/tmp/s21-tiny.mp3`). POST it to
+ `/api/v1/sets/{set_id_missing}/upload` (the `999999999` from step 3)
+ with `ADMIN_COOKIE`. Confirm the response is HTTP **404**. The service
+ returns `ErrNotFound` from the `GetSetByID` branch before any file is
+ written; verify no `media` row was inserted by running
+ `GET /api/v1/media?search=e2e-s21-missing` and confirming the result is
+ empty.
+
+## F) Oversized payload (HTTP 413)
+
+11. Trigger the `MaxBytesReader` 413 path. The default `MaxUploadSizeMB` is
+ 100 (see `DefaultMaxUploadSizeMB` in `internal/config.go`), so honestly
+ streaming 100 MiB+1 bytes from the LLM harness is wasteful. Two
+ acceptable strategies — pick ONE:
+
+ **Preferred:** if the test server was started with a lowered
+ `MAX_UPLOAD_SIZE_MB` (e.g. 1), build a multipart body whose `file` part
+ is just over the limit (1 MiB + 64 KiB of zero bytes, filename
+ `e2e-s21-big.mp3`) and POST it. Confirm the response is HTTP **413**
+ and the body contains `file too large`.
+
+ **Fallback:** send a request with `Content-Length` set just above
+ `MaxUploadSizeMB << 20` but a body of the same length filled with zero
+ bytes. `MaxBytesReader` enforces the limit while the body is being
+ read, so a body that simply matches the declared length still trips
+ the limit if it exceeds `MaxUploadSizeMB << 20`. Use a 101 MiB body
+ only if the harness can stream it without buffering in RAM.
+
+ **Skip note:** if neither option is feasible in the harness (e.g.
+ `MAX_UPLOAD_SIZE_MB` is fixed at 100 and the harness cannot stream
+ >100 MiB), record `skip-step-11: harness cannot trigger 413 in this
+ environment` in the run annotation and continue. Do NOT fail the
+ scenario on a skipped 413 — the 400 paths above already satisfy the
+ YAML `status_code` assertion.
+
+## G) Forbidden access (HTTP 403)
+
+12. Create a temporary non-admin user without permission on `set_id`: call
+ `POST /api/v1/admin/users` with `ADMIN_COOKIE` and body
+ `{"username": "e2e-s21-noperm", "password": "TestPassw0rd!", "is_admin": false}`.
+ Confirm the response is HTTP 200 and save the returned `id` as
+ `noperm_user_id`. Do NOT grant any permission on `set_id` to this user.
+
+13. Log in as that user: call `POST /api/v1/auth/login` with body
+ `{"username": "e2e-s21-noperm", "password": "TestPassw0rd!"}` and no
+ prior cookie. Confirm the response is HTTP 200 and save the session
+ cookie as `USER_COOKIE`. Do NOT mix `ADMIN_COOKIE` and `USER_COOKIE`.
+
+14. Attempt an upload as the non-admin: build a valid multipart body (mp3
+ bytes from step 4, filename `e2e-s21-noperm.mp3`) and POST it to
+ `/api/v1/sets/{set_id}/upload` with `USER_COOKIE` (NOT `ADMIN_COOKIE`).
+ Confirm the response is HTTP **403** (`verifySetModifyAccess` returns
+ `ErrForbidden` because the user has no owner role on the set). Confirm
+ via `GET /api/v1/media?search=e2e-s21-noperm` (with `ADMIN_COOKIE`)
+ that NO row was written.
+
+## H) Empty filename
+
+15. Build a multipart body whose `file` part has `filename=""` (empty
+ string) and a short payload. POST it to
+ `/api/v1/sets/{set_id}/upload` with `ADMIN_COOKIE`. The expected
+ behaviour is HTTP **400** because `IsSupportedExt("")` is false (the
+ extension is empty), so the service returns `ErrUnsupportedExtension`.
+ Confirm the body contains `unsupported extension`. A 500 response or a
+ 200 response that writes an empty-named row is a defect — flag it.
+
+## I) Filename deduplication
+
+16. Upload twice with the same filename. First call: build a multipart body
+ with the `.mp3` bytes from step 4 and filename `e2e-s21-dedupe.mp3`.
+ POST it to `/api/v1/sets/{set_id}/upload` with `ADMIN_COOKIE`. Confirm
+ the response is HTTP **200** and save the returned `id` as
+ `dedupe_id_1` and the `file_name` as `dedupe_name_1` (should be
+ `e2e-s21-dedupe.mp3`).
+
+17. Second call: POST another upload to the same set with the same
+ `filename="e2e-s21-dedupe.mp3"` and the same bytes. Confirm the response
+ is HTTP **200** and save the returned `id` as `dedupe_id_2` and
+ `file_name` as `dedupe_name_2`. The new `file_name` MUST be different
+ from `dedupe_name_1` (the helper appends `(1)`, so expect
+ `e2e-s21-dedupe(1).mp3`). If `dedupe_name_2 == dedupe_name_1` or if
+ the second POST overwrote the first row, that is a regression — file
+ a bug.
+
+18. Confirm both rows exist: call
+ `GET /api/v1/media?set_id={set_id}&search=e2e-s21-dedupe` with
+ `ADMIN_COOKIE`. Confirm the result contains both `dedupe_id_1` and
+ `dedupe_id_2` and that their `file_name` values differ.
+
+## J) Cleanup
+
+19. Soft-delete every media row this scenario created. For each id in
+ `[traversal_media_id, dedupe_id_1, dedupe_id_2]` that is set, call
+ `DELETE /api/v1/media/{id}` with `ADMIN_COOKIE` and confirm the
+ response is HTTP 200. Skip ids that were never assigned (e.g. if step 9
+ is being investigated for a security defect, leave the row in place
+ and annotate the run instead).
+
+20. Delete the temporary non-admin user: call
+ `DELETE /api/v1/admin/users/{noperm_user_id}` with `ADMIN_COOKIE`.
+ Confirm the response is HTTP 200 and the body is `{"status": "ok"}`.
+ Leftover users will pollute future runs of S12, S13, S15, and S21.
+
+21. Confirm cleanup: call `GET /api/v1/admin/users` with `ADMIN_COOKIE` and
+ confirm the array does NOT contain any entry whose `username` is
+ `e2e-s21-noperm`. Call
+ `GET /api/v1/media?set_id={set_id}&search=e2e-s21-dedupe` and confirm
+ the result is empty (or only contains rows whose `deleted_at` is set,
+ depending on whether the API filters soft-deleted by default).
diff --git a/player-server/test/e2e-llm/scenarios/S22-share-expiry-uniqueness.md b/player-server/test/e2e-llm/scenarios/S22-share-expiry-uniqueness.md
new file mode 100644
index 0000000..0c76662
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S22-share-expiry-uniqueness.md
@@ -0,0 +1,152 @@
+---
+id: S22
+title: "Share expiry (410 Gone) and token uniqueness"
+tags: [share, api, public, security, db]
+preconditions:
+ server_state: running # server running with admin account and at least one media item
+ fixtures: []
+assertions:
+ - db: "SELECT token FROM shares"
+ - db: "SELECT token FROM shares WHERE expires_at < '2021-01-01T00:00:00Z'"
+ - status_code: "POST /api/v1/auth/login 200"
+ - status_code: "GET /api/v1/media 200"
+ - status_code: "GET /api/v1/shares 200"
+skip: false
+---
+
+# Notes
+
+This scenario covers two related share guarantees:
+
+* **Part A — Expiry:** an expired share token must return HTTP 410 Gone
+ from every public endpoint (`/s/{token}/thumbnail`, `/s/{token}/stream`,
+ `/s/{token}/download`). The `expires_at` column on the `shares` table is
+ rewritten in place via the `sqlite3` CLI (the harness lists `sqlite3` as
+ a prerequisite in `test/e2e-llm/README.md`) so the test does not have to
+ wait for the configured default expiry. The DB path is the same one the
+ server is started with: `/tmp/player-e2e-llm.db` (overridable with the
+ `PLAYER_DB` env var).
+* **Part B — Token uniqueness:** creating multiple shares in quick
+ succession on the same media item must produce 5 distinct, non-colliding
+ tokens. Tokens are generated in `shareService.generateToken()` via
+ `crypto/rand` (16 random bytes → 32 hex chars, 128 bits of entropy), so
+ collisions should be astronomically unlikely.
+
+If any public share endpoint still serves content after the share is
+expired (i.e. returns a 2xx response instead of 410), record that as a
+**security defect**: expired shares must not leak media. Likewise, if any
+two tokens out of the five created in Part B are identical, record that as
+a **security defect** in token generation.
+