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:
2026-10-19 07:45:44 -04:00
parent eb4788cba1
commit 1734d81e48
75 changed files with 6941 additions and 121 deletions
+48
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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",
+629
View File
@@ -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
+306
View File
@@ -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()}
+265
View File
@@ -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"
+74
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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
+74
View File
@@ -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
+9 -2
View File
@@ -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)
+3 -1
View File
@@ -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: