The two filter-search cases were test.fixme placeholders (expect(true).toBe(true)),
so the OR/AND chip rules were untested.
- Extract the grid-filter predicate from feed/+page.svelte into a pure
filterUploads(uploads, filters) in $lib/feed-filter (behavior-preserving) and
unit-test it (9 cases): tag OR, user OR, tag+user AND, AND-excludes-partial,
case-insensitive caption match, null caption.
- Replace the e2e fixmes with real tests that seed known captions/uploaders, switch
to grid view, activate chips via the search suggestions, and count grid tiles:
OR widens 1→2, AND narrows 2→1.
Frontend unit: 27 passing. filter-search e2e: 3 passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups from the code review of the test-quality batches:
- Consolidate duplicated helpers into e2e/helpers/: seed.ts (seedUpload,
seedComment, listComments, findFeedRow) and sse.ts (mintSseTicket, openStream,
trackStreamOpens). Refactor authorization-deep, xss-injection, like-comment,
sse-ticket-abuse, ddos, sse-realtime, multi-tab, and SseListener to use them —
the upload/comment/ticket-flow contracts now live in one place each instead of
being re-inlined across 3–7 specs.
- xss-injection display-name loop: it navigated to /feed (which renders uploader
names, not the viewer's) so "nothing fired" passed vacuously — the payload was
never rendered. Now navigate to /account (the actual sink) and add a render
guard asserting the payload reached the DOM as escaped text before checking
__xssFired.
- sse-realtime reconnect: snapshot the stream-open count AFTER backgrounding, so
the "new connection" assertion is attributable to the foreground event and can't
be satisfied by a spurious native/error reconnect before the toggle.
- recover-page: correct the comment (auto-submit is the onPinInput handler, not an
$effect).
44 affected specs verified green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "429 from server is surfaced (no infinite retry storm)" test routed
`**/api/v1/feed`, but the app requests `/api/v1/feed?limit=20` — a plain glob
without a trailing wildcard doesn't match a URL with a query string, so the
route never fired: `attempts` stayed 0 and `expect(attempts).toBeLessThan(15)`
passed trivially. The test never forced a 429 or exercised any retry behavior.
Fix: match with a regex `/\/api\/v1\/feed(\?|$)/` (catches the query-string URL,
excludes /feed/delta), and gate on `attempts >= 1` before judging retry
behavior so it can't pass again without actually hitting the throttled endpoint.
Found during self-review of the test-quality batches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ContextSheet: add data-testid="context-sheet". longpress tests targeted the
open sheet via the `.translate-y-0` animation class (breaks on any animation
refactor) — now target `[data-testid="context-sheet"][aria-modal="true"]`,
which is stable and unambiguous vs. the centered LightboxModal (also aria-modal).
- toast-on-failure: the like button was `button.filter(hasText:/\d+/).first()`,
which could match any digit-bearing button (e.g. the comment count) → use the
stable aria-label "Gefällt mir".
- config stats: assert the exact user_count (4 = 3 seeded guests + admin) instead
of `>= 3` — deterministic after the per-test truncate, catches under/overcount.
- offline-network 429 test: replace the fixed 3s waitForTimeout with a
poll-until-the-retry-count-stabilizes (faster, and a real storm never stabilizes
→ the poll fails, which is the intended outcome).
Note: reviewed the "config restore not in try/finally" finding — it's a non-issue.
The truncate auto-fixture wipes+reseeds the whole config table (and clears the
rate limiter) before every test, so config state cannot leak between tests.
All affected specs verified green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several tests ran green while asserting nothing. Replace them with real
assertions, and fix the SSE helper they depend on.
sse-listener: exchange the JWT for a single-use ticket (POST /stream/ticket) and
connect via ?ticket= — the helper still used the dead ?token= scheme, so every
SSE-based assertion would have silently failed to receive events.
like-comment:
- "like is idempotent" asserted nothing (void feed; void b) → now seeds a real
upload and pins the like contract: counted once per user, toggles off on repeat
(guards double-count), and a second user's like is counted independently.
- "comment → SSE to B" asserted length >= 0 (always true) → B now subscribes to
the stream, A comments, and B must receive the new-comment event for that upload
(comment_count === 1). ~30s due to reverse-proxy SSE buffering; timeout raised.
sse-realtime: only checked a nav link was visible → now counts EventSource opens
and asserts a fresh stream connection after hidden→visible (also fixes the sim,
which set visibilityState but not document.hidden, so the close never fired).
multi-tab "SSE delivers to both": only checked nav links → now asserts each tab
opens its own stream connection (delivery isn't asserted — it hinges on the ~30s
proxy buffering; connection establishment is the reliable, honest signal).
safe-area: delete the /join probe whose only assertion was Array.isArray(x) ===
true (always true); the real sheet-level env() check already exists below it.
All verified green against the live backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Close two malicious-input gaps flagged in the suite review: XSS was only fuzzed
through display_name, and the SSE ticket flow had no security assertions.
xss-injection:
- Stored XSS in captions — upload with each XSS payload as the caption, mark it
feed-visible, render /feed and assert window.__xssFired stays false, no dialog
fires, and no live `img[onerror]`/`<script>` element is produced (Svelte escaping
renders it as inert text). A trailing CAPMARK gates the assertion on the caption
actually having rendered, so it can't pass vacuously.
- Stored XSS in comments — post the two render-executing payloads as a comment,
open the lightbox (which loads comments) and assert the same inert-render props.
sse-ticket-abuse (new):
- Minting a ticket requires auth (POST /stream/ticket without Bearer → 401).
- Single-use: after the first open consumes the ticket, replaying it → 401 (a 200
would be capability replay). The first open (→200) also proves a fresh ticket works.
- An unminted/garbage ticket → 401.
All verified green against the live backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn "accept-both-outcomes" documentation tests — which pass whether the app is
secure or vulnerable — into assertions that pin the secure behavior, and replace
fake-UUID authz tests that 404'd before ever reaching the ownership guard with
real cross-user resources.
file-upload-attacks:
- SVG-with-<script> → pin 400 (infer returns None for text → stored-XSS defense);
was [201,400], which accepted the vulnerable outcome.
- zero-byte → pin 400; path-traversal → pin 201 with UUID-derived storage and a
no-path-echo check (both were [201,400]).
- Fix the application/octet-stream rationale (no "application bypass" exists — the
handler ignores the declared type and keys off magic bytes).
authorization-deep (IDOR):
- B deleting A's comment: seed a real upload+comment as A, assert 403, assert the
comment survives, and assert the owner (A) still gets 204 — proving the 403 is
about identity, not a broken route. (Was a DELETE on the all-zeros UUID → 404
before the user_id guard, so authorization was never exercised.)
- Add B-deletes-A's-upload (403, countUploads unchanged) and B-edits-A's-caption
(403, caption intact) — the find_by_id_and_event + ownership guards.
export: pin the ZIP-download 404 (was [404,200] — a 200 is a data-exposure
regression) and split into the two real branches: not-yet-ready, and
ready-but-file-missing (new db.setExportZipReady helper).
All 21 assertions verified green against the live secure backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The running test container had been built from an older tree; rebuilding it to
pick up current app code surfaced several tests that predated shipped
security/UX features:
- file-upload-attacks / rate-limit: assert the magic-byte rejection wording and
upload a real decodable JPEG (a zero-buffer is now rejected at the boundary).
- ddos: open SSE via the single-use /stream/ticket flow, not the dead ?token=.
- recover-page / join: the PIN field auto-submits on the 4th digit, so don't
race an explicit submit click against the ensuing navigation.
- gestures-doubletap / sheet-escape: target the lightbox by aria-labelledby and
anchor the radio accessible-name match at the start (gated dialog semantics).
playwright.config: camera/mic/clipboard are Chromium-only permissions (they threw
"Unknown permission: camera" on firefox/webkit and failed the test at context
creation); grant them per-Chromium-project. firefox-android drops the
unsupported isMobile flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>