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:
@@ -115,7 +115,7 @@ on narrow screens or in the add-printer sidebar (`.form-aside`).
|
|||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
|
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
|
||||||
| `PORT` | `8000` | Server port |
|
| `PORT` | `8000` | Server port |
|
||||||
| `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` | `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:
|
`COOKIE_SECURE=false` degrades the session instead of weakening the credential:
|
||||||
`services/session.cookie_kwargs()` drops `max_age`, so the browser holds the
|
`services/session.cookie_kwargs()` drops `max_age`, so the browser holds the
|
||||||
@@ -126,3 +126,17 @@ through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag,
|
|||||||
and `base.html` renders the `#ephemeral-session-warning` banner plus an extra
|
and `base.html` renders the `#ephemeral-session-warning` banner plus an extra
|
||||||
paragraph in the onboarding modal off it. Changing this touches
|
paragraph in the onboarding modal off it. Changing this touches
|
||||||
`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`.
|
`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_*`.
|
||||||
@@ -52,7 +52,7 @@ Set these under `environment:` in `docker-compose.yml`.
|
|||||||
|------------------|---------|--------------------------------------------------|
|
|------------------|---------|--------------------------------------------------|
|
||||||
| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
|
| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
|
||||||
| `PORT` | `8000` | Port the server listens on inside the container. |
|
| `PORT` | `8000` | Port the server listens on inside the container. |
|
||||||
| `COOKIE_SECURE` | `true` | `Secure` flag on the session cookie. Set to `false` when the app is reached over plain HTTP — see below. |
|
| `COOKIE_SECURE` | `true` | Session mode: `true` (Secure cookie), `false` (plain-HTTP cookie, memory-only) or `single_user` (no cookie, one shared store) — see below. |
|
||||||
|
|
||||||
### `COOKIE_SECURE` and HTTPS
|
### `COOKIE_SECURE` and HTTPS
|
||||||
|
|
||||||
@@ -91,3 +91,34 @@ export DATA_DIR=/tmp/imptune_data
|
|||||||
export COOKIE_SECURE=false
|
export COOKIE_SECURE=false
|
||||||
uvicorn imptune.main:app --reload --port 8000
|
uvicorn imptune.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `COOKIE_SECURE=single_user` — no sessions at all
|
||||||
|
|
||||||
|
For a test box or a local deployment used by one person, sessions are pure
|
||||||
|
friction. `COOKIE_SECURE=single_user` (also accepted: `single-user`, `single`)
|
||||||
|
drops them:
|
||||||
|
|
||||||
|
- No cookie is read or set. Every request — every browser, every device, curl —
|
||||||
|
resolves to **one shared owner**, so all printers, print defaults and clients
|
||||||
|
are simply "the server's".
|
||||||
|
- The onboarding modal, the memory-only warning, the "This session" sidebar menu
|
||||||
|
and both `/session/*` key routes disappear (the routes return `404`). There is
|
||||||
|
no backup key to lose, and none to hand out.
|
||||||
|
- Every page shows a banner stating that whoever reaches the app sees the same
|
||||||
|
data.
|
||||||
|
|
||||||
|
**There is no isolation left in this mode**, so put it only where reaching the
|
||||||
|
app is already the permission — localhost, or a network you trust. Switching an
|
||||||
|
existing deployment over adopts the oldest existing owner, so printers saved
|
||||||
|
under a cookie stay visible; switching back re-enables cookie scoping and hands
|
||||||
|
new browsers a fresh empty session (that same data is then reachable only with
|
||||||
|
its key, which single-user mode never printed — download a backup key *before*
|
||||||
|
switching if you may switch back).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
imptune:
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/data
|
||||||
|
- COOKIE_SECURE=single_user
|
||||||
|
```
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ services:
|
|||||||
# drops it and every request mints a new empty Owner. With `false` the
|
# drops it and every request mints a new empty Owner. With `false` the
|
||||||
# session works but lives only until the browser closes, and the UI warns.
|
# session works but lives only until the browser closes, and the UI warns.
|
||||||
# - COOKIE_SECURE=false
|
# - COOKIE_SECURE=false
|
||||||
|
# Or drop sessions entirely — no cookie, one shared store for everyone who
|
||||||
|
# can reach the app. Test boxes / single-person local prod only.
|
||||||
|
# - COOKIE_SECURE=single_user
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
imptune_data:
|
imptune_data:
|
||||||
+16
-1
@@ -3,10 +3,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
import imptune.config as cfg
|
||||||
from imptune.db.models import Owner
|
from imptune.db.models import Owner
|
||||||
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
|
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
|
||||||
|
|
||||||
@@ -17,9 +18,21 @@ templates = Jinja2Templates(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_cookie_sessions() -> None:
|
||||||
|
"""404 these routes in single-user mode — keys have no meaning without a cookie.
|
||||||
|
|
||||||
|
Restoring one could not re-point anything, and downloading one would hand
|
||||||
|
out the shared owner's bearer key, which turns into a live credential the
|
||||||
|
moment the deployment is switched back to a cookie-scoped mode.
|
||||||
|
"""
|
||||||
|
if cfg.SINGLE_USER:
|
||||||
|
raise HTTPException(status_code=404, detail="Session keys are disabled in single-user mode")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/key/download")
|
@router.get("/key/download")
|
||||||
def download_key(request: Request) -> PlainTextResponse:
|
def download_key(request: Request) -> PlainTextResponse:
|
||||||
"""Mark the current owner permanent and hand back its key as a backup file."""
|
"""Mark the current owner permanent and hand back its key as a backup file."""
|
||||||
|
_require_cookie_sessions()
|
||||||
owner: Owner = request.state.owner
|
owner: Owner = request.state.owner
|
||||||
if not owner.is_permanent:
|
if not owner.is_permanent:
|
||||||
owner.is_permanent = True
|
owner.is_permanent = True
|
||||||
@@ -33,6 +46,7 @@ def download_key(request: Request) -> PlainTextResponse:
|
|||||||
|
|
||||||
@router.get("/restore", response_class=HTMLResponse)
|
@router.get("/restore", response_class=HTMLResponse)
|
||||||
def restore_page(request: Request, error: str = "") -> HTMLResponse:
|
def restore_page(request: Request, error: str = "") -> HTMLResponse:
|
||||||
|
_require_cookie_sessions()
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="session_restore.html",
|
name="session_restore.html",
|
||||||
@@ -43,6 +57,7 @@ def restore_page(request: Request, error: str = "") -> HTMLResponse:
|
|||||||
@router.post("/restore")
|
@router.post("/restore")
|
||||||
def restore_session(request: Request, key: str = Form(...)):
|
def restore_session(request: Request, key: str = Form(...)):
|
||||||
"""Re-associate this browser with a previously downloaded backup key."""
|
"""Re-associate this browser with a previously downloaded backup key."""
|
||||||
|
_require_cookie_sessions()
|
||||||
if not is_same_origin(request):
|
if not is_same_origin(request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
|
|||||||
+22
-2
@@ -7,8 +7,28 @@ load_dotenv()
|
|||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||||
PORT = int(os.environ.get("PORT", "8000"))
|
PORT = int(os.environ.get("PORT", "8000"))
|
||||||
# Set to "false" for local plain-HTTP dev — browsers drop Secure cookies over HTTP.
|
|
||||||
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false"
|
_SINGLE_USER_VALUES = frozenset({"single_user", "single-user", "singleuser", "single"})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cookie_mode(raw: str) -> tuple[bool, bool]:
|
||||||
|
"""Read `COOKIE_SECURE` as a three-way mode → `(cookie_secure, single_user)`.
|
||||||
|
|
||||||
|
- `true` (default): cookie-scoped Owners, `Secure` + persistent cookie.
|
||||||
|
- `false`: cookie-scoped Owners over plain HTTP, memory-only cookie —
|
||||||
|
browsers drop `Secure` cookies on HTTP, so the flag has to come off.
|
||||||
|
- `single_user`: no cookie at all. Every request shares one Owner, so a
|
||||||
|
test box or a local single-person deployment needs no session plumbing.
|
||||||
|
Anyone who can reach the app gets that data — there is no separation
|
||||||
|
left to enforce, which is the point.
|
||||||
|
"""
|
||||||
|
value = raw.strip().lower()
|
||||||
|
if value in _SINGLE_USER_VALUES:
|
||||||
|
return False, True
|
||||||
|
return value != "false", False
|
||||||
|
|
||||||
|
|
||||||
|
COOKIE_SECURE, SINGLE_USER = parse_cookie_mode(os.environ.get("COOKIE_SECURE", "true"))
|
||||||
|
|
||||||
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
|
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
|
||||||
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
|
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ def cookie_kwargs() -> dict:
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def single_user_owner() -> Owner:
|
||||||
|
"""The one Owner every request shares under `COOKIE_SECURE=single_user`.
|
||||||
|
|
||||||
|
No cookie is read, so the owner has to be findable from the DB alone: the
|
||||||
|
oldest row wins. That way a deployment that used to run cookie-scoped keeps
|
||||||
|
the printers it already had instead of waking up empty, and repeat requests
|
||||||
|
never mint a second row. It is marked permanent so nothing treats it as a
|
||||||
|
throwaway first-visit session.
|
||||||
|
"""
|
||||||
|
owner = Owner.select().order_by(Owner.id).first()
|
||||||
|
if owner is None:
|
||||||
|
owner = Owner.create(key=generate_key(), is_permanent=True)
|
||||||
|
return owner
|
||||||
|
|
||||||
|
|
||||||
def _request_hosts(request: Request) -> set[str]:
|
def _request_hosts(request: Request) -> set[str]:
|
||||||
"""Every host spelling that legitimately identifies this deployment."""
|
"""Every host spelling that legitimately identifies this deployment."""
|
||||||
hosts = {request.url.netloc}
|
hosts = {request.url.netloc}
|
||||||
@@ -98,6 +113,16 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
|||||||
if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES):
|
if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
if cfg.SINGLE_USER:
|
||||||
|
# No cookie is read and none is set: identity comes from the DB, so
|
||||||
|
# onboarding ("keep your printers?") and the ephemeral-cookie
|
||||||
|
# warning are both meaningless here.
|
||||||
|
request.state.owner = single_user_owner()
|
||||||
|
request.state.is_new_owner = False
|
||||||
|
request.state.ephemeral_session = False
|
||||||
|
request.state.single_user = True
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
key = request.cookies.get(COOKIE_NAME)
|
key = request.cookies.get(COOKIE_NAME)
|
||||||
owner = Owner.get_or_none(Owner.key == key) if key else None
|
owner = Owner.get_or_none(Owner.key == key) if key else None
|
||||||
is_new = owner is None
|
is_new = owner is None
|
||||||
@@ -106,6 +131,7 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
request.state.owner = owner
|
request.state.owner = owner
|
||||||
request.state.is_new_owner = is_new
|
request.state.is_new_owner = is_new
|
||||||
|
request.state.single_user = False
|
||||||
# Templates warn about it; see cookie_kwargs().
|
# Templates warn about it; see cookie_kwargs().
|
||||||
request.state.ephemeral_session = not cfg.COOKIE_SECURE
|
request.state.ephemeral_session = not cfg.COOKIE_SECURE
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -1203,16 +1203,20 @@ div.error > p {
|
|||||||
|
|
||||||
.error p, .notice p { margin: 0; }
|
.error p, .notice p { margin: 0; }
|
||||||
|
|
||||||
/* Memory-only-session banner (COOKIE_SECURE=false): icon, message, and the
|
/* Session-mode banners — memory-only (COOKIE_SECURE=false) and single-user
|
||||||
escape hatch on one line, stacking on narrow screens. */
|
(COOKIE_SECURE=single_user): icon, message, and the escape hatch on one
|
||||||
.notice.session-ephemeral {
|
line, stacking on narrow screens. */
|
||||||
|
.notice.session-ephemeral,
|
||||||
|
.notice.session-single-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .6rem;
|
gap: .6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.session-ephemeral p { flex: 1; min-width: 12rem; }
|
.notice.session-ephemeral p,
|
||||||
.notice.session-ephemeral .ico { flex: none; }
|
.notice.session-single-user p { flex: 1; min-width: 12rem; }
|
||||||
|
.notice.session-ephemeral .ico,
|
||||||
|
.notice.session-single-user .ico { flex: none; }
|
||||||
|
|
||||||
.notice.session-ephemeral .btn {
|
.notice.session-ephemeral .btn {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -1226,7 +1230,8 @@ div.error > p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.notice.session-ephemeral { flex-wrap: wrap; }
|
.notice.session-ephemeral,
|
||||||
|
.notice.session-single-user { flex-wrap: wrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice details { margin: .4rem 0 0; }
|
.notice details { margin: .4rem 0 0; }
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
session_restore_key: 'Restaurer depuis une clé',
|
session_restore_key: 'Restaurer depuis une clé',
|
||||||
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
|
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
|
||||||
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
|
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
|
||||||
|
single_user_notice: 'Mode mono-utilisateur : tout est enregistré sur le serveur sans cookie de session, et toute personne qui accède à cette application voit les mêmes imprimantes.',
|
||||||
open_menu: 'Ouvrir le menu',
|
open_menu: 'Ouvrir le menu',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
|
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
|
||||||
@@ -277,6 +278,7 @@
|
|||||||
session_restore_key: 'Restore from a key',
|
session_restore_key: 'Restore from a key',
|
||||||
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
|
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
|
||||||
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
|
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
|
||||||
|
single_user_notice: 'Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.',
|
||||||
open_menu: 'Open menu',
|
open_menu: 'Open menu',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashboard_intro: 'Three steps from driver ZIP to deployable package.',
|
dashboard_intro: 'Three steps from driver ZIP to deployable package.',
|
||||||
@@ -459,6 +461,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Single-user mode has no cookie and no keys — the whole menu would 404. #}
|
||||||
|
{% if not request.state.single_user %}
|
||||||
<div class="sidebar-foot">
|
<div class="sidebar-foot">
|
||||||
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
|
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
|
||||||
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
|
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
|
||||||
@@ -469,6 +473,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="main-wrapper">
|
<div class="main-wrapper">
|
||||||
@@ -497,6 +502,13 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
|
{% if request.state.single_user %}
|
||||||
|
{# COOKIE_SECURE=single_user: one shared store, no cookie, no isolation. #}
|
||||||
|
<div class="notice session-single-user" id="single-user-notice">
|
||||||
|
{{ ico.i('key', 16) }}
|
||||||
|
<p x-data x-text="$store.i18n.t('single_user_notice')">Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if request.state.ephemeral_session %}
|
{% if request.state.ephemeral_session %}
|
||||||
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
|
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
|
||||||
dies with the browser window. Say it before work is lost. #}
|
dies with the browser window. Say it before work is lost. #}
|
||||||
|
|||||||
@@ -268,3 +268,96 @@ def test_restore_also_issues_a_session_cookie_when_insecure(tmp_data_dir, monkey
|
|||||||
assert resp.status_code == 303
|
assert resp.status_code == 303
|
||||||
header = _set_cookie_header(resp)
|
header = _set_cookie_header(resp)
|
||||||
assert "Max-Age" not in header and "Expires" not in header
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user