feat: memory-only sessions on HTTP, streamed exports, UI refresh

Session

- COOKIE_SECURE=false no longer persists the owner key for ten years.
  services/session.cookie_kwargs() drops max_age in that mode, so the
  browser holds the key in memory and the session ends with the window.
  Everything still persists server-side; only the browser link is
  temporary. base.html shows a warning banner (FR/EN) and an extra
  paragraph in the onboarding modal, and the README explains the
  trade-off and the backup-key escape hatch.
- Both cookie writers (middleware, POST /session/restore) go through
  cookie_kwargs() so the policy cannot drift between them.
- The CSRF guard on /session/restore compared request.url.scheme against
  the Origin header. Behind a TLS-terminating proxy uvicorn sees http
  while the browser sends https, so every legitimate restore was
  rejected with 403. It now compares hosts only, including
  X-Forwarded-Host.
- /static/*, /favicon.ico and /robots.txt skip the middleware. Each
  cookieless hit was inserting an Owner row no browser could ever use.

Reliability

- Malformed printer-form FK fields no longer escape as HTTP 500:
  a non-numeric client_id/driver_id raised ValueError and an unknown
  driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX
  fragments, and the duplicated field checks moved into
  _validate_fields().
- Package exports stream. build_intunewin() encrypts the inner ZIP in
  1 MB chunks against temp files with a streaming HMAC and SHA256, and
  both endpoints serve the result with FileResponse plus a background
  cleanup task. A 100 MB driver used to be held in memory three or four
  times over per concurrent download. The byte layout is unchanged.
- FileResponse also escapes the download filename, which was previously
  interpolated raw into Content-Disposition.
- python-multipart >= 0.0.18 (CVE-2024-53981, reachable from
  /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from
  icon upload).
- icons.py reads cfg.ICONS_DIR instead of re-deriving the path from
  DATA_DIR, matching the .intunewin export.

UI

- Sidebar/topbar shell, inline SVG icon macros (partials/icons.html),
  card and data-table components, grouped printer list, and the
  dedicated /printers/new page replacing partials/printer_form.html.

Tests

- 194 pass with a bare `pytest tests/`: tests/conftest.py now forces
  cfg.COOKIE_SECURE = False like the e2e conftest already did, so the
  Secure cookie is no longer dropped over http://testserver.
- New coverage for the malformed-FK guards, the chunk-boundary cases in
  the encrypt loop (every residue mod _CHUNK plus a multi-megabyte
  payload), temp-dir cleanup after both exports, and the whole
  COOKIE_SECURE matrix.
- test_printer_edit.py located the Edit button by its translated label,
  so it only passed on English-locale machines. It now targets the
  showModal() hook, which also cuts the e2e run from 84s to 15s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 17:58:49 +02:00
co-authored by Claude Opus 5
parent ed41f7f520
commit b397d3dc3d
38 changed files with 3739 additions and 1014 deletions
+59 -3
View File
@@ -6,11 +6,18 @@ Guide for Claude Code (claude.ai/code) work in repo.
**Run tests:**
```bash
pytest tests/
pytest tests/unit/test_inf_parser.py # single test file
pytest tests/ # whole suite (no env vars needed)
pytest tests/test_inf_parser.py # single test file (no tests/unit/ dir)
pytest tests/ -k "test_name" # single test by name
```
Both `tests/conftest.py` (`tmp_data_dir`) and `tests/e2e/conftest.py` force
`cfg.COOKIE_SECURE = False`, because `TestClient` talks plain HTTP to
`http://testserver` and a `Secure` cookie would be dropped — every request would
land on a *new* `Owner` and ~41 tests would 404. Tests asserting the `Secure`
branch (`test_secure_mode_*` in `tests/test_session.py`) monkeypatch it back to
`True`.
**Run dev server:**
```bash
export DATA_DIR=/tmp/imptune_data
@@ -51,6 +58,45 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
**UI stack:** Pico CSS + HTMX 2 + Alpine.js 3 + Jinja2 server-side templates.
**Design layer (`static/app.css`):** a token + component layer over Pico. Tokens
(`--im-*`) are declared three times — `:root:not([data-theme=dark])`, the
`prefers-color-scheme: dark` block, and `[data-theme=dark]` — mirroring Pico's
own selectors so equal specificity + later source order wins; a new color must be
added to all three. Pico vars are remapped from those tokens, so use `--im-*` in
components. Prose is set in the system UI face, machine values (IPs, ports, INF
names, PS commands) in `--im-mono`. Components: `.card`, `.rail` (the
driver → printer → package pipeline on the dashboard), `.data-table`, `.badge`,
`.pill`, `.kv`, `.cmd`, `.empty`, `.form-section`, `.toolbar`. Because the edit
dialog renders inside a table cell, `dialog` resets inherited `text-align` /
`white-space` — keep that.
**Shell:** `base.html` owns the sidebar + topbar; pages fill the `crumb`,
`page_title`, `page_actions`, and `content` blocks and must not render their own
`<h1>`. Icons come from `{% import "partials/icons.html" as ico %}`
`{{ ico.i('printer') }}` — inline SVG with no text nodes, because E2E tests read
`textContent` of nav links to assert the translated label. Nav links are
`{{ ico.i(...) }}<span x-text="...">`: never add count badges or other text
inside them.
**i18n:** every user-facing string goes through `$store.i18n.t('key')` with the
English text as the element's fallback body, and keys must be added to *both*
`fr` and `en` in `base.html`. Server-rendered HTMX fragments (icon-upload
confirmation, `_error_response`) are English-only.
The store's default language follows `navigator.language`, so E2E specs must
**never locate a control by its visible label**`button:has-text('Edit')`
matched only on English-locale machines and timed out everywhere else. Target a
structural hook instead (`button[onclick*='showModal']`), except in
`test_i18n_toggle.py`, which asserts the labels on purpose and pins
`locale=` per context.
**Client-side filter:** `Alpine.store('filter')` holds the printer search text.
Rows and group cards carry `data-search` (lowercased) and `x-show` off that
store, so HTMX-swapped rows keep filtering. A group's `data-search` must be a
superset of its rows' — otherwise a matching row hides inside a hidden group.
`.col-defaults` / `.col-arch` / `.col-used` / `.col-added` mark columns dropped
on narrow screens or in the add-printer sidebar (`.form-aside`).
**HTMX pattern:** Forms `hx-post`, swap `#driver-list` / `#printer-list` / `#client-list` targets. Errors return inline HTML fragments (HTTP 400/409) via `_error_response()`. Success return partials from `templates/partials/`.
**PowerShell install script notes:**
@@ -69,4 +115,14 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
|-----|---------|---------|
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
| `PORT` | `8000` | Server port |
| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for local plain-HTTP dev (`uvicorn --reload`) or the browser drops the cookie and a new Owner is created on every request. |
| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for plain-HTTP serving or the browser drops the cookie and a new Owner is created on every request. `false` also makes the cookie **memory-only** (no `Max-Age`) — see below. |
`COOKIE_SECURE=false` degrades the session instead of weakening the credential:
`services/session.cookie_kwargs()` drops `max_age`, so the browser holds the
owner key in memory and the session ends when the window closes. Everything
still persists server-side; only the browser's link to it is temporary. Both
cookie-setting call sites (the middleware and `POST /session/restore`) must go
through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag,
and `base.html` renders the `#ephemeral-session-warning` banner plus an extra
paragraph in the onboarding modal off it. Changing this touches
`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`.