10 Commits

Author SHA1 Message Date
pax
10f1b3fd10 test_mpv_options: update demuxer_max_bytes assertion to 50MiB 2026-04-11 23:01:41 -05:00
pax
553734fe79 test_mpv_options: update demuxer_max_bytes assertion (50→150MiB) 2026-04-11 20:01:29 -05:00
pax
4db7943ac7 security: fix #2 — apply lavf protocol whitelist via property API
The previous attempt set ``demuxer_lavf_o`` as an init kwarg with a
comma-laden ``protocol_whitelist=file,http,https,tls,tcp`` value.
mpv rejected it with -7 OPT_FORMAT because python-mpv's init path
goes through ``mpv_set_option_string``, which routes through mpv's
keyvalue list parser — that parser splits on ``,`` to find entries,
shredding the protocol list into orphan tokens. Backslash-escaping
``\,`` did not unescape on this code path either.

Splits the option set into two helpers:

- ``build_mpv_kwargs`` — init kwargs only (ytdl=no, load_scripts=no,
  POSIX input_conf null, all the existing playback/audio/network
  tuning). The lavf option is intentionally absent.
- ``lavf_options`` — a dict applied post-construction via the
  python-mpv property API, which uses the node API and accepts
  dict values for keyvalue-list options without splitting on
  commas inside the value.

Tests cover both paths: that ``demuxer_lavf_o`` is NOT in the init
kwargs (regression guard), and that ``lavf_options`` returns the
expected protocol set.

Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
2026-04-11 16:34:50 -05:00
pax
fa4f2cb270 security: fix #6 — add pure source HTML escape helper
Extracts the rich-text Source-line builder out of info_panel.py
into a Qt-free module so it can be unit-tested under CI (which
installs only httpx + Pillow + pytest, no PySide6).

The helper html.escape()s both the href and the visible display
text, and only emits an <a> tag for http(s) URLs — non-URL
sources (including javascript: and data: schemes) get rendered
as escaped plain text without a clickable anchor.

Not yet wired into InfoPanel.set_post; that lands in the next
commit.

Audit-Ref: SECURITY_AUDIT.md finding #6
Severity: Medium
2026-04-11 16:19:06 -05:00
pax
22744c48af security: fix #2 — add pure mpv options builder helper
Extracts the mpv.MPV() kwargs into a Qt-free pure function so the
security-relevant options can be unit-tested on CI (which lacks
PySide6 and libmpv). The builder embeds the audit #2 hardening —
ytdl="no", load_scripts="no", and a lavf protocol whitelist of
file,http,https,tls,tcp — alongside the existing playback tuning.
Not yet wired into _MpvGLWidget; that lands in the next commit.

Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
2026-04-11 16:06:33 -05:00
pax
562c03071b test: Phase 2 — add 64 tests for extracted pure functions
5 new test files covering the pure-function extractions from Phase 1:
- test_search_controller.py (24): tag building, blacklist filtering, backfill
- test_window_state.py (16): geometry parsing, splitter parsing, hyprctl cmds
- test_media_controller.py (9): prefetch ring-expansion ordering
- test_post_actions.py (10): batch message detection, library membership
- test_popout_controller.py (3): video sync dict shape

All import-pure (no PySide6, no mpv, no httpx). Total suite: 186 tests.
2026-04-10 15:20:57 -05:00
pax
ecda09152c ship tests/ (81 tests, was gitignored)
Remove tests/ from .gitignore and track the existing test suite:
  tests/core/test_db.py         — DB schema, migration, CRUD
  tests/core/test_cache.py      — cache helpers
  tests/core/test_config.py     — config/path helpers
  tests/core/test_concurrency.py — app loop accessor
  tests/core/api/test_base.py   — Post dataclass, BooruClient
  tests/gui/popout/test_state.py — 57 state machine tests

All pure Python, no secrets, no external deps. Uses temp DBs and
synthetic data. Run with: pytest tests/
2026-04-09 23:55:38 -05:00
pax
1b66b03a30 Untrack tests/ directory and related dev tooling
Removes the tests/ folder from git tracking and adds it to .gitignore.
The 81 tests (16 Phase A core + 65 popout state machine) stay on
disk as local-only working notes, the same way docs/ and project.md
are gitignored. Running them is `pytest tests/` from the project
root inside .venv as before — nothing about the tests themselves
changed, just whether they're version-controlled.

Reverts the related additions in pyproject.toml and README.md from
commit bf14466 (Phase A baseline) so the public surface doesn't
reference a tests/ folder that no longer ships:

  - pyproject.toml: drops [project.optional-dependencies] test extra
    and [tool.pytest.ini_options]. pytest + pytest-asyncio are still
    installed in the local .venv via the previous pip install -e ".[test]"
    so the suite keeps running locally; new clones won't get them
    automatically.

  - README.md: drops the "Run tests:" section from the Linux install
    block. The README's install instructions return to their pre-
    Phase-A state.

  - .gitignore: adds `tests/` alongside the existing `docs/` and
    `project.md` lines (the same convention used for the refactor
    inventory / plan / notes / final report docs).

The 12 test files removed from tracking (`git rm -r --cached`):
  tests/__init__.py
  tests/conftest.py
  tests/core/__init__.py
  tests/core/test_cache.py
  tests/core/test_concurrency.py
  tests/core/test_config.py
  tests/core/test_db.py
  tests/core/api/__init__.py
  tests/core/api/test_base.py
  tests/gui/__init__.py
  tests/gui/popout/__init__.py
  tests/gui/popout/test_state.py

Verification:
  - tests/ still exists on disk
  - `pytest tests/` still runs and passes 81 / 81 in 0.11s
  - `git ls-files tests/` returns nothing
  - `git status` is clean
