phase: 11_admin_login
**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.
**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).
**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.
**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.
**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.
**Next pending phase**: `12_nut_self_test_verdict`.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"""How a test gets a signed-in client, and the credential it signs in with.
|
||||
|
||||
Phase 11 task 03 put every route except `PUBLIC_PATHS` behind a session, so a TestClient that
|
||||
used to `GET /api/config` now gets a 401 unless it signs in first. This module is the one place
|
||||
that does it, for the same reason `tests/card_contract.py` is the one place the card paths are
|
||||
written down: if signing in is a sequence twelve test files have to remember, one of them will
|
||||
remember it wrong and assert the 401.
|
||||
|
||||
`sign_in` also copies the CSRF token onto the client's default headers, which is what keeps the
|
||||
twelve existing `client.post(`/`ups_client.post(` assertions in `tests/test_api.py` unedited:
|
||||
they get the session cookie *and* the write header for free, so the assertions still assert the
|
||||
UPS behaviour they were written for instead of the new gate.
|
||||
|
||||
The password itself lives in `auth_contract.py` (with the cheap PBKDF2 cost the suite hashes it
|
||||
at) and is re-exported here, so `from auth_client import ADMIN_PASSWORD, sign_in` works and
|
||||
there is still exactly one file that says what the testing password is.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2 # re-exported: one constant, two doors
|
||||
from dashboards import auth
|
||||
|
||||
__all__ = ["ADMIN_PASSWORD", "FAST_PBKDF2", "sign_in"]
|
||||
|
||||
|
||||
def sign_in(client: TestClient, password: str = ADMIN_PASSWORD,
|
||||
**payload: Any) -> dict[str, Any]:
|
||||
"""Log the client in and make its writes carry the CSRF token.
|
||||
|
||||
Returns the login body (`{ok, username, expires_at, csrf, next}`) so a test can assert on it
|
||||
without repeating the request. The assertion that the sign-in *worked* is part of the helper:
|
||||
a test that silently started from a 401 would otherwise fail three lines later on something
|
||||
that looks like an unrelated bug.
|
||||
|
||||
Pass extra keyword arguments to reach the parts of the endpoint the pages do not use (a
|
||||
`next`, or a body the login form would never send).
|
||||
"""
|
||||
response = client.post("/api/auth/login", json={"password": password, **payload})
|
||||
assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}"
|
||||
body: dict[str, Any] = response.json()
|
||||
# On the client, not per request: the ~200 existing HTTP assertions in test_api.py post to
|
||||
# the one write endpoint, and a token the helper did not attach would 403 every one of them.
|
||||
client.headers[auth.CSRF_HEADER] = str(body.get("csrf", ""))
|
||||
return body
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The admin credential the HTTP suite boots with, and the cost it hashes it at.
|
||||
|
||||
Phase 11 made every app start need a password: with no `auth.verifier` row and no
|
||||
`DASH_ADMIN_PASSWORD` the lifespan raises instead of serving the fleet (see
|
||||
`auth.load_auth_state`). So every test that runs a lifespan passes one, the same way the
|
||||
operator's `.env.dev` does, and `testpass` is the phase's local-testing password — never a
|
||||
production fallback.
|
||||
|
||||
The cost is the other half. The real 600 000 rounds cost ~0.3 s per hash, and the HTTP suite
|
||||
starts a server a hundred-odd times; hashing at 1 000 rounds is the documented purpose of
|
||||
`DASH_PBKDF2_ITERATIONS` and keeps that overhead at microseconds. The real figure is pinned
|
||||
where it belongs — `tests/test_auth.py::test_the_default_cost_is_the_owasp_figure` and
|
||||
`tests/test_config.py::test_the_auth_defaults_are_a_locked_door_and_a_long_session` — so
|
||||
lowering it here cannot lower it there.
|
||||
|
||||
Deliberately not a fixture: `Settings(...)` is built inside helper functions and module-level
|
||||
setup as well as inside tests, and a constant imports everywhere without dragging a request
|
||||
scope along.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: The password every test app is seeded with (and, from task 03 on, signs in with).
|
||||
ADMIN_PASSWORD = "testpass"
|
||||
|
||||
#: PBKDF2 rounds for the suite. See the module docstring; do not use this in a deployment.
|
||||
FAST_PBKDF2 = 1_000
|
||||
|
||||
|
||||
def auth_settings() -> dict[str, object]:
|
||||
"""The `Settings` kwargs a test app needs in order to boot at all.
|
||||
|
||||
A server with neither a stored verifier nor an `admin_password` **refuses to start**
|
||||
(task 02, invariant 11), and every `create_app()` in the suite runs a lifespan. Spreading
|
||||
this into the `Settings(...)` calls keeps that a one-line change in this file instead of an
|
||||
edit in every fixture that builds its own settings object.
|
||||
"""
|
||||
return {"admin_password": ADMIN_PASSWORD, "pbkdf2_iterations": FAST_PBKDF2}
|
||||
+291
-20
@@ -19,8 +19,10 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dashboards import alerts
|
||||
from dashboards.api import RANGE_SECONDS, parse_range
|
||||
from auth_client import sign_in
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from dashboards import alerts, auth
|
||||
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
|
||||
from dashboards.app import create_app
|
||||
from dashboards.collectors import UpstreamError
|
||||
from dashboards.config import Settings, Target
|
||||
@@ -52,6 +54,14 @@ DEAD_URL = "http://127.0.0.1:1"
|
||||
#: fifth page cannot join the masthead and skip the checks the others had to pass.
|
||||
PAGES = ("/", "/system", "/disks", "/llm", "/ups")
|
||||
|
||||
#: `/settings` is a **page** and not a dashboard (phase 11): it carries the same masthead with
|
||||
#: no `aria-current` tab, and the account link in every dashboard's masthead is how you reach
|
||||
#: it. Kept out of `PAGES` because the pins over that tuple assert tab structure — one current
|
||||
#: tab, a fixed tab order — which this page deliberately does not have. `MARKUP_PAGES` is the
|
||||
#: list for the checks that are about the bytes rather than the nav.
|
||||
SETTINGS_PAGE = "/settings"
|
||||
MARKUP_PAGES = (*PAGES, SETTINGS_PAGE)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False,
|
||||
@@ -67,11 +77,13 @@ def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: b
|
||||
else:
|
||||
monkeypatch.delenv("NUT_SIMULATE", raising=False)
|
||||
db = str(tmp_path / "api.sqlite3")
|
||||
settings = Settings(db_path=db, **overrides)
|
||||
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2, **overrides)
|
||||
store = SampleStore(db)
|
||||
client = TestClient(create_app(settings, store))
|
||||
client.__enter__()
|
||||
try:
|
||||
sign_in(client)
|
||||
yield client
|
||||
finally:
|
||||
client.__exit__(None, None, None)
|
||||
@@ -128,10 +140,12 @@ def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
db = str(tmp_path / "summary.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, nut_host="127.0.0.1",
|
||||
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
test_client = TestClient(app)
|
||||
test_client.__enter__()
|
||||
try:
|
||||
sign_in(test_client)
|
||||
app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload(
|
||||
host_scrape, agent_snapshot)
|
||||
wait_for_payload(test_client, "/api/ups/snapshot")
|
||||
@@ -449,13 +463,15 @@ def test_an_upstream_failure_escaping_a_route_keeps_its_own_status(tmp_path: Pat
|
||||
"""
|
||||
db = str(tmp_path / "upstream-error.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
|
||||
@app.get("/api/_boom")
|
||||
async def boom() -> None:
|
||||
raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504)
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
sign_in(test_client)
|
||||
response = test_client.get("/api/_boom")
|
||||
assert response.status_code == 504, "a timeout stays a timeout, not a 500"
|
||||
assert response.json() == {"ok": False,
|
||||
@@ -473,8 +489,11 @@ def test_agent_tokens_never_reach_the_browser(tmp_path: Path, monkeypatch: pytes
|
||||
headers={"Authorization": "Bearer SEKRIT-TOKEN"})]
|
||||
db = str(tmp_path / "tokens.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, host_targets=tuple(targets)), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=tuple(targets)), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
body = client.get("/api/config")
|
||||
assert "SEKRIT-TOKEN" not in body.text
|
||||
target = body.json()["system"]["targets"][0]
|
||||
@@ -505,10 +524,12 @@ def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> Non
|
||||
store = SampleStore(db)
|
||||
settings = Settings(
|
||||
db_path=db,
|
||||
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_interval=0.05,
|
||||
host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),),
|
||||
)
|
||||
with TestClient(create_app(settings, store)) as client:
|
||||
sign_in(client)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
health = client.get("/api/health").json()
|
||||
@@ -540,10 +561,13 @@ def test_engine_urls_are_redacted_the_same_way_hosts_are(tmp_path: Path) -> None
|
||||
happens to carry credentials has to be redacted where it is served (invariant 6).
|
||||
"""
|
||||
db = str(tmp_path / "engine-redact.sqlite3")
|
||||
settings = Settings(db_path=db, gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731",
|
||||
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731",
|
||||
vllm_url="http://driveripper:8000")
|
||||
store = SampleStore(db)
|
||||
with TestClient(create_app(settings, store)) as client:
|
||||
sign_in(client)
|
||||
body = client.get("/api/config")
|
||||
assert "SEKRIT-ENGINE-TOKEN" not in body.text
|
||||
llm = body.json()["llm"]
|
||||
@@ -567,6 +591,60 @@ def test_target_problems_are_surfaced_not_swallowed(client) -> None:
|
||||
assert body["system"]["target_problems"] == []
|
||||
|
||||
|
||||
# ---- the credential the server refuses to start without (phase 11) --------- #
|
||||
|
||||
|
||||
def test_no_verifier_and_no_seed_means_the_server_does_not_come_up(tmp_path: Path) -> None:
|
||||
"""Fail closed, at the one place a mistake is unmissable: startup.
|
||||
|
||||
Task 03 is what demands a session on every route; this is the half before it — a deployment
|
||||
with neither a stored `auth.verifier` row nor `DASH_ADMIN_PASSWORD` must not run open, so
|
||||
the lifespan raises instead of publishing a context, starting the pollers, or serving a
|
||||
page. Asserting that nothing was written is part of it: "refused" that seeded a row on the
|
||||
way out would leave the next start half-configured.
|
||||
"""
|
||||
db = str(tmp_path / "no-credential.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=""), store)
|
||||
try:
|
||||
with pytest.raises(RuntimeError) as caught, TestClient(app):
|
||||
pytest.fail("the lifespan yielded with no admin credential")
|
||||
message = str(caught.value)
|
||||
assert "DASH_ADMIN_PASSWORD" in message, "the operator's fix is in the exit output"
|
||||
assert "auth.verifier" in message and "DELETE FROM meta" in message
|
||||
assert not hasattr(app.state, "ctx"), "nothing is published, so nothing can serve"
|
||||
assert store.get_meta("auth.verifier") is None
|
||||
assert store.get_meta("auth.session_secret") is None
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def test_the_same_server_starts_once_the_environment_seeds_a_password(tmp_path: Path) -> None:
|
||||
"""The other side of the gate, so the failure above cannot be satisfied by a broken app.
|
||||
|
||||
With a seed the process comes up, the state hangs off the context (task 03 reads it on
|
||||
every request), and both rows are durable — which is also what lets the operator delete
|
||||
`DASH_ADMIN_PASSWORD` from the deployment after the first boot.
|
||||
"""
|
||||
db = str(tmp_path / "seeded.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/health").status_code == 200 # public: the healthcheck
|
||||
state = app.state.ctx.auth
|
||||
assert state is not None
|
||||
assert state.seeded is True and state.env_ignored is False
|
||||
assert state.iterations == FAST_PBKDF2
|
||||
assert auth.verify_password(ADMIN_PASSWORD, state.verifier) is True
|
||||
assert store.get_meta("auth.verifier") == state.verifier
|
||||
assert store.get_meta("auth.session_secret") == state.secret
|
||||
assert ADMIN_PASSWORD not in state.verifier + state.secret
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
# ---- pages ---------------------------------------------------------------- #
|
||||
|
||||
@pytest.mark.parametrize("path,title", [
|
||||
@@ -581,8 +659,14 @@ def test_pages_serve_and_declare_themselves(client, path, title) -> None:
|
||||
|
||||
|
||||
def test_no_page_loads_anything_from_the_internet(client) -> None:
|
||||
"""No CDN, no webfonts: this dashboard has to work with no route to the internet."""
|
||||
for path in PAGES:
|
||||
"""No CDN, no webfonts: this dashboard has to work with no route to the internet.
|
||||
|
||||
`/login` is not in `MARKUP_PAGES` and cannot be: this `client` is signed in, a signed-in
|
||||
`GET /login` is a `303`, and following it would audit `/`'s bytes under the wrong name. The
|
||||
same fragments are checked against the real login page, unsigned, by
|
||||
`tests/test_auth_api.py::test_the_login_page_loads_nothing_from_the_internet`.
|
||||
"""
|
||||
for path in MARKUP_PAGES:
|
||||
html = client.get(path).text
|
||||
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
|
||||
assert fragment not in html, f"{path} references {fragment}"
|
||||
@@ -593,6 +677,152 @@ def test_static_assets_are_served(client) -> None:
|
||||
assert client.get(asset).status_code == 200, asset
|
||||
|
||||
|
||||
# ---- /settings: the account page (phase 11 task 06) ----------------------- #
|
||||
#
|
||||
# `/settings` is the operator's own surface: the session this browser holds, a sign-out, and the
|
||||
# one form that changes the admin password. Its HTTP surface is tested twice on purpose — the
|
||||
# route-enumeration test in `tests/test_auth_api.py` proves nothing outside the public list
|
||||
# answers anonymously (which is how `/settings` is gated for free by being in `PAGES` in
|
||||
# `app.py`), and the assertions here pin the **page**: the form fields a password manager needs,
|
||||
# the outcome line that is a live region, the precedence sentence that stops the "I edited the
|
||||
# env file and nothing happened" support call, and the masthead link that is the only way in.
|
||||
|
||||
def test_the_settings_page_carries_the_session_and_the_form(client) -> None:
|
||||
response = client.get(SETTINGS_PAGE)
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
html = response.text
|
||||
# the session panel and its way out
|
||||
for fragment in ('id="sign-out"', 'id="session-user"', 'id="session-expires"',
|
||||
'id="session-left"'):
|
||||
assert fragment in html, f"/settings has no {fragment}"
|
||||
# the change-password form: three fields, each labelled, each with the floor spelled out,
|
||||
# and the autocomplete values a password manager needs to fill the right one
|
||||
for fragment in ('id="current-password"', 'autocomplete="current-password"',
|
||||
'id="new-password"', 'autocomplete="new-password"',
|
||||
'id="confirm-password"', 'id="change-password"',
|
||||
'<label for="current-password">', '<label for="new-password">',
|
||||
'<label for="confirm-password">', 'aria-describedby="password-hint"'):
|
||||
assert fragment in html, f"/settings' form is missing {fragment}"
|
||||
assert "8 characters" in html, "the form stopped naming the length floor it enforces"
|
||||
# the outcome line is announced, because the operator asked for it and nothing else will
|
||||
assert 'id="settings-result"' in html and 'role="status"' in html
|
||||
|
||||
|
||||
def test_the_settings_page_states_the_precedence_rule(client) -> None:
|
||||
"""The sentence that makes the env file's losing half legible on the page that wins.
|
||||
|
||||
`README.md` documents the same rule, but the operator reading it there is deciding what to
|
||||
write in a file; the operator reading *this* page just changed a password and needs to know
|
||||
why a container env will not undo it.
|
||||
"""
|
||||
html = client.get(SETTINGS_PAGE).text
|
||||
assert "DASH_ADMIN_PASSWORD" in html
|
||||
assert "auth.verifier" in html
|
||||
assert "never overwrites" in html
|
||||
|
||||
|
||||
def test_the_settings_page_is_not_one_of_the_dashboards(client) -> None:
|
||||
"""The tabs are there to leave with; nothing marks itself current, because nothing is.
|
||||
|
||||
A tab marked current on a page that is not in the tab list claims a location the app does not
|
||||
have, and the four dashboards' own masthead pin (`aria-current` exactly once) relies on that
|
||||
marker meaning something.
|
||||
"""
|
||||
html = client.get(SETTINGS_PAGE).text
|
||||
assert '<nav class="tabs"' in html
|
||||
assert 'aria-current="page"' not in html
|
||||
for path in PAGES:
|
||||
assert f'href="{path}"' in html, f"/settings cannot navigate to {path}"
|
||||
assert 'id="nav-status"' not in html, "/settings grew fleet dots it has nothing to fill"
|
||||
|
||||
|
||||
def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> None:
|
||||
"""One page-level assertion of the gate, not a second enumeration of it.
|
||||
|
||||
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session` is the
|
||||
general proof; this one exists so a regression in `/settings` names `/settings`.
|
||||
"""
|
||||
db = str(tmp_path / "settings-gate.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
try:
|
||||
with TestClient(app, follow_redirects=False) as anon:
|
||||
page = anon.get(SETTINGS_PAGE, headers={"accept": "text/html"})
|
||||
assert page.status_code == 303
|
||||
assert page.headers["location"] == f"/login?next={SETTINGS_PAGE}"
|
||||
data = anon.get(SETTINGS_PAGE, headers={"accept": "application/json"})
|
||||
assert data.status_code == 401
|
||||
assert data.json() == {"ok": False, "detail": "sign in required",
|
||||
"login": "/login"}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def test_every_dashboard_page_links_to_settings(client) -> None:
|
||||
"""The `⚙ Account` link is the only way to the session page, so it is on every dashboard.
|
||||
|
||||
It sits in `.masthead-right` before the theme toggle and **outside** `.tabs`, because the tab
|
||||
strip's structure — five dashboards, one of them current — is pinned by
|
||||
`test_the_disks_tab_is_in_the_masthead_of_every_page` and a sixth entry with no
|
||||
`aria-current` would break what that marker means.
|
||||
"""
|
||||
link = '<a class="ghost account" href="/settings"'
|
||||
for path in PAGES:
|
||||
html = client.get(path).text
|
||||
assert link in html, f"{path} has no route to the account page"
|
||||
tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")]
|
||||
assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
|
||||
assert html.index(link) < html.index('id="theme-toggle"'), \
|
||||
f"{path} put the account link after the theme toggle"
|
||||
# the stylesheet styles the link with the button's own rule, not a copy of it
|
||||
css = client.get("/static/dash.css").text
|
||||
assert "a.ghost" in css
|
||||
|
||||
|
||||
def test_the_password_floor_the_form_checks_is_the_one_the_api_enforces(client) -> None:
|
||||
"""The client-side length check is a courtesy; the server's is the rule.
|
||||
|
||||
There is no build step to share the constant (a locked anchor), so the two literals are
|
||||
pinned together here: a floor raised in `api.py` alone would leave the form promising eight
|
||||
characters to a request the server answers `422`.
|
||||
"""
|
||||
js = client.get("/static/page-settings.js").text
|
||||
found = re.search(r"const MIN_PASSWORD_LENGTH = (\d+);", js)
|
||||
assert found, "page-settings.js no longer names its length floor"
|
||||
assert int(found.group(1)) == MIN_PASSWORD_LENGTH
|
||||
|
||||
|
||||
def test_a_rotated_secret_clears_the_cached_session_every_page_uses(client) -> None:
|
||||
"""`Dash.resetAuth()` is the difference between a password change and a 403 dashboard.
|
||||
|
||||
`POST /api/auth/password` rotates the signing secret on purpose (`auth.update_verifier`),
|
||||
which invalidates the CSRF token `ensureAuth()` memoised. Every page writes through
|
||||
`fetchJSON`, which reads that cache — so unless the change clears it, the operator changes
|
||||
the password, clicks *beeper disable* on `/ups`, and gets `missing or invalid CSRF token`
|
||||
until they reload the page. A bounce on a `401` clears it for the same reason: the session it
|
||||
described no longer exists.
|
||||
"""
|
||||
app_js = client.get("/static/app.js").text
|
||||
assert "function resetAuth(" in app_js
|
||||
exports = app_js.rsplit("return {", 1)[1].split("}", 1)[0]
|
||||
assert re.search(r"\bresetAuth\b", exports), "Dash.resetAuth is not exported"
|
||||
reset = app_js[app_js.index("function resetAuth("):app_js.index("function bounceToLogin(")]
|
||||
for field in ("loaded: false", "authenticated: false", "csrf: null"):
|
||||
assert field in reset, f"resetAuth() left `{field}` cached"
|
||||
assert "authPromise = null" in reset, \
|
||||
"resetAuth() left an in-flight status call memoised — the fresh write would reuse it"
|
||||
bounce = app_js[app_js.index("function bounceToLogin("):app_js.index("const READ_METHODS")]
|
||||
assert "resetAuth()" in bounce, "a 401 bounce kept the dead session's token cached"
|
||||
|
||||
js = client.get("/static/page-settings.js").text
|
||||
assert js.count("Dash.resetAuth()") >= 2, \
|
||||
"the settings page must reset the cached session after both a change and a sign-out"
|
||||
assert "textContent" in js and "innerHTML" not in js, \
|
||||
"/settings renders a server sentence as markup"
|
||||
|
||||
|
||||
# ---- the severity language in the static assets --------------------------- #
|
||||
#
|
||||
# There is no JS test runner (a locked anchor: adding one would mean a build step).
|
||||
@@ -603,7 +833,8 @@ def test_static_assets_are_served(client) -> None:
|
||||
|
||||
ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js",
|
||||
"/static/page-index.js", "/static/page-disks.js", "/static/page-system.js",
|
||||
"/static/page-llm.js", "/static/page-ups.js")
|
||||
"/static/page-llm.js", "/static/page-ups.js", "/static/page-login.js",
|
||||
"/static/page-settings.js")
|
||||
|
||||
|
||||
def _css(client: TestClient) -> str:
|
||||
@@ -702,7 +933,7 @@ def _fleet_field_metrics(js: str) -> list[str]:
|
||||
|
||||
def test_no_parallel_severity_vocabulary_exists_anywhere(client) -> None:
|
||||
"""One word per colour: `ok|warn|fail`, as in severity.py and the existing components."""
|
||||
for asset in (*ASSETS, *PAGES):
|
||||
for asset in (*ASSETS, *MARKUP_PAGES):
|
||||
body = _code(client.get(asset).text)
|
||||
assert ".sev-" not in body, f"{asset} invented a .sev-* class"
|
||||
assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']",
|
||||
@@ -2404,7 +2635,16 @@ PAGE_SCRIPTS = {
|
||||
"/disks": "page-disks.js",
|
||||
"/llm": "page-llm.js",
|
||||
"/ups": "page-ups.js",
|
||||
# `/settings` is audited like a dashboard even though it is not one: an id nothing reads is
|
||||
# dead markup whatever page it sits on, and this page has five new ones.
|
||||
SETTINGS_PAGE: "page-settings.js",
|
||||
}
|
||||
# `/login` is deliberately **not** in this table even though it is audited (phase 11): a
|
||||
# signed-in client asking for it gets a 303 to the dashboard, so this helper's own
|
||||
# redirect-following `get` would audit `/`'s markup and call it `/login`. The login page's
|
||||
# orphan-`id`, CDN and masthead checks live in `tests/test_auth_api.py`, fetched unsigned, and
|
||||
# `/static/page-login.js` is in `ASSETS` above.
|
||||
|
||||
SHARED_SCRIPTS = ("app.js", "charts.js")
|
||||
|
||||
|
||||
@@ -2472,7 +2712,7 @@ def test_no_page_narrates_its_own_pipeline(client, fragment: str) -> None:
|
||||
the same sentence migrating into another page's card is the same slop, and a pin that
|
||||
only guards its original file would let that through.
|
||||
"""
|
||||
for path in (*PAGES, *ASSETS):
|
||||
for path in (*MARKUP_PAGES, *ASSETS):
|
||||
body = client.get(path).text
|
||||
assert fragment not in body, f"{path} narrates the app again: {fragment!r}"
|
||||
|
||||
@@ -2484,7 +2724,7 @@ def test_pages_leave_no_empty_narration_container(client) -> None:
|
||||
renders a gap where the sentence used to be. Elements the scripts fill (`#sub`,
|
||||
`#alerts-count`) carry an `id` and are not in this category.
|
||||
"""
|
||||
for path in PAGES:
|
||||
for path in MARKUP_PAGES:
|
||||
html = _code(client.get(path).text)
|
||||
for cls in ("sub", "faint", "mono"):
|
||||
assert f'<span class="{cls}"></span>' not in html, \
|
||||
@@ -2543,8 +2783,11 @@ def test_history_returns_seeded_samples(tmp_path: Path, monkeypatch: pytest.Monk
|
||||
now = time.time()
|
||||
for i in range(30):
|
||||
store.add("host", "driveripper", "cpu.usage_pct", 10.0 + i, ts=now - 300 + i)
|
||||
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
body = client.get("/api/history", params={"source": "host", "host": "driveripper",
|
||||
"metric": "cpu.usage_pct", "range": "15m"})
|
||||
assert response_ok(body)
|
||||
@@ -2743,9 +2986,12 @@ def disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
"""
|
||||
db = str(tmp_path / "disks.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),
|
||||
Target("deskwork", DEAD_URL))), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),
|
||||
Target("deskwork", DEAD_URL))), store)
|
||||
with TestClient(app) as test_client:
|
||||
sign_in(test_client)
|
||||
now = time.time()
|
||||
snapshot = disk_snapshot("driveripper")
|
||||
snapshot["ts"] = now # "the agent answered just now", so charts are in range
|
||||
@@ -2867,7 +3113,7 @@ def test_the_disks_fleet_header_is_summed_by_the_api_not_the_page(disks_client)
|
||||
|
||||
def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
|
||||
disks_client) -> None:
|
||||
"""Invariant 1: the app has exactly one POST, and a fourth dashboard does not add one."""
|
||||
"""Invariant 1: the app writes to an **upstream** from exactly one route."""
|
||||
routes = flat_routes(disks_client.app)
|
||||
disks = [route for route in routes
|
||||
if str(getattr(route, "path", "")).startswith("/api/disks")]
|
||||
@@ -2879,7 +3125,26 @@ def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
|
||||
assert response.status_code == 405, f"{verb} /api/disks/snapshot was accepted"
|
||||
writes = sorted(str(route.path) for route in routes
|
||||
if "POST" in (getattr(route, "methods", None) or set()))
|
||||
assert writes == ["/api/ups/command"], "one write endpoint, four gates, unchanged"
|
||||
# Invariant 1 is about the fleet, and phase 11 did not touch the fleet: the three `/auth/`
|
||||
# writes create, re-credential, and clear a session in this process and dial nothing. They
|
||||
# are named here instead of filtered out of sight, because the failure this assertion catches
|
||||
# is a POST arriving quietly — a list the reader recognises is worth more than a short one.
|
||||
upstream = [path for path in writes if not path.startswith("/api/auth/")]
|
||||
session = [path for path in writes if path.startswith("/api/auth/")]
|
||||
assert upstream == ["/api/ups/command"], "one write endpoint, four gates, unchanged"
|
||||
assert session == ["/api/auth/login", "/api/auth/logout", "/api/auth/password"]
|
||||
# …and nothing anywhere grew the verbs this app has no use for, session included.
|
||||
assert not sorted(path for route in routes
|
||||
for path in [str(route.path)]
|
||||
if set(getattr(route, "methods", None) or ()) & {"PUT", "PATCH", "DELETE"})
|
||||
# The session writes are not upstream writes, but they are still the only ones: `/login`
|
||||
# is the page, not a POST target, and `/api/auth/status` stays a read.
|
||||
pages = sorted(str(route.path) for route in routes
|
||||
if "GET" in (getattr(route, "methods", None) or set())
|
||||
and not str(route.path).startswith("/api/"))
|
||||
# `/settings` (phase 11) is a fifth dashboard-shaped **page** and it grew no verb: a page
|
||||
# route is a GET, so the write surface this test guards stays the one UPS command.
|
||||
assert pages == ["/", "/disks", "/llm", "/login", "/settings", "/system", "/ups"]
|
||||
|
||||
|
||||
def test_config_prints_the_disks_cadence_the_page_explains_itself_with(
|
||||
@@ -3239,9 +3504,11 @@ def test_summary_names_the_drive_the_health_verdict_called_failed(
|
||||
"""
|
||||
db = str(tmp_path / "summary-failed.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db,
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
with TestClient(app) as test_client:
|
||||
sign_in(test_client)
|
||||
now = time.time()
|
||||
snapshot = disk_snapshot("driveripper", mode="failed")
|
||||
snapshot["ts"] = now
|
||||
@@ -3261,8 +3528,10 @@ def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyP
|
||||
store = SampleStore(db)
|
||||
store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down",
|
||||
severity="error", message="deskwork is not answering"))
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
events = client.get("/api/events").json()["events"]
|
||||
assert events[0]["host"] == "deskwork"
|
||||
assert events[0]["severity"] == "error"
|
||||
@@ -3544,8 +3813,10 @@ def test_samples_written_by_the_poller_are_queryable(tmp_path: Path,
|
||||
store = SampleStore(db)
|
||||
store.add_many([Sample("llm", "gufo", "llm.decode_tokens_per_second", 40.0, time.time() - 60),
|
||||
Sample("llm", "gufo", "llm.decode_tokens_per_second", 45.0, time.time() - 30)])
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"]
|
||||
assert "llm.decode_tokens_per_second" in metrics
|
||||
points = client.get("/api/history", params={"source": "llm", "host": "gufo",
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
"""`src/dashboards/auth.py` — every authentication decision, tested as a pure function.
|
||||
|
||||
This is the security boundary of the app, so the negative table is the point of the file:
|
||||
a wrong password, a tampered cookie, a cookie signed with a rotated secret, a CSRF token
|
||||
from another session, a spoofed swarm of source addresses. A branch here that no test reaches
|
||||
is a security decision nobody checked, which is why `auth.py` is covered to ~100%.
|
||||
|
||||
Nothing sleeps. `now` is an argument on every function that reads a clock, so expiry and
|
||||
backoff are asserted with numbers instead of with `time.sleep(60)`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from dashboards import auth
|
||||
|
||||
AUTH_SOURCE = Path(__file__).resolve().parents[1] / "src" / "dashboards" / "auth.py"
|
||||
|
||||
#: Cheap cost for tests that are not about the cost. The default (600k) is pinned once, in
|
||||
#: `test_the_default_cost_is_the_owasp_figure`, so the rest of the suite stays fast.
|
||||
CHEAP = 1_000
|
||||
SECRET = "0" * 64
|
||||
NOW = 1_700_000_000.0
|
||||
SALT = "00" * 16
|
||||
DIGEST = "a" * 64
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
"""The wire form of a base64url payload with the padding stripped.
|
||||
|
||||
Reimplemented here rather than calling the module's own encoder on purpose: a test that
|
||||
builds a tampered token with `_b64e` cannot notice that the format changed.
|
||||
"""
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _token(secret: str, payload: str) -> str:
|
||||
"""A correctly-signed token for an arbitrary payload string."""
|
||||
signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest()
|
||||
return f"{_b64(payload.encode())}.{_b64(signature)}"
|
||||
|
||||
|
||||
def _tree() -> ast.Module:
|
||||
"""`auth.py` parsed, for the structural pins below."""
|
||||
return ast.parse(AUTH_SOURCE.read_text())
|
||||
|
||||
|
||||
def _imported_modules() -> set[str]:
|
||||
"""Every top-level module `auth.py` imports."""
|
||||
names: set[str] = set()
|
||||
for node in ast.walk(_tree()):
|
||||
if isinstance(node, ast.Import):
|
||||
names.update(alias.name.split(".")[0] for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||
names.add(node.module.split(".")[0])
|
||||
return names
|
||||
|
||||
|
||||
# --- the vocabulary ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_the_public_names_are_the_ones_the_middleware_and_pages_share() -> None:
|
||||
"""Pinned because task 03 sets the cookie and task 04 reads the header by these names."""
|
||||
assert auth.ADMIN_USER == "admin"
|
||||
assert auth.SESSION_COOKIE == "dash_session"
|
||||
assert auth.CSRF_HEADER == "X-CSRF-Token"
|
||||
assert frozenset({"GET", "HEAD", "OPTIONS"}) == auth.SAFE_METHODS
|
||||
assert "POST" not in auth.SAFE_METHODS
|
||||
assert auth.PBKDF2_ALGO == "pbkdf2_sha256"
|
||||
assert auth.SESSION_HOURS == 720.0
|
||||
# The two rows the operator has to be able to name — in the reset command, in `/settings`,
|
||||
# and in every test that seeds a database by hand.
|
||||
assert auth.META_VERIFIER == "auth.verifier"
|
||||
assert auth.META_SESSION_SECRET == "auth.session_secret"
|
||||
|
||||
|
||||
def test_the_primitives_import_nothing_third_party() -> None:
|
||||
"""No `itsdangerous`, no `bcrypt`, no session store (PLAN.md's locked toolchain).
|
||||
|
||||
A dependency would not fail a test by itself — it would simply become the way this
|
||||
project authenticates. This fails the day someone adds one.
|
||||
"""
|
||||
outside = {name for name in _imported_modules() if name not in sys.stdlib_module_names}
|
||||
assert outside == set()
|
||||
|
||||
|
||||
def test_the_module_cannot_echo_a_credential() -> None:
|
||||
"""`AGENTS.md` invariant 6, applied to the admin's own password.
|
||||
|
||||
The docstring says nothing here can echo a secret; this makes it a fact instead of an
|
||||
intention. `logging` is allowed (task 02 added exactly one startup warning), but the shape
|
||||
of that call is pinned: it must pass a bare module-level **string constant**, never an
|
||||
f-string, a concatenation, or a local — so there is no expression in this file that could
|
||||
put a verifier, a password, or a prefix of either on a log line.
|
||||
"""
|
||||
imported = _imported_modules()
|
||||
assert imported & {"sys", "warnings", "traceback"} == set()
|
||||
called: set[str] = set()
|
||||
for node in ast.walk(_tree()):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
called.add(node.func.id)
|
||||
assert called & {"print", "pprint", "input", "vars", "locals"} == set()
|
||||
|
||||
tree = _tree()
|
||||
constants = {
|
||||
node.id for stmt in tree.body
|
||||
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Constant)
|
||||
for node in stmt.targets if isinstance(node, ast.Name)
|
||||
}
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
||||
continue
|
||||
if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "_log"):
|
||||
continue
|
||||
assert len(node.args) == 1, "the one log call takes one message"
|
||||
message = node.args[0]
|
||||
assert isinstance(message, ast.Name) and message.id in constants, (
|
||||
"the logged message must be a module-level literal, not an interpolation")
|
||||
assert isinstance(ast.literal_eval(_constant_value(tree, message.id)), str)
|
||||
|
||||
|
||||
def _constant_value(tree: ast.Module, name: str) -> ast.Constant:
|
||||
"""The literal behind a module-level constant name."""
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, ast.Assign) and any(
|
||||
isinstance(t, ast.Name) and t.id == name for t in stmt.targets
|
||||
):
|
||||
assert isinstance(stmt.value, ast.Constant)
|
||||
return stmt.value
|
||||
raise AssertionError(f"{name} is not a module-level constant")
|
||||
|
||||
|
||||
def test_the_primitives_never_raise_and_the_bootstrap_raises_exactly_twice() -> None:
|
||||
"""Every primitive's failure mode is a `False`/`None` the caller turns into a status code.
|
||||
|
||||
An exception would carry the reason, and the reason is a credential (`bad salt '3f9a…'`)
|
||||
in a traceback the health record and the event feed both surface. The negative tables
|
||||
below are the behavioural half of this structural pin.
|
||||
|
||||
The two documented exceptions are the bootstrap (task 02), and they are enumerated rather
|
||||
than merely tolerated: `load_auth_state` refuses to start a dashboard with no password
|
||||
anywhere, and `update_verifier` refuses to store an empty one. Both raise fixed text, and
|
||||
neither may interpolate a password, a verifier, a secret, a salt or a digest into it.
|
||||
"""
|
||||
tree = _tree()
|
||||
allowed = {"load_auth_state": "RuntimeError", "update_verifier": "ValueError"}
|
||||
credential_names = {"password", "verifier", "secret", "token", "salt", "digest", "provided"}
|
||||
per_function: dict[str, list[ast.Raise]] = {}
|
||||
for func in (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)):
|
||||
found = [n for n in ast.walk(func) if isinstance(n, ast.Raise)]
|
||||
if found:
|
||||
per_function[func.name] = found
|
||||
assert set(per_function) == set(allowed)
|
||||
for name, expected in allowed.items():
|
||||
for node in per_function[name]:
|
||||
exc = node.exc
|
||||
assert isinstance(exc, ast.Call) and isinstance(exc.func, ast.Name)
|
||||
assert exc.func.id == expected, f"{name} must raise {expected}"
|
||||
assert node.cause is None
|
||||
for arg in exc.args:
|
||||
assert not _mentions(arg, credential_names), f"{name} raises with a credential"
|
||||
# nothing outside those two functions raises at all
|
||||
module_raises = [n for n in ast.walk(tree) if isinstance(n, ast.Raise)]
|
||||
assert len(module_raises) == sum(len(v) for v in per_function.values())
|
||||
|
||||
|
||||
def _mentions(node: ast.AST, names: set[str]) -> bool:
|
||||
"""Does this expression name one of `names` anywhere inside it?"""
|
||||
return any(isinstance(child, ast.Name) and child.id in names
|
||||
for child in ast.walk(node))
|
||||
|
||||
|
||||
# --- password verifier ------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_hash_round_trips_and_describes_itself() -> None:
|
||||
encoded = auth.hash_password("testpass", iterations=CHEAP)
|
||||
algo, iterations, salt, digest = encoded.split("$")
|
||||
assert algo == auth.PBKDF2_ALGO
|
||||
assert iterations == str(CHEAP)
|
||||
assert len(salt) == auth.PBKDF2_SALT_BYTES * 2
|
||||
assert len(digest) == auth.PBKDF2_HASH_BYTES * 2
|
||||
assert auth.verify_password("testpass", encoded) is True
|
||||
|
||||
|
||||
def test_the_default_cost_is_the_owasp_figure() -> None:
|
||||
"""The completion criterion for this task, end to end, at the real cost."""
|
||||
assert auth.PBKDF2_ITERATIONS == 600_000
|
||||
encoded = auth.hash_password("testpass")
|
||||
assert encoded.startswith(f"{auth.PBKDF2_ALGO}${auth.PBKDF2_ITERATIONS}$")
|
||||
assert auth.verify_password("testpass", encoded) is True
|
||||
assert auth.verify_password("wrong", encoded) is False
|
||||
assert auth.verify_password("testpass", "junk") is False
|
||||
|
||||
|
||||
def test_a_wrong_password_is_false_and_nothing_else() -> None:
|
||||
encoded = auth.hash_password("correct horse", iterations=CHEAP)
|
||||
assert auth.verify_password("", encoded) is False
|
||||
assert auth.verify_password("Correct horse", encoded) is False
|
||||
assert auth.verify_password("correct horse ", encoded) is False
|
||||
|
||||
|
||||
def test_the_password_is_encoded_as_utf8() -> None:
|
||||
"""`pässwörd` round-trips — proof the encode is utf-8 and not a lossy 8-bit one.
|
||||
|
||||
A latin-1 or ascii encode would either raise or make two distinct passwords verify
|
||||
against one digest; non-ASCII input is the only way to tell them apart.
|
||||
"""
|
||||
for password in ("pässwörd", "пароль", "パスワード", "🔥🔥"):
|
||||
encoded = auth.hash_password(password, iterations=CHEAP)
|
||||
assert auth.verify_password(password, encoded) is True
|
||||
assert auth.verify_password("password", encoded) is False
|
||||
|
||||
|
||||
def test_a_lone_surrogate_verifies_instead_of_raising() -> None:
|
||||
"""`.encode("utf-8")` raises on a surrogate; the login path must not."""
|
||||
password = "\ud800"
|
||||
encoded = auth.hash_password(password, iterations=CHEAP)
|
||||
assert auth.verify_password(password, encoded) is True
|
||||
assert auth.verify_password("\ud801", encoded) is False
|
||||
|
||||
|
||||
def test_the_salt_is_fresh_per_hash() -> None:
|
||||
"""Two hashes of one password share no byte of digest — or one table cracks both."""
|
||||
first = auth.hash_password("testpass", iterations=CHEAP)
|
||||
second = auth.hash_password("testpass", iterations=CHEAP)
|
||||
assert first != second
|
||||
assert first.split("$")[2] != second.split("$")[2]
|
||||
assert first.split("$")[3] != second.split("$")[3]
|
||||
assert auth.verify_password("testpass", first) is True
|
||||
assert auth.verify_password("testpass", second) is True
|
||||
|
||||
|
||||
def test_verification_uses_the_stored_iteration_count() -> None:
|
||||
"""Bumping the cost field must not silently accept the password.
|
||||
|
||||
The iteration count feeds the digest, so rewriting it is corruption, not an upgrade:
|
||||
`needs_rehash` is what asks for a replacement, never `verify_password`.
|
||||
"""
|
||||
encoded = auth.hash_password("testpass", iterations=CHEAP)
|
||||
algo, _iterations, salt, digest = encoded.split("$")
|
||||
assert auth.verify_password("testpass", f"{algo}${CHEAP * 2}${salt}${digest}") is False
|
||||
|
||||
|
||||
#: Every malformed verifier shape. `verify_password` answers `False` to all of them — the same
|
||||
#: answer a wrong password gets — because a damaged `auth.verifier` row has to read as a login
|
||||
#: error the operator can act on, not a 500 on the page whose job is to say "wrong password".
|
||||
MALFORMED: list[tuple[str, str]] = [
|
||||
("empty", ""),
|
||||
("junk", "junk"),
|
||||
("one field", auth.PBKDF2_ALGO),
|
||||
("three fields", f"{auth.PBKDF2_ALGO}${CHEAP}$00112233"),
|
||||
("five fields", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}$extra"),
|
||||
("leading dollar", f"${auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}"),
|
||||
("unknown algo", f"md5${CHEAP}${SALT}${DIGEST}"),
|
||||
("plaintext row", "testpass"),
|
||||
("non-numeric iterations", f"{auth.PBKDF2_ALGO}$six$00112233${DIGEST}"),
|
||||
("float iterations", f"{auth.PBKDF2_ALGO}$1000.5${SALT}${DIGEST}"),
|
||||
("zero iterations", f"{auth.PBKDF2_ALGO}$0${SALT}${DIGEST}"),
|
||||
("negative iterations", f"{auth.PBKDF2_ALGO}$-5${SALT}${DIGEST}"),
|
||||
("absurd iterations", f"{auth.PBKDF2_ALGO}$999999999999${SALT}${DIGEST}"),
|
||||
("odd-length salt", f"{auth.PBKDF2_ALGO}${CHEAP}$00112233a${DIGEST}"),
|
||||
("non-hex salt", f"{auth.PBKDF2_ALGO}${CHEAP}$zzzzzzzz${DIGEST}"),
|
||||
("empty salt", f"{auth.PBKDF2_ALGO}${CHEAP}$$0123456789abcdef"),
|
||||
("odd-length hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}a"),
|
||||
("non-hex hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "z" * 64),
|
||||
("short hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "a" * 32),
|
||||
("long hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "a" * 96),
|
||||
("empty hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("label", "encoded"), MALFORMED, ids=[label for label, _ in MALFORMED])
|
||||
def test_a_malformed_verifier_is_neither_true_nor_an_exception(label: str, encoded: str) -> None:
|
||||
assert auth.verify_password("testpass", encoded) is False, label
|
||||
assert auth.verify_password("", encoded) is False, label
|
||||
|
||||
|
||||
def test_a_corrupt_row_does_not_make_the_absurd_cost_free() -> None:
|
||||
"""The iteration ceiling bounds the CPU a damaged row can demand of the login page."""
|
||||
assert auth.PBKDF2_MAX_ITERATIONS > auth.PBKDF2_ITERATIONS
|
||||
absurd = "9" * 30
|
||||
assert auth.verify_password("testpass", f"{auth.PBKDF2_ALGO}${absurd}${SALT}${DIGEST}") is False
|
||||
|
||||
|
||||
def test_needs_rehash_tracks_the_configured_cost() -> None:
|
||||
weak = auth.hash_password("testpass", iterations=1_000)
|
||||
strong = auth.hash_password("testpass", iterations=100_000)
|
||||
# the case from the task: a 1_000-iteration hash against the default cost
|
||||
assert auth.needs_rehash(weak, iterations=auth.PBKDF2_ITERATIONS) is True
|
||||
assert auth.needs_rehash(strong, iterations=100_000) is False # equal is not below
|
||||
assert auth.needs_rehash(strong, iterations=1_000) is False # above is not below
|
||||
assert auth.needs_rehash(weak, iterations=1_000) is False
|
||||
assert auth.needs_rehash(weak, iterations=999) is False
|
||||
assert auth.needs_rehash(auth.hash_password("x"), iterations=auth.PBKDF2_ITERATIONS) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoded", [
|
||||
"",
|
||||
"junk",
|
||||
f"md5${CHEAP}$00112233${DIGEST}",
|
||||
f"{auth.PBKDF2_ALGO}$many$00112233${DIGEST}",
|
||||
])
|
||||
def test_needs_rehash_wants_a_replacement_for_anything_off_shape(encoded: str) -> None:
|
||||
"""Unparseable or another algorithm → `True`: that is not the shape we want stored."""
|
||||
assert auth.needs_rehash(encoded, iterations=auth.PBKDF2_ITERATIONS) is True
|
||||
|
||||
|
||||
# --- sessions ---------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_minted_session_round_trips() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
session = auth.read_session(SECRET, token, now=NOW + 60)
|
||||
assert session is not None
|
||||
assert session.username == auth.ADMIN_USER
|
||||
assert session.expires_at == pytest.approx(NOW + auth.SESSION_HOURS * 3600.0, abs=1.0)
|
||||
assert session.token == token
|
||||
|
||||
|
||||
def test_the_token_is_two_urlsafe_base64_parts_with_no_padding() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
payload_b64, signature_b64 = token.split(".")
|
||||
assert "=" not in token
|
||||
assert set(token) <= set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.")
|
||||
assert token.count(".") == 1 # the separator only: nothing inside the payload or signature
|
||||
assert base64.urlsafe_b64decode(payload_b64 + "==").decode().startswith("admin|")
|
||||
assert len(base64.urlsafe_b64decode(signature_b64 + "==")) == 32
|
||||
|
||||
|
||||
def test_a_session_can_be_minted_for_another_lifetime() -> None:
|
||||
token = auth.mint_session(SECRET, hours=1.0, now=NOW)
|
||||
assert auth.read_session(SECRET, token, now=NOW + 3_599) is not None
|
||||
assert auth.read_session(SECRET, token, now=NOW + 3_600) is None # the expiry is exclusive
|
||||
|
||||
|
||||
def test_a_new_secret_is_256_bits_of_hex_and_unique() -> None:
|
||||
minted = {auth.new_secret() for _ in range(8)}
|
||||
assert len(minted) == 8
|
||||
for secret in minted:
|
||||
assert len(secret) == 64
|
||||
int(secret, 16) # raises if it is not hex
|
||||
|
||||
|
||||
def test_an_expired_session_is_none() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
assert auth.read_session(SECRET, token, now=NOW + 90 * 24 * 3600) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", [
|
||||
"",
|
||||
"no-dot-here",
|
||||
".YWRtaW58OTk5OTk5OTk5OQ",
|
||||
"YWRtaW58OTk5OTk5OTk5OQ.",
|
||||
"!!!.!!!",
|
||||
"YWRtaW58OTk5OTk5OTk5OQ.!!!",
|
||||
"....",
|
||||
])
|
||||
def test_a_token_that_is_not_a_token_is_none(token: str) -> None:
|
||||
assert auth.read_session(SECRET, token, now=NOW) is None
|
||||
|
||||
|
||||
def test_a_flipped_signature_byte_is_none() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
payload_b64, signature_b64 = token.split(".")
|
||||
first = signature_b64[0]
|
||||
flipped = ("B" if first != "B" else "C") + signature_b64[1:]
|
||||
assert auth.read_session(SECRET, f"{payload_b64}.{flipped}", now=NOW) is None
|
||||
|
||||
|
||||
def test_a_truncated_token_is_none() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
assert auth.read_session(SECRET, token[:-4], now=NOW) is None
|
||||
assert auth.read_session(SECRET, token[4:], now=NOW) is None
|
||||
|
||||
|
||||
def test_a_cookie_signed_by_a_rotated_secret_is_none() -> None:
|
||||
"""Rotating `auth.session_secret` is how signing every other browser out works."""
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
assert auth.read_session("f" * 64, token, now=NOW) is None
|
||||
|
||||
|
||||
def test_a_tampered_username_is_none() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
payload_b64, signature_b64 = token.split(".")
|
||||
payload = base64.urlsafe_b64decode(payload_b64 + "==").decode()
|
||||
forged = _b64(payload.replace("admin", "root").encode())
|
||||
assert forged != payload_b64
|
||||
assert auth.read_session(SECRET, f"{forged}.{signature_b64}", now=NOW) is None
|
||||
|
||||
|
||||
def test_a_payload_that_is_not_utf8_is_none() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
_signed_payload, signature_b64 = token.split(".")
|
||||
assert auth.read_session(SECRET, f"{_b64(b'\xff\xff')}.{signature_b64}", now=NOW) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("label", "payload"), [
|
||||
("no bar", "admin"),
|
||||
("no username", f"|{NOW + 3600:.0f}"),
|
||||
("non-numeric expiry", "admin|soon"),
|
||||
("empty expiry", "admin|"),
|
||||
("nan expiry", "admin|nan"),
|
||||
("inf expiry", "admin|inf"),
|
||||
("-inf expiry", "admin|-inf"),
|
||||
("empty payload", ""),
|
||||
])
|
||||
def test_a_signed_payload_that_is_not_a_session_is_none(label: str, payload: str) -> None:
|
||||
"""Correctly signed, still rejected: the signature is necessary and not sufficient."""
|
||||
assert auth.read_session(SECRET, _token(SECRET, payload), now=NOW) is None, label
|
||||
|
||||
|
||||
def test_the_signature_covers_the_expiry_too() -> None:
|
||||
"""Pushing a session's expiry out is the same class of forgery as renaming the user."""
|
||||
token = auth.mint_session(SECRET, hours=1.0, now=NOW)
|
||||
session = auth.read_session(SECRET, token, now=NOW)
|
||||
assert session is not None
|
||||
later = f"admin|{session.expires_at + 10 * 3600:.0f}"
|
||||
# without the secret, an extended payload is just an unsigned string
|
||||
assert auth.read_session(SECRET, _token("f" * 64, later), now=NOW) is None
|
||||
# and the real token does not outlive the expiry it was minted with
|
||||
assert auth.read_session(SECRET, token, now=NOW + 2 * 3600) is None
|
||||
|
||||
|
||||
# --- CSRF -------------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_the_csrf_token_is_bound_to_its_session() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
other = auth.mint_session(SECRET, hours=1.0, now=NOW)
|
||||
csrf = auth.mint_csrf(SECRET, token)
|
||||
assert len(csrf) == 64
|
||||
assert csrf == auth.mint_csrf(SECRET, token) # stateless: no rotation, no storage
|
||||
assert csrf != auth.mint_csrf(SECRET, other)
|
||||
assert auth.verify_csrf(SECRET, token, csrf) is True
|
||||
# a token stolen from one session cannot authorise another's write
|
||||
assert auth.verify_csrf(SECRET, other, csrf) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provided", [None, "", " ", "deadbeef", " " * 64])
|
||||
def test_a_missing_or_wrong_csrf_token_is_false(provided: str | None) -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
assert auth.verify_csrf(SECRET, token, provided) is False
|
||||
|
||||
|
||||
def test_csrf_verification_survives_an_empty_session_and_a_non_ascii_header() -> None:
|
||||
token = auth.mint_session(SECRET, now=NOW)
|
||||
csrf = auth.mint_csrf(SECRET, token)
|
||||
assert auth.verify_csrf(SECRET, "", csrf) is False
|
||||
assert auth.verify_csrf(SECRET, "", "") is False
|
||||
assert auth.verify_csrf(SECRET, token, f"{csrf}päss") is False
|
||||
assert auth.verify_csrf(SECRET, token, "\ud800" + csrf) is False
|
||||
assert auth.verify_csrf(SECRET, token, csrf) is True # the good case still works
|
||||
|
||||
|
||||
# --- login throttle ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_the_defaults_are_five_attempts_then_a_minute() -> None:
|
||||
throttle = auth.LoginThrottle()
|
||||
assert throttle.max_attempts == 5
|
||||
assert throttle.base_seconds == 60.0
|
||||
assert throttle.max_seconds == 3600.0
|
||||
|
||||
|
||||
def test_five_failures_are_allowed_and_the_sixth_is_blocked() -> None:
|
||||
throttle = auth.LoginThrottle()
|
||||
for _ in range(5):
|
||||
assert throttle.retry_after("10.0.0.1", now=100.0) == 0.0
|
||||
throttle.failure("10.0.0.1", now=100.0)
|
||||
assert throttle.retry_after("10.0.0.1", now=100.0) == pytest.approx(60.0)
|
||||
assert throttle.retry_after("10.0.0.9", now=100.0) == 0.0 # another address, another bucket
|
||||
|
||||
|
||||
def test_an_unknown_key_is_never_blocked() -> None:
|
||||
assert auth.LoginThrottle().retry_after("nobody", now=1.0) == 0.0
|
||||
|
||||
|
||||
def test_the_backoff_doubles_and_caps_at_max_seconds() -> None:
|
||||
throttle = auth.LoginThrottle(max_attempts=3, base_seconds=10.0, max_seconds=100.0)
|
||||
now = 1_000.0
|
||||
assert throttle.failure("k", now=now) == 0.0 # 1st
|
||||
assert throttle.failure("k", now=now) == 0.0 # 2nd
|
||||
assert throttle.failure("k", now=now) == 10.0 # 3rd trips the block
|
||||
assert throttle.retry_after("k", now=now) == 10.0
|
||||
now += 10
|
||||
assert throttle.failure("k", now=now) == 20.0
|
||||
now += 20
|
||||
assert throttle.failure("k", now=now) == 40.0
|
||||
now += 40
|
||||
assert throttle.failure("k", now=now) == 80.0
|
||||
now += 80
|
||||
assert throttle.failure("k", now=now) == 100.0 # capped
|
||||
now += 100
|
||||
assert throttle.failure("k", now=now) == 100.0 # and stays capped
|
||||
|
||||
|
||||
def test_the_block_lapses_on_its_own() -> None:
|
||||
throttle = auth.LoginThrottle(max_attempts=1, base_seconds=30.0)
|
||||
throttle.failure("k", now=500.0)
|
||||
assert throttle.retry_after("k", now=500.0) == 30.0
|
||||
assert throttle.retry_after("k", now=515.0) == pytest.approx(15.0)
|
||||
assert throttle.retry_after("k", now=530.0) == 0.0
|
||||
|
||||
|
||||
def test_a_failure_during_a_block_never_shortens_it() -> None:
|
||||
throttle = auth.LoginThrottle(max_attempts=2, base_seconds=60.0)
|
||||
throttle.failure("k", now=1_000.0)
|
||||
throttle.failure("k", now=1_000.0) # blocked until 1_060
|
||||
assert throttle.retry_after("k", now=1_000.0) == 60.0
|
||||
throttle.failure("k", now=1_010.0) # hammering during the lockout
|
||||
assert throttle.retry_after("k", now=1_010.0) > 60.0
|
||||
|
||||
|
||||
def test_a_successful_login_clears_the_key() -> None:
|
||||
throttle = auth.LoginThrottle(max_attempts=3, base_seconds=60.0)
|
||||
for _ in range(2):
|
||||
throttle.failure("k", now=10.0)
|
||||
throttle.success("k")
|
||||
assert throttle.retry_after("k", now=10.0) == 0.0
|
||||
# the count is gone too: the next block starts at the base step, not a doubled one
|
||||
throttle.failure("k", now=10.0)
|
||||
throttle.failure("k", now=10.0)
|
||||
assert throttle.failure("k", now=10.0) == 60.0
|
||||
throttle.success("k")
|
||||
assert throttle.retry_after("k", now=10.0) == 0.0
|
||||
throttle.success("k") # clearing an unknown key is not an error
|
||||
|
||||
|
||||
def test_prune_forgets_only_keys_that_can_no_longer_affect_a_decision() -> None:
|
||||
throttle = auth.LoginThrottle(max_attempts=2, base_seconds=60.0, max_seconds=600.0)
|
||||
throttle.failure("quiet", now=0.0) # under the block threshold, then idle
|
||||
throttle.failure("blocked", now=0.0)
|
||||
throttle.failure("blocked", now=0.0) # blocked until 60
|
||||
assert throttle.tracked == 2
|
||||
assert throttle.prune(now=100.0) == 0 # 100 - 0 < max_seconds: too soon to forget
|
||||
assert throttle.retry_after("blocked", now=100.0) == 0.0
|
||||
assert throttle.prune(now=700.0) == 2
|
||||
assert throttle.tracked == 0
|
||||
assert throttle.prune(now=700.0) == 0
|
||||
|
||||
|
||||
def test_the_dict_is_bounded_against_a_swarm_of_source_addresses() -> None:
|
||||
"""A spoofed spray must not turn the dashboard's own memory into the outage."""
|
||||
throttle = auth.LoginThrottle()
|
||||
for _ in range(5):
|
||||
throttle.failure("attacker", now=5_000.0)
|
||||
assert throttle.retry_after("attacker", now=5_000.0) == pytest.approx(60.0)
|
||||
for index in range(auth.THROTTLE_MAX_KEYS * 2):
|
||||
throttle.failure(f"swarm-{index}", now=float(index))
|
||||
assert throttle.tracked <= auth.THROTTLE_MAX_KEYS
|
||||
# what pressure drops is the least-recently-active key, never the one still attacking
|
||||
assert throttle.retry_after("attacker", now=5_000.0) == pytest.approx(60.0)
|
||||
|
||||
|
||||
def test_the_length_guard_prunes_before_it_evicts() -> None:
|
||||
throttle = auth.LoginThrottle()
|
||||
for index in range(auth.THROTTLE_MAX_KEYS):
|
||||
throttle.failure(f"k{index}", now=0.0)
|
||||
assert throttle.tracked == auth.THROTTLE_MAX_KEYS
|
||||
# every entry has been idle past max_seconds, so pruning alone makes the room
|
||||
throttle.failure("newest", now=10_000.0)
|
||||
assert throttle.tracked == 1
|
||||
|
||||
|
||||
def test_silly_limits_are_clamped_instead_of_disabling_the_throttle() -> None:
|
||||
throttle = auth.LoginThrottle(max_attempts=0, base_seconds=30.0, max_seconds=5.0)
|
||||
assert throttle.max_attempts == 1
|
||||
assert throttle.base_seconds == 30.0
|
||||
assert throttle.max_seconds == 30.0
|
||||
assert throttle.failure("k", now=0.0) == 30.0
|
||||
|
||||
|
||||
# --- origin / Sec-Fetch ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
def test_the_origin_gate_accepts_this_app_and_the_allow_list() -> None:
|
||||
own = "https://dash.example"
|
||||
assert auth.origin_matches(own, own) is True
|
||||
assert auth.origin_matches("https://dash.example/", own) is True
|
||||
assert auth.origin_matches("HTTPS://DASH.EXAMPLE", own) is True
|
||||
assert auth.origin_matches(" https://dash.example ", own) is True
|
||||
extra = ["https://lab.internal:8090"]
|
||||
assert auth.origin_matches("https://lab.internal:8090", own, extra) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("origin", [
|
||||
"http://evil.example",
|
||||
"https://evil.example",
|
||||
"https://dash.example:9999",
|
||||
"http://dash.example", # a different scheme is not the same origin
|
||||
"https://dash.example.evil.net",
|
||||
"null",
|
||||
])
|
||||
def test_the_origin_gate_rejects_everything_else(origin: str) -> None:
|
||||
assert auth.origin_matches(origin, "https://dash.example") is False
|
||||
|
||||
|
||||
def test_an_empty_origin_is_not_a_match_and_the_caller_gates_on_presence() -> None:
|
||||
"""The empty string is not this app's origin; `if origin and not origin_matches(…)` is the
|
||||
caller's check, so curl (no Origin header at all) is not rejected here."""
|
||||
assert auth.origin_matches("", "https://dash.example") is False
|
||||
assert auth.origin_matches(" ", "https://dash.example") is False
|
||||
assert auth.origin_matches("", "") is False
|
||||
|
||||
|
||||
def test_the_sec_fetch_gate_allows_same_origin_and_non_browsers_only() -> None:
|
||||
assert auth.sec_fetch_site_allowed(None) is True
|
||||
assert auth.sec_fetch_site_allowed("same-origin") is True
|
||||
assert auth.sec_fetch_site_allowed("none") is True
|
||||
assert auth.sec_fetch_site_allowed(" Same-Origin ") is True
|
||||
assert frozenset({"same-origin", "none"}) == auth.ALLOWED_SEC_FETCH_SITE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"cross-site", "cross-origin", "same-site", "SAME-SITE", "", " ", "sourcerer", "same-site ",
|
||||
])
|
||||
def test_an_unrecognised_sec_fetch_site_is_rejected(value: str) -> None:
|
||||
"""Unknown → reject: a browser inventing a value is a browser doing something we did not
|
||||
reason about, so the safe answer is the one this returns."""
|
||||
assert auth.sec_fetch_site_allowed(value) is False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
"""Where the admin credential lives, and what happens when it does not (phase 11 task 02).
|
||||
|
||||
`auth.load_auth_state` is the only code that turns a deployment's environment plus the durable
|
||||
`meta` rows into the state every request is checked against, and it owns three decisions that
|
||||
are easy to get subtly wrong and impossible to see in a screenshot:
|
||||
|
||||
* the environment **seeds** a password once — after the first boot the stored verifier wins, so
|
||||
`/settings` can change it without a redeploy and a restart cannot roll it back;
|
||||
* a server with neither a stored verifier nor `DASH_ADMIN_PASSWORD` **refuses to start**
|
||||
(fail closed) rather than serving the fleet open behind a public domain;
|
||||
* changing the password rotates the signing secret, which is what signs out every *other*
|
||||
browser when there is no session table to walk.
|
||||
|
||||
The store here is a dict. That is the point of the `MetaStore` protocol: the security-relevant
|
||||
branch has no SQLite in it, so these tests assert the decisions rather than the schema — and
|
||||
one test at the end puts the real `SampleStore` back underneath to prove the durability the
|
||||
protocol is written against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from dashboards import auth
|
||||
from dashboards.store import SampleStore
|
||||
|
||||
#: Nothing in this file is about the cost; `tests/test_auth.py` pins the 600 000 default.
|
||||
CHEAP = 1_000
|
||||
|
||||
PASSWORD = "testpass"
|
||||
|
||||
|
||||
class FakeMeta:
|
||||
"""The three-method shape `load_auth_state` asks for, with no database in it."""
|
||||
|
||||
def __init__(self, rows: dict[str, str] | None = None) -> None:
|
||||
self.rows: dict[str, str] = dict(rows or {})
|
||||
|
||||
def get_meta(self, key: str) -> str | None:
|
||||
return self.rows.get(key)
|
||||
|
||||
def set_meta(self, key: str, value: str) -> None:
|
||||
self.rows[key] = value
|
||||
|
||||
def delete_meta(self, key: str) -> int:
|
||||
return 1 if self.rows.pop(key, None) is not None else 0
|
||||
|
||||
|
||||
def load(store: FakeMeta, *, admin_password: str = PASSWORD, **kwargs: Any) -> auth.AuthState:
|
||||
return auth.load_auth_state(store, admin_password=admin_password, iterations=CHEAP,
|
||||
**kwargs)
|
||||
|
||||
|
||||
def seed(*, admin_password: str = PASSWORD) -> tuple[FakeMeta, auth.AuthState]:
|
||||
"""A store that has already booted once, and the state that boot produced."""
|
||||
store = FakeMeta()
|
||||
return store, load(store, admin_password=admin_password)
|
||||
|
||||
|
||||
# --- first boot: the environment seeds ---------------------------------------------------- #
|
||||
|
||||
|
||||
def test_first_boot_seeds_the_verifier_and_persists_the_secret() -> None:
|
||||
store = FakeMeta()
|
||||
state = load(store)
|
||||
|
||||
assert state.seeded is True, "this start wrote the verifier row"
|
||||
assert state.env_ignored is False, "there was nothing stored to ignore the env with"
|
||||
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
|
||||
assert store.rows[auth.META_SESSION_SECRET] == state.secret
|
||||
assert state.iterations == CHEAP
|
||||
assert state.session_hours == auth.SESSION_HOURS
|
||||
|
||||
|
||||
def test_the_seed_is_stored_as_a_verifier_and_never_as_a_password() -> None:
|
||||
"""`AGENTS.md` invariant 6 on the way *in*: the row is PBKDF2 text, not the secret.
|
||||
|
||||
The completion criterion for the task is that `grep -c testpass` on a real database is 0;
|
||||
this is the same claim one layer up, where a regression would show.
|
||||
"""
|
||||
store = FakeMeta()
|
||||
state = load(store)
|
||||
for value in [*store.rows.values(), state.verifier, state.secret]:
|
||||
assert PASSWORD not in value
|
||||
assert store.rows[auth.META_VERIFIER].startswith(f"{auth.PBKDF2_ALGO}${CHEAP}$")
|
||||
|
||||
|
||||
def test_the_stored_rows_survive_a_reopen_of_the_real_database(tmp_path: Path) -> None:
|
||||
"""The whole reason this is in SQLite and not in process memory.
|
||||
|
||||
A restart must not re-seed, must not mint a new signing secret (that would sign every
|
||||
browser out on every deploy), and must not need `DASH_ADMIN_PASSWORD` any more.
|
||||
"""
|
||||
path = tmp_path / "dashboards.sqlite3"
|
||||
first_store = SampleStore(path)
|
||||
try:
|
||||
first = auth.load_auth_state(first_store, admin_password=PASSWORD, iterations=CHEAP)
|
||||
assert first.seeded is True
|
||||
finally:
|
||||
first_store.close()
|
||||
|
||||
reopened = SampleStore(path)
|
||||
try:
|
||||
second = auth.load_auth_state(reopened, admin_password="", iterations=CHEAP)
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
assert second.seeded is False
|
||||
assert second.verifier == first.verifier
|
||||
assert second.secret == first.secret, "a new secret on every restart would log everyone out"
|
||||
assert auth.verify_password(PASSWORD, second.verifier) is True
|
||||
|
||||
|
||||
# --- the stored verifier wins ------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_stored_verifier_beats_a_conflicting_env_password(caplog: pytest.LogCaptureFixture) -> None:
|
||||
store, _first = seed()
|
||||
original = store.rows[auth.META_VERIFIER]
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
|
||||
second = load(store, admin_password="some-other-password")
|
||||
|
||||
assert second.env_ignored is True
|
||||
assert second.seeded is False
|
||||
assert store.rows[auth.META_VERIFIER] == original, "the env must not overwrite the row"
|
||||
assert second.verifier == original
|
||||
assert auth.verify_password(PASSWORD, second.verifier) is True
|
||||
assert auth.verify_password("some-other-password", second.verifier) is False
|
||||
|
||||
|
||||
def test_the_ignored_env_var_is_reported_once_in_fixed_words(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""The operator who set `DASH_ADMIN_PASSWORD` and saw nothing happen gets one sentence.
|
||||
|
||||
The sentence must name the variable and the row; it must not name the value, the hash, or
|
||||
any prefix of either — that line ends up in a container log, which is exactly where
|
||||
invariant 6 says a credential must not go.
|
||||
"""
|
||||
store, _first = seed()
|
||||
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
|
||||
state = load(store, admin_password="ignored-now")
|
||||
|
||||
messages = [rec.getMessage() for rec in caplog.records if rec.name == "dashboards.auth"]
|
||||
assert messages == [auth.ENV_IGNORED_MESSAGE], "exactly one line, at startup"
|
||||
line = messages[0]
|
||||
assert "DASH_ADMIN_PASSWORD is ignored" in line
|
||||
assert auth.META_VERIFIER in line
|
||||
for secret in (PASSWORD, "ignored-now", state.verifier, state.secret):
|
||||
assert secret not in line
|
||||
|
||||
|
||||
def test_the_warning_is_not_written_when_the_env_actually_seeded(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""`seeded` and `env_ignored` are mutually exclusive, and only the second one speaks."""
|
||||
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
|
||||
state = load(FakeMeta())
|
||||
assert state.env_ignored is False
|
||||
assert [r for r in caplog.records if r.name == "dashboards.auth"] == []
|
||||
|
||||
|
||||
def test_the_environment_variable_can_be_dropped_after_first_boot() -> None:
|
||||
"""The operator's wording: "configured initially with an env var, not required to change".
|
||||
|
||||
Second boot with no seed at all reuses the row, reports neither `seeded` nor
|
||||
`env_ignored`, and stays quiet.
|
||||
"""
|
||||
store, first = seed()
|
||||
second = load(store, admin_password="")
|
||||
assert second.seeded is False
|
||||
assert second.env_ignored is False
|
||||
assert second.verifier == first.verifier
|
||||
|
||||
|
||||
def test_a_blank_stored_verifier_counts_as_no_verifier_at_all() -> None:
|
||||
"""A row of whitespace is not a password — reseeding it is the only way back in."""
|
||||
store = FakeMeta({auth.META_VERIFIER: " \n "})
|
||||
state = load(store)
|
||||
assert state.seeded is True
|
||||
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
|
||||
|
||||
|
||||
# --- fail closed -------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_no_verifier_and_no_seed_refuses_to_start_instead_of_running_open() -> None:
|
||||
store = FakeMeta()
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
load(store, admin_password="")
|
||||
|
||||
message = str(caught.value)
|
||||
assert "DASH_ADMIN_PASSWORD" in message, "names the variable to set"
|
||||
assert auth.META_VERIFIER in message, "names the row that would have been used"
|
||||
assert auth.META_SESSION_SECRET in message
|
||||
assert "DELETE FROM meta WHERE key IN" in message, "gives the reset command"
|
||||
assert "sqlite3" in message
|
||||
assert store.rows == {}, "refusing means writing nothing, not writing and then complaining"
|
||||
|
||||
|
||||
def test_the_refusal_names_the_database_the_operator_has_to_reset() -> None:
|
||||
"""`<db>` is a placeholder nobody can run; the real path is what makes the fix copy-paste."""
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
auth.load_auth_state(FakeMeta(), admin_password="", db_path="/var/lib/dashboards/d.db")
|
||||
assert "/var/lib/dashboards/d.db" in str(caught.value)
|
||||
|
||||
with pytest.raises(RuntimeError) as bare:
|
||||
auth.load_auth_state(FakeMeta(), admin_password="")
|
||||
assert "<db>" in str(bare.value), "no path known, and no invented one either"
|
||||
|
||||
|
||||
def test_the_refusal_carries_no_password_and_no_verifier() -> None:
|
||||
"""The message reaches a terminal and a container log; it may carry instructions only."""
|
||||
store = FakeMeta()
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
load(store, admin_password="")
|
||||
assert PASSWORD not in str(caught.value)
|
||||
|
||||
|
||||
# --- the signing secret -------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_usable_session_secret_is_reused_as_is() -> None:
|
||||
"""A restart must not rotate the key, or every browser is signed out for nothing."""
|
||||
existing = auth.new_secret()
|
||||
# a second boot, not a first: the verifier row exists and the secret is what is under test
|
||||
store = FakeMeta({auth.META_VERIFIER: auth.hash_password(PASSWORD, iterations=CHEAP),
|
||||
auth.META_SESSION_SECRET: existing})
|
||||
state = load(store, admin_password="")
|
||||
assert state.secret == existing
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corrupt", [
|
||||
"",
|
||||
" ",
|
||||
"deadbeef", # hex, but a 16-bit key is not what we sign a 30-day cookie with
|
||||
"z" * 64, # long enough, and not hex
|
||||
"0" * 63, # one character short of 256 bits
|
||||
])
|
||||
def test_a_corrupt_session_secret_is_regenerated_and_persisted(corrupt: str) -> None:
|
||||
"""The chosen behaviour, stated: replace it and write the replacement.
|
||||
|
||||
The effect on the operator is "every browser is asked to sign in again" — a login prompt,
|
||||
not a lockout — which is the safe direction for a damaged row. Refusing to start instead
|
||||
would turn a corrupt cell into an outage of a dashboard that still has a working password.
|
||||
"""
|
||||
store, first = seed()
|
||||
store.rows[auth.META_SESSION_SECRET] = corrupt
|
||||
|
||||
state = load(store, admin_password="")
|
||||
|
||||
assert state.secret != corrupt
|
||||
assert state.secret == store.rows[auth.META_SESSION_SECRET], "the replacement is durable"
|
||||
assert len(state.secret) == auth.SECRET_MIN_BYTES * 2
|
||||
bytes.fromhex(state.secret) # raises if the replacement is not the format we mint
|
||||
assert state.verifier == first.verifier, "the password is untouched"
|
||||
# and the state is usable: a session minted from it reads back
|
||||
token = auth.mint_session(state.secret, now=1_700_000_000.0)
|
||||
assert auth.read_session(state.secret, token, now=1_700_000_000.0) is not None
|
||||
|
||||
|
||||
# --- changing the password ----------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_update_verifier_replaces_both_rows_and_returns_the_new_state() -> None:
|
||||
store, before = seed()
|
||||
old_token = auth.mint_session(before.secret, now=1_700_000_000.0)
|
||||
|
||||
after = auth.update_verifier(store, before, "horse battery staple")
|
||||
|
||||
assert after.secret != before.secret
|
||||
assert store.rows[auth.META_SESSION_SECRET] == after.secret
|
||||
assert store.rows[auth.META_VERIFIER] == after.verifier
|
||||
assert auth.verify_password("horse battery staple", after.verifier) is True
|
||||
assert auth.verify_password(PASSWORD, after.verifier) is False
|
||||
assert after.session_hours == before.session_hours
|
||||
assert after.iterations == before.iterations
|
||||
assert after.seeded is False and after.env_ignored is False
|
||||
# the browser that was not in front of the form is out: its cookie is signed by the old key
|
||||
assert auth.read_session(after.secret, old_token, now=1_700_000_000.0) is None
|
||||
# and the new state can still mint one that reads
|
||||
fresh = auth.mint_session(after.secret, now=1_700_000_000.0)
|
||||
assert auth.read_session(after.secret, fresh, now=1_700_000_000.0) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\t", "\n \r"])
|
||||
def test_update_verifier_refuses_to_store_an_empty_password(blank: str) -> None:
|
||||
store, before = seed()
|
||||
with pytest.raises(ValueError) as caught:
|
||||
auth.update_verifier(store, before, blank)
|
||||
|
||||
assert store.rows[auth.META_VERIFIER] == before.verifier
|
||||
assert store.rows[auth.META_SESSION_SECRET] == before.secret
|
||||
# fixed text: nothing the caller typed is echoed back into a log or a traceback
|
||||
assert str(caught.value) == "a new password must not be empty or whitespace"
|
||||
|
||||
|
||||
def test_the_state_is_a_frozen_bundle_of_credentials() -> None:
|
||||
"""Nothing may mutate a live `AppContext.auth`, and nothing may treat it as display data."""
|
||||
_store, state = seed()
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.verifier = "swapped" # type: ignore[misc]
|
||||
assert {"verifier", "secret", "session_hours", "iterations", "seeded", "env_ignored"} == {
|
||||
f.name for f in state.__dataclass_fields__.values()}
|
||||
@@ -0,0 +1,265 @@
|
||||
"""The operator-facing setup surface of phase 11: the two templates, the dev script, the docs.
|
||||
|
||||
Nothing here runs a server. What it does is pin the four artefacts an operator actually reads
|
||||
when a dashboard will not come up, because every one of them is prose that can go stale without
|
||||
breaking a single assertion in the rest of the suite:
|
||||
|
||||
* the tracked env templates must **declare** every auth setting (a setting nobody has to write a
|
||||
line for is a setting nobody knows exists) and must carry **no real secret** — the same rule
|
||||
`.agents/validate.sh` 2b enforces through `git ls-files`, restated here so `uv run pytest`
|
||||
catches it too and catches it on the working tree, not only on what was committed;
|
||||
* `scripts/dev-server.sh` must keep working **unsigned**: its readiness probe and its `status`
|
||||
both ride `/api/health`, the one data route the gate leaves open (AGENTS.md invariant 12). The
|
||||
day someone "fixes" the script by signing in first, the script grows a password and the
|
||||
Containerfile healthcheck is the next thing that needs one;
|
||||
* `README.md`'s *Signing in* section and `AGENTS.md`'s invariant/gap prose must still name the
|
||||
things an operator has to act on — the four public paths, the reset command, the four
|
||||
variables, and the deployment flags. A doc that stops naming the reset command is a support
|
||||
call about a locked-out admin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ENV_EXAMPLE = ROOT / ".env.example"
|
||||
ENV_DEV_EXAMPLE = ROOT / ".env.dev.example"
|
||||
DEV_SERVER = ROOT / "scripts" / "dev-server.sh"
|
||||
README = ROOT / "README.md"
|
||||
AGENTS = ROOT / "AGENTS.md"
|
||||
|
||||
#: The four settings phase 11 added. `tests/test_config.py` pins what they *mean*; these tests
|
||||
#: pin that a human setting up a box is told they exist.
|
||||
AUTH_VARS = (
|
||||
"DASH_ADMIN_PASSWORD",
|
||||
"DASH_SESSION_HOURS",
|
||||
"DASH_COOKIE_SECURE",
|
||||
"DASH_PBKDF2_ITERATIONS",
|
||||
)
|
||||
|
||||
#: The public surface, as `app.py`'s `PUBLIC_PATHS`/`PUBLIC_PREFIXES` and AGENTS.md invariant 12
|
||||
#: name it. A README that lists a different set is the doc drifting from the gate.
|
||||
PUBLIC_ENTRIES = ("/login", "/api/auth/login", "/api/health", "/static/")
|
||||
|
||||
#: `.agents/validate.sh` 2b's placeholder allow-list, copied deliberately: the gate and the test
|
||||
#: are two readers of one rule, and a test that invents a laxer list would pass while the gate
|
||||
#: fails (or the reverse). The empty alternative is the "no secret here, unset it" spelling.
|
||||
PLACEHOLDER = re.compile(
|
||||
r"^(|changeme|change-me|replace-me[-a-z0-9]*|secret|placeholder|dummy|example|example\.net|x+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: A secret-shaped *assignment* (never a comment: the pattern is anchored at `^` exactly like the
|
||||
#: gate's, which is what makes prose about the local testing password safe to write).
|
||||
SECRET_ASSIGNMENT = re.compile(
|
||||
r"^[ \t]*(?:export[ \t]+)?([A-Za-z0-9_]*(?:PASSWORD|TOKEN|SECRET|KEY)[A-Za-z0-9_]*)=(.*)$"
|
||||
)
|
||||
|
||||
#: The local-testing word (`tests/auth_contract.ADMIN_PASSWORD`). It is documentation, not a
|
||||
#: credential — AGENTS.md invariant 11 states it in prose too — but it must never become the
|
||||
#: *value* of a tracked line, which is the exact trap the phase task calls out.
|
||||
LOCAL_TESTING_PASSWORD = "testpass"
|
||||
|
||||
TEMPLATES = (ENV_EXAMPLE, ENV_DEV_EXAMPLE)
|
||||
|
||||
|
||||
def _assignments(path: Path) -> dict[str, str]:
|
||||
"""Every secret-shaped assignment in a file, comment lines excluded by the anchor."""
|
||||
out: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
match = SECRET_ASSIGNMENT.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
name, value = match.group(1), match.group(2)
|
||||
# The gate's own value munging: strip a trailing comment, whitespace, and quotes.
|
||||
value = value.split("#", 1)[0].replace('"', "").replace("'", "").strip()
|
||||
out[name] = value
|
||||
return out
|
||||
|
||||
|
||||
# --- the two tracked env templates ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
||||
def test_every_auth_setting_is_declared_in_the_template(template: Path) -> None:
|
||||
"""A setting that appears nowhere in the template is a setting nobody knows to set."""
|
||||
text = template.read_text()
|
||||
declared = {
|
||||
name for name in AUTH_VARS if re.search(rf"^[ \t]*(?:export[ \t]+)?{name}=", text, re.MULTILINE)
|
||||
}
|
||||
assert declared == set(AUTH_VARS), f"{template.name} declares {sorted(declared)}, wants all of {AUTH_VARS}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
||||
def test_no_secret_shaped_assignment_carries_anything_but_a_placeholder(template: Path) -> None:
|
||||
"""The hermetic twin of `.agents/validate.sh` 2b, run on the working tree.
|
||||
|
||||
`testpass` is not on the allow-list and must not be: the fix for a template that needs a
|
||||
dev password is a placeholder plus prose, with the real word in the gitignored file.
|
||||
"""
|
||||
offenders = {
|
||||
name: value for name, value in _assignments(template).items() if not PLACEHOLDER.match(value)
|
||||
}
|
||||
assert not offenders, f"{template.name} carries a real-looking secret: {offenders}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
||||
def test_the_local_testing_password_is_prose_and_never_an_assignment(template: Path) -> None:
|
||||
"""The word is allowed in a comment (it is in AGENTS.md too) and forbidden as a value."""
|
||||
text = template.read_text()
|
||||
values = _assignments(template)
|
||||
assert LOCAL_TESTING_PASSWORD not in values.values(), (
|
||||
f"{template.name} assigns the local-testing password; it belongs in .env.dev"
|
||||
)
|
||||
commented = [ln for ln in text.splitlines() if ln.lstrip().startswith("#")]
|
||||
if LOCAL_TESTING_PASSWORD in text:
|
||||
assert any(LOCAL_TESTING_PASSWORD in ln for ln in commented), (
|
||||
f"{template.name} names the local-testing password outside a comment"
|
||||
)
|
||||
|
||||
|
||||
def test_the_dev_template_points_at_where_the_local_password_lives() -> None:
|
||||
"""A dev copying the template needs to know what to type at the first login prompt."""
|
||||
text = ENV_DEV_EXAMPLE.read_text().lower()
|
||||
assert LOCAL_TESTING_PASSWORD in text, "the dev template stopped naming the local-testing word"
|
||||
assert "auth_contract" in text or "auth_client" in text, (
|
||||
"the dev template stopped pointing at the file that defines that word"
|
||||
)
|
||||
# …and the seed-vs-override rule, which is the sentence that prevents the "I edited the env
|
||||
# file and nothing happened" call, has to be in the file people actually edit.
|
||||
assert "seed" in text
|
||||
assert "refuses to start" in text
|
||||
|
||||
|
||||
def test_the_tracked_templates_are_the_only_tracked_env_files() -> None:
|
||||
"""`.gitignore` un-ignores exactly the two `.example` files; a third one is a leak waiting."""
|
||||
tracked = sorted(p.name for p in ROOT.glob(".env*"))
|
||||
allowed = {".env", ".env.dev", ".env.example", ".env.dev.example"}
|
||||
assert set(tracked) <= allowed, f"unexpected env file in the checkout: {set(tracked) - allowed}"
|
||||
for name in (".env.example", ".env.dev.example"):
|
||||
assert (ROOT / name).is_file(), f"{name} must stay tracked — it is the setup documentation"
|
||||
|
||||
|
||||
# --- scripts/dev-server.sh ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_dev_server_probes_the_public_health_route() -> None:
|
||||
"""`start`'s readiness probe and `status` both ride `/api/health`, the one open data route."""
|
||||
text = DEV_SERVER.read_text()
|
||||
assert text.count("/api/health") >= 2, "both start()'s probe and status() must use /api/health"
|
||||
assert "/api/summary" not in text and "/api/config" not in text, (
|
||||
"the dev script started polling a route that needs a session"
|
||||
)
|
||||
|
||||
|
||||
def test_the_dev_server_grew_no_auth_logic_and_no_password() -> None:
|
||||
"""Invariant 12's corollary: the script needs no credential, so it must never hold one."""
|
||||
text = DEV_SERVER.read_text()
|
||||
for forbidden in ("dash_session", "/api/auth/", "X-CSRF-Token", "Authorization:"):
|
||||
assert forbidden not in text, f"dev-server.sh grew auth ({forbidden!r})"
|
||||
assert LOCAL_TESTING_PASSWORD not in text, "the local-testing password does not belong in a script"
|
||||
# What it *does* say: where the credential comes from, without quoting it. Naming the
|
||||
# variable is fine; an assignment is a value, and a value here is a committed password.
|
||||
assert "DASH_ADMIN_PASSWORD" in text, "the script stopped telling the operator where to sign in with"
|
||||
assert not re.search(r"DASH_ADMIN_PASSWORD\s*=", text), (
|
||||
"dev-server.sh assigns the admin password; it may only name the variable"
|
||||
)
|
||||
|
||||
|
||||
def test_the_dev_server_still_parses() -> None:
|
||||
"""`bash -n` is the only syntax gate a shell script here gets outside a real run."""
|
||||
bash = shutil.which("bash") or "/bin/bash"
|
||||
result = subprocess.run([bash, "-n", str(DEV_SERVER)], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
# --- README.md ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_readme_has_a_signing_in_section_before_configuration() -> None:
|
||||
"""The first thing an operator meets is a login prompt; the doc says so before the table."""
|
||||
text = README.read_text()
|
||||
signing = text.index("## Signing in")
|
||||
configuration = text.index("## Configuration")
|
||||
assert signing < configuration, "*Signing in* must come before *Configuration*"
|
||||
assert "## Admin login" not in text, "the section was renamed; a stale heading is a broken anchor"
|
||||
|
||||
|
||||
def test_readme_documents_the_public_surface_and_why_health_is_one_of_it() -> None:
|
||||
text = README.read_text()
|
||||
section = text[text.index("## Signing in"):text.index("## Configuration")]
|
||||
for entry in PUBLIC_ENTRIES:
|
||||
assert entry in section, f"*Signing in* stopped naming the public entry {entry}"
|
||||
# The reason is the part an operator decides with, not the list.
|
||||
assert "HEALTHCHECK" in section and "dev-server.sh" in section, (
|
||||
"*Signing in* must say why /api/health stays open"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_carries_the_reset_command_verbatim() -> None:
|
||||
"""Copy-pasteable: someone locked out of a public dashboard reads this line and runs it."""
|
||||
text = README.read_text()
|
||||
assert "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')" in text, (
|
||||
"README's password-reset command changed shape; make it match the RuntimeError in auth.py"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_documents_every_auth_variable_and_the_precedence_rule() -> None:
|
||||
text = README.read_text()
|
||||
table = text[text.index("## Configuration"):text.index("## Architecture")]
|
||||
for name in AUTH_VARS:
|
||||
assert f"`{name}`" in table, f"the configuration table lost {name}"
|
||||
# seed → stored verifier wins, and the bearer path for scripted callers
|
||||
assert "seed" in table
|
||||
signing = text[text.index("## Signing in"):text.index("## Configuration")]
|
||||
assert "Authorization: Bearer" in signing, "the scripted-caller path is undocumented"
|
||||
assert "30 days" in signing or "720" in signing, "the session lifetime is undocumented"
|
||||
|
||||
|
||||
def test_readme_tells_the_deployment_repo_what_to_set() -> None:
|
||||
"""Two lines belong in the quadlet env template; a note about nginx lives there, not here."""
|
||||
text = README.read_text()
|
||||
deployment = text[text.index("## Deployment"):]
|
||||
assert "DASH_ADMIN_PASSWORD" in deployment
|
||||
assert "DASH_COOKIE_SECURE=1" in deployment, "the deployment note stopped asking for Secure cookies"
|
||||
assert "HSTS" in deployment and "deployment repo" in deployment, (
|
||||
"README must say nginx config/HSTS live in the deployment repo like every other service"
|
||||
)
|
||||
|
||||
|
||||
# --- AGENTS.md ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agents_md_carries_the_login_invariants() -> None:
|
||||
"""One rule for the credential, one for the gate, and the public list named in full."""
|
||||
text = AGENTS.read_text()
|
||||
rules = text[text.index("## Non-negotiable rules"):text.index("## Architecture")]
|
||||
for entry in PUBLIC_ENTRIES:
|
||||
assert entry in rules, f"the invariants stopped naming the public entry {entry}"
|
||||
assert "seed" in rules and "refuses to start" in rules
|
||||
assert "auth.verifier" in rules and "auth.session_secret" in rules
|
||||
# /api/health being open is a decision, and the invariant has to say what that costs.
|
||||
assert "nothing may be added to it without re-deciding" in rules
|
||||
|
||||
|
||||
def test_agents_md_documents_the_session_and_the_pages_that_need_it() -> None:
|
||||
text = AGENTS.read_text()
|
||||
intro = text[:text.index("## Non-negotiable rules")]
|
||||
assert "/settings" in intro and "/login" in intro, "the page list stopped naming the auth pages"
|
||||
assert "invariant 12" in intro, "the page list must point at the gate rule, not restate it"
|
||||
assert "- `auth.py` —" in text, "the module-responsibility list lost auth.py"
|
||||
|
||||
|
||||
def test_agents_md_names_the_three_auth_gaps() -> None:
|
||||
"""Peer-address lockout, single factor by design, per-process throttle: all three stay known."""
|
||||
text = AGENTS.read_text()
|
||||
gaps = text[text.index("## Known gaps"):text.index("## Locked decisions")]
|
||||
assert "socket peer address" in gaps, "the lockout-is-global gap went missing"
|
||||
assert "no second factor and no per-user accounts" in gaps, "the single-factor gap went missing"
|
||||
assert "The throttle is per-process" in gaps, "the multi-worker gap went missing"
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from dashboards import config
|
||||
from dashboards.auth import PBKDF2_ITERATIONS
|
||||
from dashboards.config import Settings, parse_targets
|
||||
|
||||
|
||||
@@ -216,3 +217,76 @@ def test_the_example_file_is_never_loaded(monkeypatch: pytest.MonkeyPatch, tmp_p
|
||||
reloaded = importlib.reload(config)
|
||||
assert reloaded.load_env_file() is None
|
||||
assert reloaded.SETTINGS.nut_host == ""
|
||||
|
||||
|
||||
# ---- authentication settings (phase 11) ------------------------------------ #
|
||||
|
||||
|
||||
def test_the_auth_defaults_are_a_locked_door_and_a_long_session() -> None:
|
||||
"""No default password, a 30-day session, no `Secure` flag, OWASP's cost.
|
||||
|
||||
Each default is a decision, so each is asserted rather than inherited:
|
||||
|
||||
* `admin_password == ""` is what makes "no password configured" detectable at all — a
|
||||
default of `testpass` would ship a known credential on every box that forgot the env.
|
||||
* 720 h (30 days) because a dashboard that logs the operator out mid-outage gets bypassed.
|
||||
* `cookie_secure` off: dev is `http://127.0.0.1`, and a default on would make the cookie
|
||||
unsendable there (the task adds the `X-Forwarded-Proto` case for the real proxy).
|
||||
* the cost is `auth.PBKDF2_ITERATIONS`, imported rather than repeated, so the primitive and
|
||||
the setting cannot drift.
|
||||
"""
|
||||
settings = Settings()
|
||||
assert settings.admin_password == ""
|
||||
assert settings.session_hours == 720.0
|
||||
assert settings.cookie_secure is False
|
||||
assert settings.pbkdf2_iterations == PBKDF2_ITERATIONS == 600_000
|
||||
|
||||
|
||||
def test_the_auth_settings_come_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DASH_ADMIN_PASSWORD", " spaced-but-real ")
|
||||
monkeypatch.setenv("DASH_SESSION_HOURS", "12")
|
||||
monkeypatch.setenv("DASH_COOKIE_SECURE", "1")
|
||||
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "4096")
|
||||
reloaded = importlib.reload(config)
|
||||
try:
|
||||
assert reloaded.SETTINGS.admin_password == "spaced-but-real", "_env() strips"
|
||||
assert reloaded.SETTINGS.session_hours == 12.0
|
||||
assert reloaded.SETTINGS.cookie_secure is True
|
||||
assert reloaded.SETTINGS.pbkdf2_iterations == 4096
|
||||
finally:
|
||||
for name in ("DASH_ADMIN_PASSWORD", "DASH_SESSION_HOURS", "DASH_COOKIE_SECURE",
|
||||
"DASH_PBKDF2_ITERATIONS"):
|
||||
monkeypatch.delenv(name)
|
||||
importlib.reload(config)
|
||||
|
||||
|
||||
def test_an_empty_DASH_ADMIN_PASSWORD_means_unset_not_blank(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`DASH_ADMIN_PASSWORD=` is the operator saying "no seed", not "the password is empty".
|
||||
|
||||
The distinction is load-bearing: `load_auth_state` treats `""` as no seed and fails closed,
|
||||
where a value of `""` that survived as a password would be the one credential shape an
|
||||
attacker tries first.
|
||||
"""
|
||||
monkeypatch.setenv("DASH_ADMIN_PASSWORD", "")
|
||||
reloaded = importlib.reload(config)
|
||||
try:
|
||||
assert reloaded.SETTINGS.admin_password == ""
|
||||
finally:
|
||||
monkeypatch.delenv("DASH_ADMIN_PASSWORD")
|
||||
importlib.reload(config)
|
||||
|
||||
|
||||
def test_the_pbkdf2_knob_is_for_tests_and_not_a_performance_tune(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A garbage value falls back to the OWASP figure rather than to 0 or 1.
|
||||
|
||||
`_int`/`_num` answer a non-number with the default, so a typo in
|
||||
`DASH_PBKDF2_ITERATIONS` cannot produce a verifier hashed once.
|
||||
"""
|
||||
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "not-a-cost")
|
||||
assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 600_000
|
||||
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "1000")
|
||||
assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 1000
|
||||
|
||||
@@ -21,6 +21,8 @@ from typing import Any
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth_client import sign_in
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from dashboards.app import create_app
|
||||
from dashboards.config import Settings
|
||||
from dashboards.store import SampleStore
|
||||
@@ -46,10 +48,15 @@ def client(tmp_path: Path) -> Iterator[TestClient]:
|
||||
"""A running app, so the deletion assertions run on the bytes a browser would get."""
|
||||
db = str(tmp_path / "slots-contract.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
test_client = TestClient(app)
|
||||
test_client.__enter__()
|
||||
try:
|
||||
# Signed in because this fixture reads `/llm` as served bytes, and `TestClient` follows
|
||||
# the 303 to /login: an unsigned client would check the deleted slot table against the
|
||||
# *login page* and pass without ever looking at /llm.
|
||||
sign_in(test_client)
|
||||
yield test_client
|
||||
finally:
|
||||
test_client.__exit__(None, None, None)
|
||||
|
||||
+9
-1
@@ -13,6 +13,8 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth_client import sign_in
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from dashboards import __main__ as entry
|
||||
from dashboards import debug as debug_module
|
||||
from dashboards.config import Settings
|
||||
@@ -39,7 +41,8 @@ def test_main_serves_the_assembled_app(fake_uvicorn: FakeUvicorn, tmp_path: Any,
|
||||
# Settings is frozen on purpose, so the module attribute is replaced, not poked. The DB
|
||||
# path is private: a test must not open the developer's running database.
|
||||
monkeypatch.setattr(entry, "SETTINGS",
|
||||
Settings(host="0.0.0.0", port=8090, db_path=str(tmp_path / "main.sqlite3")))
|
||||
Settings(host="0.0.0.0", port=8090, db_path=str(tmp_path / "main.sqlite3"),
|
||||
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2))
|
||||
monkeypatch.delenv("DASH_RELOAD", raising=False)
|
||||
entry.main()
|
||||
(target, kwargs), = fake_uvicorn.calls
|
||||
@@ -47,7 +50,12 @@ def test_main_serves_the_assembled_app(fake_uvicorn: FakeUvicorn, tmp_path: Any,
|
||||
# FastAPI keeps included routers in a private wrapper, so the honest check is a request
|
||||
assert isinstance(target, FastAPI)
|
||||
with TestClient(target) as client:
|
||||
# `/api/health` is public and must stay so — the Containerfile HEALTHCHECK has no
|
||||
# session and no way to get one. `/ups` is not: signing in here is what keeps this
|
||||
# test about startup wiring instead of about which routes are gated (that is
|
||||
# tests/test_auth_api.py's job).
|
||||
assert client.get("/api/health").status_code == 200
|
||||
sign_in(client)
|
||||
assert client.get("/ups").status_code == 200
|
||||
|
||||
|
||||
|
||||
@@ -326,3 +326,77 @@ def test_a_300s_source_is_pruned_by_the_existing_retention(db) -> None:
|
||||
assert counts["pruned"] >= 4
|
||||
assert db.query("disks", "h", "disk.temp_c@sda", start=0) == []
|
||||
assert len(db.query("disks", "h", "disk.temp_c@sdb", start=0)) == 1
|
||||
|
||||
|
||||
# ---- meta: the durable key/value state (phase 11) --------------------------- #
|
||||
|
||||
|
||||
def test_get_meta_answers_none_for_a_key_nobody_wrote(db) -> None:
|
||||
"""`None` and `""` are different sentences here.
|
||||
|
||||
`auth.verifier` missing means "this box has never been seeded" (fail closed at startup),
|
||||
while a value means "the credential lives here". A store that returned `""` for an absent
|
||||
row would read as a corrupt one instead.
|
||||
"""
|
||||
assert db.get_meta("no.such.key") is None
|
||||
db.set_meta("empty.on.purpose", "")
|
||||
assert db.get_meta("empty.on.purpose") == ""
|
||||
|
||||
|
||||
def test_meta_round_trips_through_a_reopen(db) -> None:
|
||||
db.set_meta("auth.session_secret", "ab" * 32)
|
||||
assert db.get_meta("auth.session_secret") == "ab" * 32
|
||||
db.close()
|
||||
reopened = SampleStore(db.path)
|
||||
try:
|
||||
assert reopened.get_meta("auth.session_secret") == "ab" * 32
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_set_meta_replaces_instead_of_adding(db) -> None:
|
||||
"""One row per key (`INSERT OR REPLACE`), because `key` is the primary key.
|
||||
|
||||
An `INSERT` alone would raise on the second write, which is the change-password path
|
||||
(`auth.update_verifier`) failing after the user pressed Save.
|
||||
"""
|
||||
db.set_meta("auth.verifier", "first")
|
||||
db.set_meta("auth.verifier", "second")
|
||||
assert db.get_meta("auth.verifier") == "second"
|
||||
(rows,) = db._db.execute( # the private handle is the point: exactly one row, not two
|
||||
"SELECT COUNT(*) FROM meta WHERE key='auth.verifier'").fetchone()
|
||||
assert rows == 1
|
||||
|
||||
|
||||
def test_delete_meta_reports_whether_a_row_was_there(db) -> None:
|
||||
"""The rowcount is the whole point: the password-reset procedure has to say what it did."""
|
||||
db.set_meta("auth.verifier", "x")
|
||||
assert db.delete_meta("auth.verifier") == 1
|
||||
assert db.delete_meta("auth.verifier") == 0
|
||||
assert db.get_meta("auth.verifier") is None
|
||||
|
||||
|
||||
def test_delete_meta_touches_only_the_key_it_names(db) -> None:
|
||||
"""A reset that deletes one row too many deletes the operator's only credential."""
|
||||
db.set_meta("auth.verifier", "the-verifier")
|
||||
db.set_meta("auth.session_secret", "the-secret")
|
||||
db.set_meta("last_maintain", "1700000000.0")
|
||||
|
||||
assert db.delete_meta("auth.session_secret") == 1
|
||||
|
||||
assert db.get_meta("auth.session_secret") is None
|
||||
assert db.get_meta("auth.verifier") == "the-verifier"
|
||||
assert db.get_meta("last_maintain") == "1700000000.0"
|
||||
|
||||
|
||||
def test_stats_reads_last_maintain_through_get_meta(db) -> None:
|
||||
"""One path to the table.
|
||||
|
||||
`stats()` reported `None` before any maintenance ran and the timestamp afterwards; that
|
||||
behaviour is pinned here while the read goes through the same helper everything else uses,
|
||||
and it is why the helper is called outside `stats()`'s own lock (`threading.Lock` is not
|
||||
reentrant — call it inside and the dashboard deadlocks on the first `/api/health`).
|
||||
"""
|
||||
assert db.stats()["last_maintain"] is None
|
||||
db.maintain(now=1_700_000_000.0)
|
||||
assert db.stats()["last_maintain"] == 1_700_000_000.0
|
||||
|
||||
@@ -36,6 +36,8 @@ from typing import Any
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth_client import sign_in
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from card_contract import GPU_METRICS, HOST_SHAPES, assert_card_contract, dig
|
||||
from dashboards.app import create_app
|
||||
from dashboards.config import Settings, Target
|
||||
@@ -91,11 +93,16 @@ def api_client(tmp_path: Any, agent_snapshot: dict[str, Any],
|
||||
"""A running app serving one host payload, so the contract is checked over HTTP too."""
|
||||
db = str(tmp_path / "contract.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)),
|
||||
store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
client = TestClient(app)
|
||||
client.__enter__()
|
||||
try:
|
||||
# phase 11: the snapshot is behind the session, and `TestClient` follows the 303 to
|
||||
# /login — a contract checked against the login page would pass on every path check
|
||||
# below it, which is worse than the test failing.
|
||||
sign_in(client)
|
||||
# the job's port refuses, so a poll can never overwrite what is injected here
|
||||
app.state.ctx.hub.jobs["host:driveripper"].state.payload = host_scrape(
|
||||
agent_snapshot, cpu_busy=10.0)
|
||||
|
||||
@@ -46,6 +46,7 @@ from typing import Any
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from dashboards.app import create_app
|
||||
from dashboards.config import Settings
|
||||
from dashboards.severity import classify
|
||||
@@ -87,7 +88,8 @@ def client(tmp_path: Path) -> Iterator[TestClient]:
|
||||
"""
|
||||
db = str(tmp_path / "ups-contract.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
test_client = TestClient(app)
|
||||
test_client.__enter__()
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user