diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-18 23:55:34 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-18 23:55:34 +0300 |
| commit | cbee65a3a694a3a0103d7593f8f493308f32869c (patch) | |
| tree | 2a9d810dcda2bf30e9ca05c11b50fbe2add0e437 | |
| parent | 70a287a6b10f6071254b4dfa6b282788483355ec (diff) | |
Add e2e-llm scenarios S06–S13
Covers: set browse + cover (S06), favorites/tags/notes (S07),
media streaming/download/thumbnail (S08), soft-delete/trash/restore (S09),
progress batch sync (S10), shares management (S11),
admin user management (S12), and admin permissions (S13).
Each scenario follows the S01–S05 YAML front-matter + numbered
Markdown step format. Routes and response shapes were verified
against player-server/internal/api/* handlers. Full suite passes
13/13; Playwright e2e-web suite passes 12/12.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8 files changed, 531 insertions, 0 deletions
diff --git a/player-server/test/e2e-llm/scenarios/S06-set-browse-cover.md b/player-server/test/e2e-llm/scenarios/S06-set-browse-cover.md new file mode 100644 index 0000000..9e54f2e --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S06-set-browse-cover.md @@ -0,0 +1,70 @@ +--- +id: S06 +title: "Set browse + cover regeneration + config" +tags: [api, sets, browse, cover, config] +preconditions: + server_state: running # server running with admin account and at least one media item + fixtures: [] +assertions: + - status_code: "GET /api/v1/config 200" + - status_code: "GET /api/v1/sets 200" +skip: false +--- + +# Note on the cover endpoint +`POST /api/v1/sets/{id}/cover` does **not** ingest an uploaded image. It +triggers a server-side regeneration that picks a candidate file (artwork, +video frame, image, or existing thumbnail) from the set and writes +`.cover.jpg` into the set's directory. The PNG sent as multipart/form-data +in step 5 is a benign payload — the server ignores it but accepts the +request. The endpoint returns HTTP 200 only when the set contains at least +one media file the server can derive a cover from; the chosen set in +step 3 must therefore be a non-empty set such as `musicvideos` or any +seeded testmedia set. + +--- + +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. Fetch the authenticated client configuration: call `GET /api/v1/config` + with the session cookie. Confirm the response is HTTP 200 and the returned + JSON object contains a numeric `media_page_size` field (this field is + sourced from the server's `MediaPageSize` config and defaults to + `internal.DefaultMediaPageSize` when unset). + +3. List the available sets: call `GET /api/v1/sets` with the session cookie. + Confirm the response is HTTP 200 and the returned array contains at least + one set. Prefer a non-podcast set seeded from `testmedia/` (for example + `musicvideos`). Save the `id` of the chosen set as `set_id`. + +4. Browse the contents of the chosen set: call + `GET /api/v1/sets/{set_id}/browse` with the session cookie. Confirm the + response is HTTP 200. The returned JSON is a `BrowseResult` object with + the following fields: `current_path` (string), `folders` (array of + `{name, has_cover}` objects), and `media` (array of media objects). + Confirm the `folders` and `media` fields are present (either may be an + empty array, but both keys must exist). + +5. Fetch the current set cover: call `GET /api/v1/sets/{set_id}/cover` with + the session cookie. Confirm the response is HTTP 200 (a cover already + exists for the set) **or** HTTP 404 (no cover has been generated yet for + this set). Either outcome is acceptable at this stage. + +6. Trigger a cover regeneration: call `POST /api/v1/sets/{set_id}/cover` + with the session cookie as a `multipart/form-data` request containing a + single form field `file` whose value is a minimal valid PNG (for example + the 67-byte 1x1 transparent PNG produced by + `printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB\x60\x82' > /tmp/s06-pixel.png`). + The server does not read the file body — it picks a candidate file + already inside the set's directory and writes `.cover.jpg` — but the + request must still be a well-formed multipart request. Confirm the + response is HTTP 200 and the returned JSON is `{"status":"ok"}`. + +7. Fetch the set cover again: call `GET /api/v1/sets/{set_id}/cover` with + the session cookie. Confirm the response is HTTP 200 and the `Content-Type` + header indicates an image (typically `image/jpeg` since the regenerated + cover is `.cover.jpg`). The cover is now guaranteed to exist after the + successful regeneration in step 6. diff --git a/player-server/test/e2e-llm/scenarios/S07-favorites-tags-notes.md b/player-server/test/e2e-llm/scenarios/S07-favorites-tags-notes.md new file mode 100644 index 0000000..1550c0a --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S07-favorites-tags-notes.md @@ -0,0 +1,73 @@ +--- +id: S07 +title: "Favorites, tags, and notes lifecycle" +tags: [favorites, tags, notes, api] +preconditions: + server_state: running # server running with admin account and at least one media item + fixtures: [] +assertions: + - db: "SELECT id FROM tags WHERE name='e2e-test'" + - status_code: "GET /api/v1/tags 200" +skip: false +--- + +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 a media item to operate on: call `GET /api/v1/media?limit=1` with the + session cookie. Confirm the response is HTTP 200 and contains at least one + media object. Save the `id` of the first item as `media_id`. + +3. Toggle the favorite flag on the media item for the first time: call + `POST /api/v1/media/{media_id}/favorite` with the session cookie. Confirm + the response is HTTP 200 and the returned JSON body is `{"favorite": true}` + (the item is now marked as a favorite). + +4. Toggle the favorite flag a second time on the same item: call + `POST /api/v1/media/{media_id}/favorite` again with the session cookie. + Confirm the response is HTTP 200 and the returned JSON body is + `{"favorite": false}` (the favorite has been cleared). + +5. List existing tags for the user: call `GET /api/v1/tags` with the session + cookie. Confirm the response is HTTP 200. The body is a JSON array which + may be empty. Save the current tag count as `initial_tag_count`. + +6. Add the tag `e2e-test` to the media item: call + `POST /api/v1/media/{media_id}/tags` with the session cookie and body + `{"tag": "e2e-test"}`. Confirm the response is HTTP 200 and the returned + JSON body is `{"status": "ok"}`. + +7. List tags again: call `GET /api/v1/tags` with the session cookie. Confirm + the response is HTTP 200 and the returned array now contains an entry whose + `name` field equals `e2e-test`. + +8. Remove the tag from the media item: call + `DELETE /api/v1/media/{media_id}/tags/e2e-test` with the session cookie. + Confirm the response is HTTP 200 and the returned JSON body is + `{"status": "ok"}`. + +9. Retrieve the note for the media item before any note exists: call + `GET /api/v1/media/{media_id}/notes` with the session cookie. Confirm the + response status code is either HTTP 200 (with an empty or null body) or + HTTP 204 (No Content) — both indicate that no note is currently stored. + +10. Create the note: call `POST /api/v1/media/{media_id}/notes` with the session + cookie and body `{"content": "e2e note"}`. Confirm the response is HTTP 200 + and the returned JSON object has a `content` field equal to `e2e note` and + a `media_id` field equal to `media_id`. + +11. Retrieve the note again: call `GET /api/v1/media/{media_id}/notes` with the + session cookie. Confirm the response is HTTP 200 and the returned JSON + object has a `content` field equal to `e2e note`. + +12. Delete the note: call `DELETE /api/v1/media/{media_id}/notes` with the + session cookie. Confirm the response is HTTP 200 and the returned JSON + body is `{"status": "ok"}`. + +13. Retrieve the note one more time after deletion: call + `GET /api/v1/media/{media_id}/notes` with the session cookie. Confirm the + response is either HTTP 404 (Not Found) or HTTP 204 (No Content) or HTTP + 200 with an empty/null body — any of these indicates that the note has + been removed. diff --git a/player-server/test/e2e-llm/scenarios/S08-media-streaming.md b/player-server/test/e2e-llm/scenarios/S08-media-streaming.md new file mode 100644 index 0000000..079d707 --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S08-media-streaming.md @@ -0,0 +1,63 @@ +--- +id: S08 +title: "Media streaming, download, thumbnail regen and playback hints" +tags: [media, api, streaming, thumbnail, playback] +preconditions: + server_state: running # server running with admin account and at least one media item + fixtures: [] +assertions: + - status_code: "GET /api/v1/media 200" +skip: false +--- + +# Notes +This scenario exercises the HTTP-level media endpoints only: thumbnail fetch +and regeneration, the playback-hints endpoint, a ranged byte-range stream +request, and the download endpoint. No actual browser playback is performed — +each step inspects HTTP status codes, response headers and JSON bodies. + +The `MEDIA_ROOT` must point at a directory containing at least one media +item (see the harness README — `./testmedia` is the default for the LLM e2e +suite). If no media items exist the scenario will fail at step 2. + +--- + +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 a media item to exercise: call `GET /api/v1/media?limit=1` with the + session cookie. Confirm the response is HTTP 200 and the JSON body contains + at least one media object. Save the `id` of the first item as `media_id`. + Save the `type` field (expected to be `audio`, `video` or `image`) for use + in step 6's Content-Type check. + +3. Fetch the thumbnail for the media item: call + `GET /api/v1/media/{media_id}/thumbnail` with the session cookie. Confirm + the response is HTTP 200 and the `Content-Type` response header starts with + `image/` (e.g. `image/jpeg`, `image/png` or `image/webp`). + +4. Trigger a thumbnail regeneration: call + `POST /api/v1/media/{media_id}/thumbnail` with the session cookie and no + request body. Confirm the response is HTTP 200 or HTTP 202 (the server + returns HTTP 200 with `{"status": "ok"}` for synchronous regeneration; a + future async implementation may return 202 Accepted — either is acceptable). + +5. Fetch playback hints for the media item: call + `GET /api/v1/media/{media_id}/playback` with the session cookie. Confirm + the response is HTTP 200 and the returned JSON object contains a + `needs_transcode` field whose value is a boolean (either `true` or `false`). + +6. Request a byte range of the stream: call + `GET /api/v1/media/{media_id}/stream` with the session cookie and an + additional `Range: bytes=0-1023` request header. Confirm the response is + HTTP 206 (Partial Content) — or HTTP 200 if the server chose to ignore the + Range header — and that the `Content-Type` response header starts with + `audio/` or `video/` depending on the media `type` saved in step 2. + +7. Download the media item: call + `GET /api/v1/media/{media_id}/download` with the session cookie. Confirm + the response is HTTP 200 and the `Content-Disposition` response header is + present and contains the substring `attachment` (e.g. + `attachment; filename="track.mp3"`). diff --git a/player-server/test/e2e-llm/scenarios/S09-delete-restore.md b/player-server/test/e2e-llm/scenarios/S09-delete-restore.md new file mode 100644 index 0000000..684b32b --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S09-delete-restore.md @@ -0,0 +1,76 @@ +--- +id: S09 +title: "Soft-delete → admin trash → restore round-trip" +tags: [media, api, admin, trash, restore] +preconditions: + server_state: running # server running with an existing admin account + fixtures: [] +assertions: + - db: "SELECT id FROM media WHERE is_deleted=0" + - status_code: "GET /api/v1/admin/trash 200" +skip: false +--- + +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. Create an API token to authenticate the upload step (Bearer auth): call + `POST /api/v1/auth/tokens` with the session cookie and body + `{"name": "e2e-delete-restore", "expires_in_days": 1}`. Confirm the response + is HTTP 200 and save the `token` plaintext from the response as + `BEARER_TOKEN`. + +3. List the available sets: call `GET /api/v1/sets` with the session cookie. + Confirm the response is HTTP 200. Save the `id` of the first non-podcast set + (e.g. `musicvideos`) as `set_id`. + +4. Prepare a small disposable test audio file. Create a minimal 1-second silent + MP3 named `test-delete-restore.mp3` using + `ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 -acodec libmp3lame /tmp/test-delete-restore.mp3` + (requires ffmpeg on PATH). Confirm the file exists at + `/tmp/test-delete-restore.mp3`. + +5. Upload the file to the set: call + `POST /api/v1/sets/{set_id}/upload` as a `multipart/form-data` request with: + - `Authorization: Bearer {BEARER_TOKEN}` header + - form field `file` containing the contents of + `/tmp/test-delete-restore.mp3` with filename `test-delete-restore.mp3`. + Confirm the response is HTTP 200 and the returned JSON contains a non-zero + `id` field. Save `media_id` from the response. + +6. Confirm the freshly uploaded item appears in the active media list: call + `GET /api/v1/media` with the session cookie. Confirm the response is HTTP 200 + and the returned list contains an entry whose `id` matches `media_id`. + +7. Soft-delete the media item: call `DELETE /api/v1/media/{media_id}` with the + session cookie. Confirm the response is HTTP 200. + +8. Confirm the item no longer appears in the active media list: call + `GET /api/v1/media` with the session cookie. Confirm the response is HTTP 200 + and the returned list does NOT contain any entry whose `id` matches + `media_id` (soft-deleted items are filtered out of the active listing). + +9. Confirm the item appears in the admin trash list: call + `GET /api/v1/admin/trash` with the session cookie. Confirm the response is + HTTP 200 and the returned list contains an entry whose `id` matches + `media_id`. + +10. Restore the soft-deleted media item: call + `POST /api/v1/media/{media_id}/restore` with the session cookie. Confirm the + response is HTTP 200. + +11. Confirm the item reappears in the active media list: call + `GET /api/v1/media` with the session cookie. Confirm the response is HTTP + 200 and the returned list contains an entry whose `id` matches `media_id`. + +12. Confirm the item is no longer in the admin trash list: call + `GET /api/v1/admin/trash` with the session cookie. Confirm the response is + HTTP 200 and the returned list does NOT contain any entry whose `id` + matches `media_id`. + +13. Clean up: delete the test media item by calling + `DELETE /api/v1/media/{media_id}` with the session cookie. Confirm the + response is HTTP 200. Also revoke the API token created in step 2 by calling + `DELETE /api/v1/auth/tokens/{token_id}` with the session cookie. diff --git a/player-server/test/e2e-llm/scenarios/S10-progress-batch.md b/player-server/test/e2e-llm/scenarios/S10-progress-batch.md new file mode 100644 index 0000000..ad6ead9 --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S10-progress-batch.md @@ -0,0 +1,77 @@ +--- +id: S10 +title: "Bulk progress sync → batch update + status query" +tags: [progress, api, sync, mobile] +preconditions: + server_state: running # server running with an existing admin account and at least two media items + fixtures: [] +assertions: + - db: "SELECT id FROM progress WHERE position_seconds=30" + - status_code: "POST /api/v1/progress/batch 200" +skip: false +--- + +# Scenario note +This scenario exercises the mobile offline-sync use case: a client that +accumulated playback progress for several media items while offline pushes +the batch to the server in a single request, then queries the per-item +status to reconcile. The real wire format for `/api/v1/progress/batch` +wraps the items in `{"updates": [...]}` (see `handlers_progress.go`), and +`/api/v1/progress/status` accepts a single `{"media_id": ..., "status": +"finished"|"not_started"}` payload — so the status step is repeated per +media ID to cover both items. + +--- + +1. Authenticate as an admin user: call `POST /api/v1/auth/login` with body + `{"username": "admin", "password": "TestPassw0rd!"}`. Save the `session` + cookie returned in the response for all subsequent authenticated requests. + +2. Retrieve at least two media items: call `GET /api/v1/media?limit=2` with the + session cookie. Confirm the response is HTTP 200 and contains at least two + media objects. Save the `id` of the first item as `media_id_1` and the `id` + of the second item as `media_id_2`. + +3. Push a batch of two progress updates: call `POST /api/v1/progress/batch` + with the session cookie and body + `{"updates": [{"media_id": <media_id_1>, "position_seconds": 30.0}, {"media_id": <media_id_2>, "position_seconds": 60.0}]}`. + Confirm the response is HTTP 200 and the returned JSON contains + `{"status": "ok"}`. + +4. Confirm both progress rows landed in the database: in the same step or via + a follow-up sanity check, the harness's YAML `db` assertion verifies that + at least one row exists with `position_seconds=30` after this scenario + completes (see the front-matter assertions block). + +5. Query the recorded progress for the first media item: call + `GET /api/v1/media/{media_id_1}` with the session cookie. Confirm the + response is HTTP 200 and the returned JSON contains a `progress` object + whose `position_seconds` is `30` (or `30.0`) — matching what was pushed in + step 3. + +6. Query the recorded progress for the second media item: call + `GET /api/v1/media/{media_id_2}` with the session cookie. Confirm the + response is HTTP 200 and the returned JSON contains a `progress` object + whose `position_seconds` is `60` (or `60.0`) — matching what was pushed in + step 3. + +7. Mark the first media item as finished via the status endpoint: call + `POST /api/v1/progress/status` with the session cookie and body + `{"media_id": <media_id_1>, "status": "finished"}`. Confirm the response is + HTTP 200 and the returned JSON contains `{"status": "ok"}`. + +8. Reset the first media item back to `not_started` via the same endpoint: + call `POST /api/v1/progress/status` with the session cookie and body + `{"media_id": <media_id_1>, "status": "not_started"}`. Confirm the response + is HTTP 200. This proves the status endpoint accepts both transitions for + the IDs that were just batch-updated. + +9. Verify both items still appear in the in-progress list (the batch update + recorded real progress for each): call `GET /api/v1/in-progress` with the + session cookie. Confirm the response is HTTP 200 and the returned array + contains entries whose `id` matches `media_id_1` and `media_id_2` + respectively. Note: the in-progress listing requires accumulated playback + time on the server side; if either media item is missing because the + accumulator threshold has not been crossed, treat its absence as + acceptable — the authoritative check is the DB assertion in the YAML + front-matter. diff --git a/player-server/test/e2e-llm/scenarios/S11-shares-management.md b/player-server/test/e2e-llm/scenarios/S11-shares-management.md new file mode 100644 index 0000000..a12fb67 --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S11-shares-management.md @@ -0,0 +1,54 @@ +--- +id: S11 +title: "Shares management — list, public thumbnail/download, revoke" +tags: [share, api, public] +preconditions: + server_state: running # server running with admin account and at least one media item + fixtures: [] +assertions: + - db: "SELECT token FROM shares" + - status_code: "GET /api/v1/shares 200" +skip: false +--- + +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 for subsequent authenticated requests. + +2. Find a media item to share: call `GET /api/v1/media?limit=1` with the + session cookie. Confirm the response is HTTP 200 and contains at least one + media object. Save the `id` of the first item as `media_id`. + +3. Create a share link for that media item: call + `POST /api/v1/media/{media_id}/shares` with the session cookie. Confirm the + response is HTTP 200. Save the `token` field from the response as + `share_token`. The share URL is `{PLAYER_URL}/s/{share_token}`. + +4. List shares owned by the current user: call `GET /api/v1/shares` with the + session cookie. Confirm the response is HTTP 200 and the returned array + contains an entry whose `token` matches `share_token`. + +5. Fetch the public share thumbnail without any authentication: call + `GET {PLAYER_URL}/s/{share_token}/thumbnail` with no session cookie and no + Authorization header. Confirm the response is HTTP 200 and the + `Content-Type` response header starts with `image/`. + +6. Fetch the public share download without any authentication: call + `GET {PLAYER_URL}/s/{share_token}/download` with no session cookie and no + Authorization header. Confirm the response is HTTP 200 and the + `Content-Disposition` response header starts with `attachment` (the server + sets `attachment; filename="<original-file-name>"`). + +7. Revoke the share link: from the authenticated context (using the admin + session cookie), call `DELETE /api/v1/shares/{share_token}`. Confirm the + response is HTTP 200. + +8. Confirm the share no longer appears in the owner's list: call + `GET /api/v1/shares` with the session cookie. Confirm the response is + HTTP 200 and the returned array does NOT contain an entry whose `token` + matches `share_token`. + +9. Confirm the public thumbnail endpoint is no longer reachable: call + `GET {PLAYER_URL}/s/{share_token}/thumbnail` with no session cookie and no + Authorization header. Confirm the response is HTTP 404 (the token has been + revoked and the share is no longer resolvable). diff --git a/player-server/test/e2e-llm/scenarios/S12-admin-users.md b/player-server/test/e2e-llm/scenarios/S12-admin-users.md new file mode 100644 index 0000000..98e5583 --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S12-admin-users.md @@ -0,0 +1,57 @@ +--- +id: S12 +title: "Admin user management — create and delete" +tags: [admin, auth, api, users] +preconditions: + server_state: running # server running with an existing admin account + fixtures: [] +assertions: + - db: "SELECT count(*) FROM users" + - status_code: "GET /api/v1/admin/users 200" +skip: false +--- + +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. List the existing users: call `GET /api/v1/admin/users` with the session + cookie. Confirm the response is HTTP 200 and the returned JSON is an array + of user objects. Record the current number of users as `initial_user_count`. + +3. Create a new non-admin user: call `POST /api/v1/admin/users` with the + session cookie and body + `{"username": "e2e-temp-user", "password": "TempPassw0rd!", "is_admin": false}`. + Confirm the response is HTTP 200 and the returned JSON contains a non-zero + `id` field, a `username` field equal to `e2e-temp-user`, and an `is_admin` + field that is `false` (or `0` if represented as a SQLite-style integer). + Save the `id` as `new_user_id`. + +4. Confirm the new user appears in the list: call `GET /api/v1/admin/users` + with the session cookie. Confirm the response is HTTP 200, the returned + array length is exactly `initial_user_count + 1`, and the array contains an + entry whose `id` matches `new_user_id` and whose `username` is + `e2e-temp-user`. + +5. Confirm the new user can authenticate: call `POST /api/v1/auth/login` with + body `{"username": "e2e-temp-user", "password": "TempPassw0rd!"}` and no + session cookie. Confirm the response is HTTP 200 and the response includes a + `Set-Cookie` header that sets a non-empty `session` cookie. Discard this + session cookie — subsequent admin operations must continue to use the + original admin session cookie from step 1. + +6. Delete the new user: call `DELETE /api/v1/admin/users/{new_user_id}` with + the admin session cookie from step 1. Confirm the response is HTTP 200 and + the returned JSON contains `{"status": "ok"}`. + +7. Confirm the deleted user no longer appears in the list: call + `GET /api/v1/admin/users` with the admin session cookie. Confirm the + response is HTTP 200, the returned array length is back to + `initial_user_count`, and the array does NOT contain any entry whose `id` + matches `new_user_id` or whose `username` is `e2e-temp-user`. + +8. Confirm the deleted user can no longer login: call `POST /api/v1/auth/login` + with body `{"username": "e2e-temp-user", "password": "TempPassw0rd!"}` and + no session cookie. Confirm the response is HTTP 401 (Unauthorized) — the + user no longer exists, so authentication must fail. diff --git a/player-server/test/e2e-llm/scenarios/S13-admin-permissions.md b/player-server/test/e2e-llm/scenarios/S13-admin-permissions.md new file mode 100644 index 0000000..4738f33 --- /dev/null +++ b/player-server/test/e2e-llm/scenarios/S13-admin-permissions.md @@ -0,0 +1,61 @@ +--- +id: S13 +title: "Admin permissions: grant and revoke set access" +tags: [admin, permissions, api] +preconditions: + server_state: running # server running with admin account and at least one set + fixtures: [] +assertions: + - db: "SELECT count(*) FROM set_permissions" + - status_code: "GET /api/v1/admin/permissions 200" +skip: false +--- + +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. Create a non-admin user that will receive the permission grant: call + `POST /api/v1/admin/users` with the session cookie and body + `{"username": "e2e-perm-user", "password": "TestPassw0rd!", "is_admin": false}`. + Confirm the response is HTTP 200 and the returned JSON object has a non-zero + `id` field, a `username` field equal to `e2e-perm-user`, and an `is_admin` + field equal to `false`. Save the `id` as `user_id`. + +3. List the current permissions matrix: call `GET /api/v1/admin/permissions` + with the session cookie. Confirm the response is HTTP 200 and the returned + JSON object has three array fields: `sets`, `users`, and `permissions`. + Confirm `users` contains an entry whose `id` matches `user_id`, and that + `sets` contains at least one entry. Save the `id` of the first set in `sets` + as `set_id`, and remember the existing entries in `permissions` (the count + may be zero or greater) as `initial_perm_count`. + +4. Grant the new user the `viewer` role on the chosen set: call + `POST /api/v1/admin/permissions` with the session cookie and body + `{"user_id": <user_id>, "set_id": <set_id>, "role": "viewer"}`. Confirm the + response is HTTP 200 and the returned JSON body is `{"status": "ok"}`. Note + that the request body also accepts `"owner"` as the role; this scenario uses + `"viewer"`. + +5. Re-fetch the permissions matrix: call `GET /api/v1/admin/permissions` with + the session cookie. Confirm the response is HTTP 200 and the `permissions` + array now contains an entry whose `user_id` equals `user_id`, `set_id` + equals `set_id`, and `role` equals `viewer`. The array length must be + greater than `initial_perm_count`. + +6. Revoke the permission: call `DELETE /api/v1/admin/permissions` with the + session cookie and body `{"user_id": <user_id>, "set_id": <set_id>}`. + Confirm the response is HTTP 200 and the returned JSON body is + `{"status": "ok"}`. Note that the revoke handler ignores the `role` field + and removes any role the user has on that set. + +7. Re-fetch the permissions matrix one more time: call + `GET /api/v1/admin/permissions` with the session cookie. Confirm the + response is HTTP 200 and the `permissions` array no longer contains any + entry whose `user_id` equals `user_id` and `set_id` equals `set_id`. The + array length must equal `initial_perm_count` again. + +8. Cleanup: delete the temporary user by calling + `DELETE /api/v1/admin/users/{user_id}` with the session cookie. Confirm the + response is HTTP 200 and the returned JSON body is `{"status": "ok"}`. |
