Files
ImpTune/CLAUDE.md
T
kawaandClaude Haiku 4.5 f70ba93e7a feat: session mode parsing, memory-only cookies, single-user mode
- COOKIE_SECURE env now accepts: 'true' (secure), 'false' (memory-only), 'single_user' (no cookie)
- config.parse_cookie_mode() returns (COOKIE_SECURE, SINGLE_USER) tuple for routing
- single_user_owner() returns oldest Owner for test/local deployments
- session cookie respects mode: Max-Age only in secure mode, dropped for memory-only
- base.html renders ephemeral-session and single-user banners per mode
- Tests: comprehensive coverage for all three modes with monkeypatch configs
- CLAUDE.md + README docs updated

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-05 09:21:18 +02:00

142 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Guide for Claude Code (claude.ai/code) work in repo.
## Commands
**Run tests:**
```bash
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
export COOKIE_SECURE=false # plain HTTP — omit if serving behind TLS
uvicorn imptune.main:app --reload --port 8000
```
**Docker:**
```bash
docker-compose up
docker build -t imptune .
```
**Install deps:**
```bash
pip install -r requirements.txt -r requirements-dev.txt
```
## Architecture
ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaRMM) from Windows driver ZIPs + web UI. No external services — single FastAPI + SQLite + Docker volume.
**Request flow:**
1. Driver upload → `api/drivers.py``services/inf_parser.py` parse INF → `storage/driver_store.py` store by SHA256 → Peewee `Driver` record (shared/global — visible to every Owner)
2. Printer config → `api/printers.py``db/models.py` `Printer` record (links Driver FK, scoped to `request.state.owner`)
3. Icon upload → `api/icons.py` → Pillow validate PNG 256×256 → SHA256 storage → `Icon` record
4. Package export → `api/packages.py``generators/script_generator.py` render Jinja2 PS1 templates → `generators/intunewin_builder.py` encrypt ZIP (AES-256-CBC + HMAC-SHA256)
**Key modules:**
- `imptune/config.py``DATA_DIR`, `DB_PATH`, `DRIVERS_DIR`, `ICONS_DIR`, `COOKIE_SECURE` from env
- `imptune/db/database.py` — SQLite WAL mode + `foreign_keys=1`; all models inherit `BaseModel`; `init_db()` also backfills `owner_id` on pre-per-owner-scoping DBs into a synthetic legacy `Owner` (key written to `{DATA_DIR}/legacy_owner_key.txt`)
- `imptune/services/session.py``OwnerSessionMiddleware` resolves `request.state.owner` from the `imptune_owner_key` cookie, creating one on first visit (skips `/health`)
- `imptune/services/inf_parser.py` — auto-detect encoding (UTF-16/UTF-8/cp1252), resolve `%TOKEN%` from `[Strings]`, handle multi-model INFs
- `imptune/generators/intunewin_builder.py` — Python-native `.intunewin` (ZIP-in-ZIP); IV 16 bytes (not 32); match reference tool `1.8.6.0` output
- `imptune/templates/scripts/` — Jinja2 templates for `install.ps1`, `uninstall.ps1`, `detect.ps1`
**Per-owner storage:** `Printer`/`Client` (groups) are scoped to an `Owner` identified by an opaque bearer key in a cookie — no accounts. `Driver` stays global/shared. Every route taking a `printer_id`/`client_id` must filter/check `.owner == request.state.owner` (404, not 403, on mismatch) — printer IDs are small sequential ints, so a list-only filter isn't enough. Onboarding modal (`templates/base.html`, gated on `request.state.is_new_owner`) offers "download backup key" (`GET /session/key/download`, marks `Owner.is_permanent`) vs. temporary; `/session/restore` re-attaches a browser to a previously downloaded key. In tests, use the `owner` fixture (`tests/conftest.py`) when creating `Printer`/`Client` rows directly via the ORM so the `client` fixture's cookie-scoped requests can see them.
**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:**
- WOW64 64-bit relaunch guard (Intune run 32-bit, `pnputil` need 64-bit)
- UAC self-elevation for user context (SYSTEM context skip)
- Two-step: `pnputil /add-driver` then `Add-PrinterDriver` + `Add-PrinterPort` + `Add-Printer`
- All idempotent (`-ErrorAction SilentlyContinue`)
## Test Setup
`conftest.py` monkeypatch `config.DATA_DIR` + `config.DB_PATH` to temp dir per test. `client` fixture yield `TestClient(app)` with isolated SQLite. E2E in `tests/e2e/` use Playwright.
## Environment Variables
| Var | Default | Purpose |
|-----|---------|---------|
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
| `PORT` | `8000` | Server port |
| `COOKIE_SECURE` | `true` | Three-way session mode, parsed by `config.parse_cookie_mode()` into `(COOKIE_SECURE, SINGLE_USER)`: `true` = Secure + 10-year cookie; `false` = plain-HTTP serving (browser drops a Secure cookie → new Owner per request), cookie becomes **memory-only** (no `Max-Age`); `single_user` (or `single-user`/`single`) = no cookie at all, one shared Owner — 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_*`.
`COOKIE_SECURE=single_user` (`cfg.SINGLE_USER`) removes sessions for test boxes
and single-person local prod: the middleware never reads or sets a cookie and
returns `services/session.single_user_owner()` — the **oldest** `Owner` row,
created on demand — so a deployment switched over from cookie mode keeps the
printers it already had and no second row is ever minted. `request.state.owner`
is still what every route filters on, so per-owner query code is unchanged.
`request.state.single_user` gates the sidebar "This session" menu and the
`#single-user-notice` banner in `base.html`; `is_new_owner`/`ephemeral_session`
are forced `False` (no onboarding modal, no memory-only warning). All three
`/session/*` routes 404 via `api/session._require_cookie_sessions()` — a key
can't re-point a cookie that isn't read, and downloading one would leak the
shared owner's bearer credential for a later switch back to cookie mode. Tests:
`test_session.py::test_single_user_*` + `test_parse_cookie_mode_*`.