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>
This commit is contained in:
2026-08-05 09:21:18 +02:00
co-authored by Claude Haiku 4.5
parent a8bcf7cdeb
commit f70ba93e7a
9 changed files with 231 additions and 12 deletions
+93
View File
@@ -268,3 +268,96 @@ def test_restore_also_issues_a_session_cookie_when_insecure(tmp_data_dir, monkey
assert resp.status_code == 303
header = _set_cookie_header(resp)
assert "Max-Age" not in header and "Expires" not in header
# ---------------------------------------------------------------------------
# COOKIE_SECURE=single_user — no cookie at all, one shared Owner
# ---------------------------------------------------------------------------
def test_parse_cookie_mode_reads_three_modes():
from imptune.config import parse_cookie_mode
assert parse_cookie_mode("true") == (True, False)
assert parse_cookie_mode("") == (True, False) # unset-ish → secure default
assert parse_cookie_mode("False") == (False, False)
for spelling in ("single_user", "single-user", "SingleUser", " single "):
assert parse_cookie_mode(spelling) == (False, True)
def _single_user(monkeypatch):
import imptune.config as cfg
monkeypatch.setattr(cfg, "SINGLE_USER", True)
monkeypatch.setattr(cfg, "COOKIE_SECURE", False)
def test_single_user_mode_sets_no_cookie_and_skips_onboarding(tmp_data_dir, monkeypatch):
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as fresh:
resp = fresh.get("/")
assert resp.status_code == 200
assert COOKIE_NAME not in fresh.cookies
assert not [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"]
assert "session-choice-modal" not in resp.text
assert "ephemeral-session-warning" not in resp.text
assert "single-user-notice" in resp.text
def test_single_user_mode_shares_data_across_browsers(tmp_data_dir, monkeypatch):
"""The point of the mode: a cookie-less client sees the same printers."""
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as client_a, TestClient(app) as client_b:
client_a.post(
"/printers",
data={"name": "Shared Printer", "ip_address": "10.0.7.1", "port_name": "IP_S"},
follow_redirects=False,
)
assert "Shared Printer" in client_b.get("/printers").text
def test_single_user_mode_creates_exactly_one_owner(tmp_data_dir, monkeypatch):
from imptune.db.models import Owner
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as fresh:
for _ in range(4):
assert fresh.get("/printers").status_code == 200
assert Owner.select().count() == 1
def test_single_user_mode_adopts_the_existing_owner(tmp_data_dir, monkeypatch):
"""Switching a cookie-scoped deployment over must not hide its printers."""
from imptune.main import app
with TestClient(app) as cookie_client:
cookie_client.post(
"/printers",
data={"name": "Pre-switch Printer", "ip_address": "10.0.7.2", "port_name": "IP_T"},
follow_redirects=False,
)
_single_user(monkeypatch)
with TestClient(app) as after_switch:
assert "Pre-switch Printer" in after_switch.get("/printers").text
def test_single_user_mode_disables_session_key_routes(tmp_data_dir, monkeypatch):
"""No cookie to re-point, and the shared owner's key must not leak."""
from imptune.main import app
_single_user(monkeypatch)
with TestClient(app) as fresh:
assert fresh.get("/session/key/download").status_code == 404
assert fresh.get("/session/restore").status_code == 404
assert fresh.post(
"/session/restore",
data={"key": "anything"},
headers={"origin": "http://testserver"},
).status_code == 404
assert "/session/key/download" not in fresh.get("/printers").text