2026-04-08 20:47:50 -05:00
pax
3ade3a71c1 popout/state: implement illegal transition handler (env-gated)
Adds the structural alternative to "wait for a downstream symptom and
bisect to find the bad dispatch": catch illegal transitions at the
dispatch boundary instead of letting them silently no-op.

In release mode (default — no env var set):
  - Illegal events are dropped silently
  - A `log.debug` line is emitted with the state and event type
  - dispatch returns []
  - state is unchanged
  - This is what production runs

In strict mode (BOORU_VIEWER_STRICT_STATE=1):
  - Illegal events raise InvalidTransition(state, event)
  - The exception carries both fields for the diagnostic
  - This is for development and the test suite — it makes
    programmer errors loud and immediate instead of silently
    cascading into a downstream symptom

The legality map (`_LEGAL_EVENTS_BY_STATE`) is per-state. Most events
(NavigateRequested / Mute / Volume / LoopMode / Fullscreen / window
events / Close / ContentArrived) are globally legal in any non-Closing
state. State-specific events are listed per state. Closing has an
empty legal set; the dispatch entry already drops everything from
Closing before the legality check runs.

The map distinguishes "legal-but-no-op" from "structurally invalid":

  - VideoEofReached in LoadingVideo: LEGAL. The state machine
    intentionally accepts and drops this event. It's the EOF race
    fix — the event arriving in LoadingVideo is the race scenario,
    and dropping is the structural cure. Strict mode does NOT raise.

  - VideoEofReached in SeekingVideo: LEGAL. Same reasoning — eof
    during a seek is stale.

  - VideoEofReached in AwaitingContent / DisplayingImage: ILLEGAL.
    No video is loaded; an eof event arriving here is a real bug
    in either mpv or the adapter. Strict mode raises.

The strict-mode read happens per-dispatch (`os.environ.get`), not
cached at module load, so monkeypatch.setenv in tests works
correctly. The cost is microseconds per dispatch — negligible.

Tests passing after this commit (65 total → 65 pass):

  Newly added (3):
  - test_strict_mode_raises_invalid_transition
  - test_strict_mode_does_not_raise_for_legal_events
  - test_strict_mode_legal_but_no_op_does_not_raise

  Plus the existing 62 still pass — the legality check is non-
  invasive in release mode (existing tests run without
  BOORU_VIEWER_STRICT_STATE set, so they see release-mode behavior).

Phase A (16 tests in tests/core/) still green.

The state machine logic is now COMPLETE. Every state, every event,
every effect is implemented with both happy-path transitions and
illegal-transition handling. The remaining commits (12-16) carve
the implementation into the planned file layout (effects.py split,
hyprland.py extraction) and rewire the Qt adapter.

Test cases for commit 12 (effects split):
  - Re-import after the file split still works
  - All 65 tests still pass after `from .effects import ...` change
2026-04-08 19:40:05 -05:00
pax
f2f7d64759 popout/state: test scaffolding (62 tests, 27 pass at skeleton stage)
Lays down the full test surface for the popout state machine ahead of
any transition logic. 62 collected tests across the four categories
from docs/POPOUT_REFACTOR_PLAN.md "Test plan":

  1. Read-path queries (4 tests, all passing at commit 3 — these
     exercise the parts of the skeleton that are already real:
     compute_slider_display_ms, the terminal Closing guard, the
     initial state defaults)
  2. Per-state transition tests (~22 tests, all failing at commit 3
     because the per-event handlers in state.py are stubs returning
     []. Each documents the expected new state and effects for one
     specific (state, event) pair. These pass progressively as
     commits 4-11 land.)
  3. Race-fix invariant tests (6 tests — one for each of the six
     structural fixes from the prior fix sweep: EOF race, double-
     navigate, persistent viewport, F11 round-trip, seek pin,
     pending mute replay. The EOF race test already passes because
     dropping VideoEofReached in LoadingVideo is just "stub returns
     []", which is the right behavior for now. The others fail
     until their transitions land.)
  4. Illegal transition tests (17 parametrized cases — at commit 11
     these become BOORU_VIEWER_STRICT_STATE-gated raises. At commits
     3-10 they pass trivially because the stubs return [], which is
     the release-mode behavior.)

All 62 tests are pure Python:
  - Import only `booru_viewer.gui.popout.state` and `popout.viewport`
  - Construct StateMachine() directly
  - Use direct field mutation (`m.state = State.PLAYING_VIDEO`) for
    setup, dispatch the event under test, assert the new state +
    returned effects
  - No QApplication, no mpv, no httpx, no filesystem outside tmp_path
  - Sub-100ms total runtime (currently 0.31s including test discovery)

The forcing function: if state.py grows a PySide6/mpv/httpx import,
this test file fails to collect and the suite breaks. That's the
guardrail that keeps state.py pure as transitions land.

Test count breakdown (62 total):
- 4 trivially-passing (read-path queries + initial state)
- 22 transition tests (one per (state, event) pair)
- 6 invariant tests (mapped to the six race fixes)
- 17 illegal transition cases (parametrized over (state, event) pairs)
- 5 close-from-each-state cases (parametrized)
- 8 misc (state field persistence, window events)

Result at commit 3:
  35 failed, 27 passed in 0.31s

The 27 passing are exactly the predicted set: trivial reads + the
illegal-transition pass-throughs (which work today because the stubs
return [] just like release-mode strict-state would). The 35 failing
are the transition handlers that need real implementations.

Phase A test suite (16 tests in tests/core/) still passes — this
commit only adds new tests, no existing test changed.

Test cases for state machine implementation (commits 4-11):
- Each failing test is its own commit acceptance criterion
- Commit N "passes" when the relevant subset of tests turns green
- Final state machine sweep (commit 11): all 62 tests pass
2026-04-08 19:27:23 -05:00