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:
+291
-20
@@ -19,8 +19,10 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dashboards import alerts
|
||||
from dashboards.api import RANGE_SECONDS, parse_range
|
||||
from auth_client import sign_in
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from dashboards import alerts, auth
|
||||
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
|
||||
from dashboards.app import create_app
|
||||
from dashboards.collectors import UpstreamError
|
||||
from dashboards.config import Settings, Target
|
||||
@@ -52,6 +54,14 @@ DEAD_URL = "http://127.0.0.1:1"
|
||||
#: fifth page cannot join the masthead and skip the checks the others had to pass.
|
||||
PAGES = ("/", "/system", "/disks", "/llm", "/ups")
|
||||
|
||||
#: `/settings` is a **page** and not a dashboard (phase 11): it carries the same masthead with
|
||||
#: no `aria-current` tab, and the account link in every dashboard's masthead is how you reach
|
||||
#: it. Kept out of `PAGES` because the pins over that tuple assert tab structure — one current
|
||||
#: tab, a fixed tab order — which this page deliberately does not have. `MARKUP_PAGES` is the
|
||||
#: list for the checks that are about the bytes rather than the nav.
|
||||
SETTINGS_PAGE = "/settings"
|
||||
MARKUP_PAGES = (*PAGES, SETTINGS_PAGE)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False,
|
||||
@@ -67,11 +77,13 @@ def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: b
|
||||
else:
|
||||
monkeypatch.delenv("NUT_SIMULATE", raising=False)
|
||||
db = str(tmp_path / "api.sqlite3")
|
||||
settings = Settings(db_path=db, **overrides)
|
||||
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2, **overrides)
|
||||
store = SampleStore(db)
|
||||
client = TestClient(create_app(settings, store))
|
||||
client.__enter__()
|
||||
try:
|
||||
sign_in(client)
|
||||
yield client
|
||||
finally:
|
||||
client.__exit__(None, None, None)
|
||||
@@ -128,10 +140,12 @@ def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
db = str(tmp_path / "summary.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, nut_host="127.0.0.1",
|
||||
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
test_client = TestClient(app)
|
||||
test_client.__enter__()
|
||||
try:
|
||||
sign_in(test_client)
|
||||
app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload(
|
||||
host_scrape, agent_snapshot)
|
||||
wait_for_payload(test_client, "/api/ups/snapshot")
|
||||
@@ -449,13 +463,15 @@ def test_an_upstream_failure_escaping_a_route_keeps_its_own_status(tmp_path: Pat
|
||||
"""
|
||||
db = str(tmp_path / "upstream-error.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
|
||||
@app.get("/api/_boom")
|
||||
async def boom() -> None:
|
||||
raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504)
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
sign_in(test_client)
|
||||
response = test_client.get("/api/_boom")
|
||||
assert response.status_code == 504, "a timeout stays a timeout, not a 500"
|
||||
assert response.json() == {"ok": False,
|
||||
@@ -473,8 +489,11 @@ def test_agent_tokens_never_reach_the_browser(tmp_path: Path, monkeypatch: pytes
|
||||
headers={"Authorization": "Bearer SEKRIT-TOKEN"})]
|
||||
db = str(tmp_path / "tokens.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, host_targets=tuple(targets)), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=tuple(targets)), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
body = client.get("/api/config")
|
||||
assert "SEKRIT-TOKEN" not in body.text
|
||||
target = body.json()["system"]["targets"][0]
|
||||
@@ -505,10 +524,12 @@ def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> Non
|
||||
store = SampleStore(db)
|
||||
settings = Settings(
|
||||
db_path=db,
|
||||
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_interval=0.05,
|
||||
host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),),
|
||||
)
|
||||
with TestClient(create_app(settings, store)) as client:
|
||||
sign_in(client)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
health = client.get("/api/health").json()
|
||||
@@ -540,10 +561,13 @@ def test_engine_urls_are_redacted_the_same_way_hosts_are(tmp_path: Path) -> None
|
||||
happens to carry credentials has to be redacted where it is served (invariant 6).
|
||||
"""
|
||||
db = str(tmp_path / "engine-redact.sqlite3")
|
||||
settings = Settings(db_path=db, gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731",
|
||||
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731",
|
||||
vllm_url="http://driveripper:8000")
|
||||
store = SampleStore(db)
|
||||
with TestClient(create_app(settings, store)) as client:
|
||||
sign_in(client)
|
||||
body = client.get("/api/config")
|
||||
assert "SEKRIT-ENGINE-TOKEN" not in body.text
|
||||
llm = body.json()["llm"]
|
||||
@@ -567,6 +591,60 @@ def test_target_problems_are_surfaced_not_swallowed(client) -> None:
|
||||
assert body["system"]["target_problems"] == []
|
||||
|
||||
|
||||
# ---- the credential the server refuses to start without (phase 11) --------- #
|
||||
|
||||
|
||||
def test_no_verifier_and_no_seed_means_the_server_does_not_come_up(tmp_path: Path) -> None:
|
||||
"""Fail closed, at the one place a mistake is unmissable: startup.
|
||||
|
||||
Task 03 is what demands a session on every route; this is the half before it — a deployment
|
||||
with neither a stored `auth.verifier` row nor `DASH_ADMIN_PASSWORD` must not run open, so
|
||||
the lifespan raises instead of publishing a context, starting the pollers, or serving a
|
||||
page. Asserting that nothing was written is part of it: "refused" that seeded a row on the
|
||||
way out would leave the next start half-configured.
|
||||
"""
|
||||
db = str(tmp_path / "no-credential.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=""), store)
|
||||
try:
|
||||
with pytest.raises(RuntimeError) as caught, TestClient(app):
|
||||
pytest.fail("the lifespan yielded with no admin credential")
|
||||
message = str(caught.value)
|
||||
assert "DASH_ADMIN_PASSWORD" in message, "the operator's fix is in the exit output"
|
||||
assert "auth.verifier" in message and "DELETE FROM meta" in message
|
||||
assert not hasattr(app.state, "ctx"), "nothing is published, so nothing can serve"
|
||||
assert store.get_meta("auth.verifier") is None
|
||||
assert store.get_meta("auth.session_secret") is None
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def test_the_same_server_starts_once_the_environment_seeds_a_password(tmp_path: Path) -> None:
|
||||
"""The other side of the gate, so the failure above cannot be satisfied by a broken app.
|
||||
|
||||
With a seed the process comes up, the state hangs off the context (task 03 reads it on
|
||||
every request), and both rows are durable — which is also what lets the operator delete
|
||||
`DASH_ADMIN_PASSWORD` from the deployment after the first boot.
|
||||
"""
|
||||
db = str(tmp_path / "seeded.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/health").status_code == 200 # public: the healthcheck
|
||||
state = app.state.ctx.auth
|
||||
assert state is not None
|
||||
assert state.seeded is True and state.env_ignored is False
|
||||
assert state.iterations == FAST_PBKDF2
|
||||
assert auth.verify_password(ADMIN_PASSWORD, state.verifier) is True
|
||||
assert store.get_meta("auth.verifier") == state.verifier
|
||||
assert store.get_meta("auth.session_secret") == state.secret
|
||||
assert ADMIN_PASSWORD not in state.verifier + state.secret
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
# ---- pages ---------------------------------------------------------------- #
|
||||
|
||||
@pytest.mark.parametrize("path,title", [
|
||||
@@ -581,8 +659,14 @@ def test_pages_serve_and_declare_themselves(client, path, title) -> None:
|
||||
|
||||
|
||||
def test_no_page_loads_anything_from_the_internet(client) -> None:
|
||||
"""No CDN, no webfonts: this dashboard has to work with no route to the internet."""
|
||||
for path in PAGES:
|
||||
"""No CDN, no webfonts: this dashboard has to work with no route to the internet.
|
||||
|
||||
`/login` is not in `MARKUP_PAGES` and cannot be: this `client` is signed in, a signed-in
|
||||
`GET /login` is a `303`, and following it would audit `/`'s bytes under the wrong name. The
|
||||
same fragments are checked against the real login page, unsigned, by
|
||||
`tests/test_auth_api.py::test_the_login_page_loads_nothing_from_the_internet`.
|
||||
"""
|
||||
for path in MARKUP_PAGES:
|
||||
html = client.get(path).text
|
||||
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
|
||||
assert fragment not in html, f"{path} references {fragment}"
|
||||
@@ -593,6 +677,152 @@ def test_static_assets_are_served(client) -> None:
|
||||
assert client.get(asset).status_code == 200, asset
|
||||
|
||||
|
||||
# ---- /settings: the account page (phase 11 task 06) ----------------------- #
|
||||
#
|
||||
# `/settings` is the operator's own surface: the session this browser holds, a sign-out, and the
|
||||
# one form that changes the admin password. Its HTTP surface is tested twice on purpose — the
|
||||
# route-enumeration test in `tests/test_auth_api.py` proves nothing outside the public list
|
||||
# answers anonymously (which is how `/settings` is gated for free by being in `PAGES` in
|
||||
# `app.py`), and the assertions here pin the **page**: the form fields a password manager needs,
|
||||
# the outcome line that is a live region, the precedence sentence that stops the "I edited the
|
||||
# env file and nothing happened" support call, and the masthead link that is the only way in.
|
||||
|
||||
def test_the_settings_page_carries_the_session_and_the_form(client) -> None:
|
||||
response = client.get(SETTINGS_PAGE)
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
html = response.text
|
||||
# the session panel and its way out
|
||||
for fragment in ('id="sign-out"', 'id="session-user"', 'id="session-expires"',
|
||||
'id="session-left"'):
|
||||
assert fragment in html, f"/settings has no {fragment}"
|
||||
# the change-password form: three fields, each labelled, each with the floor spelled out,
|
||||
# and the autocomplete values a password manager needs to fill the right one
|
||||
for fragment in ('id="current-password"', 'autocomplete="current-password"',
|
||||
'id="new-password"', 'autocomplete="new-password"',
|
||||
'id="confirm-password"', 'id="change-password"',
|
||||
'<label for="current-password">', '<label for="new-password">',
|
||||
'<label for="confirm-password">', 'aria-describedby="password-hint"'):
|
||||
assert fragment in html, f"/settings' form is missing {fragment}"
|
||||
assert "8 characters" in html, "the form stopped naming the length floor it enforces"
|
||||
# the outcome line is announced, because the operator asked for it and nothing else will
|
||||
assert 'id="settings-result"' in html and 'role="status"' in html
|
||||
|
||||
|
||||
def test_the_settings_page_states_the_precedence_rule(client) -> None:
|
||||
"""The sentence that makes the env file's losing half legible on the page that wins.
|
||||
|
||||
`README.md` documents the same rule, but the operator reading it there is deciding what to
|
||||
write in a file; the operator reading *this* page just changed a password and needs to know
|
||||
why a container env will not undo it.
|
||||
"""
|
||||
html = client.get(SETTINGS_PAGE).text
|
||||
assert "DASH_ADMIN_PASSWORD" in html
|
||||
assert "auth.verifier" in html
|
||||
assert "never overwrites" in html
|
||||
|
||||
|
||||
def test_the_settings_page_is_not_one_of_the_dashboards(client) -> None:
|
||||
"""The tabs are there to leave with; nothing marks itself current, because nothing is.
|
||||
|
||||
A tab marked current on a page that is not in the tab list claims a location the app does not
|
||||
have, and the four dashboards' own masthead pin (`aria-current` exactly once) relies on that
|
||||
marker meaning something.
|
||||
"""
|
||||
html = client.get(SETTINGS_PAGE).text
|
||||
assert '<nav class="tabs"' in html
|
||||
assert 'aria-current="page"' not in html
|
||||
for path in PAGES:
|
||||
assert f'href="{path}"' in html, f"/settings cannot navigate to {path}"
|
||||
assert 'id="nav-status"' not in html, "/settings grew fleet dots it has nothing to fill"
|
||||
|
||||
|
||||
def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> None:
|
||||
"""One page-level assertion of the gate, not a second enumeration of it.
|
||||
|
||||
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session` is the
|
||||
general proof; this one exists so a regression in `/settings` names `/settings`.
|
||||
"""
|
||||
db = str(tmp_path / "settings-gate.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
try:
|
||||
with TestClient(app, follow_redirects=False) as anon:
|
||||
page = anon.get(SETTINGS_PAGE, headers={"accept": "text/html"})
|
||||
assert page.status_code == 303
|
||||
assert page.headers["location"] == f"/login?next={SETTINGS_PAGE}"
|
||||
data = anon.get(SETTINGS_PAGE, headers={"accept": "application/json"})
|
||||
assert data.status_code == 401
|
||||
assert data.json() == {"ok": False, "detail": "sign in required",
|
||||
"login": "/login"}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def test_every_dashboard_page_links_to_settings(client) -> None:
|
||||
"""The `⚙ Account` link is the only way to the session page, so it is on every dashboard.
|
||||
|
||||
It sits in `.masthead-right` before the theme toggle and **outside** `.tabs`, because the tab
|
||||
strip's structure — five dashboards, one of them current — is pinned by
|
||||
`test_the_disks_tab_is_in_the_masthead_of_every_page` and a sixth entry with no
|
||||
`aria-current` would break what that marker means.
|
||||
"""
|
||||
link = '<a class="ghost account" href="/settings"'
|
||||
for path in PAGES:
|
||||
html = client.get(path).text
|
||||
assert link in html, f"{path} has no route to the account page"
|
||||
tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")]
|
||||
assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
|
||||
assert html.index(link) < html.index('id="theme-toggle"'), \
|
||||
f"{path} put the account link after the theme toggle"
|
||||
# the stylesheet styles the link with the button's own rule, not a copy of it
|
||||
css = client.get("/static/dash.css").text
|
||||
assert "a.ghost" in css
|
||||
|
||||
|
||||
def test_the_password_floor_the_form_checks_is_the_one_the_api_enforces(client) -> None:
|
||||
"""The client-side length check is a courtesy; the server's is the rule.
|
||||
|
||||
There is no build step to share the constant (a locked anchor), so the two literals are
|
||||
pinned together here: a floor raised in `api.py` alone would leave the form promising eight
|
||||
characters to a request the server answers `422`.
|
||||
"""
|
||||
js = client.get("/static/page-settings.js").text
|
||||
found = re.search(r"const MIN_PASSWORD_LENGTH = (\d+);", js)
|
||||
assert found, "page-settings.js no longer names its length floor"
|
||||
assert int(found.group(1)) == MIN_PASSWORD_LENGTH
|
||||
|
||||
|
||||
def test_a_rotated_secret_clears_the_cached_session_every_page_uses(client) -> None:
|
||||
"""`Dash.resetAuth()` is the difference between a password change and a 403 dashboard.
|
||||
|
||||
`POST /api/auth/password` rotates the signing secret on purpose (`auth.update_verifier`),
|
||||
which invalidates the CSRF token `ensureAuth()` memoised. Every page writes through
|
||||
`fetchJSON`, which reads that cache — so unless the change clears it, the operator changes
|
||||
the password, clicks *beeper disable* on `/ups`, and gets `missing or invalid CSRF token`
|
||||
until they reload the page. A bounce on a `401` clears it for the same reason: the session it
|
||||
described no longer exists.
|
||||
"""
|
||||
app_js = client.get("/static/app.js").text
|
||||
assert "function resetAuth(" in app_js
|
||||
exports = app_js.rsplit("return {", 1)[1].split("}", 1)[0]
|
||||
assert re.search(r"\bresetAuth\b", exports), "Dash.resetAuth is not exported"
|
||||
reset = app_js[app_js.index("function resetAuth("):app_js.index("function bounceToLogin(")]
|
||||
for field in ("loaded: false", "authenticated: false", "csrf: null"):
|
||||
assert field in reset, f"resetAuth() left `{field}` cached"
|
||||
assert "authPromise = null" in reset, \
|
||||
"resetAuth() left an in-flight status call memoised — the fresh write would reuse it"
|
||||
bounce = app_js[app_js.index("function bounceToLogin("):app_js.index("const READ_METHODS")]
|
||||
assert "resetAuth()" in bounce, "a 401 bounce kept the dead session's token cached"
|
||||
|
||||
js = client.get("/static/page-settings.js").text
|
||||
assert js.count("Dash.resetAuth()") >= 2, \
|
||||
"the settings page must reset the cached session after both a change and a sign-out"
|
||||
assert "textContent" in js and "innerHTML" not in js, \
|
||||
"/settings renders a server sentence as markup"
|
||||
|
||||
|
||||
# ---- the severity language in the static assets --------------------------- #
|
||||
#
|
||||
# There is no JS test runner (a locked anchor: adding one would mean a build step).
|
||||
@@ -603,7 +833,8 @@ def test_static_assets_are_served(client) -> None:
|
||||
|
||||
ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js",
|
||||
"/static/page-index.js", "/static/page-disks.js", "/static/page-system.js",
|
||||
"/static/page-llm.js", "/static/page-ups.js")
|
||||
"/static/page-llm.js", "/static/page-ups.js", "/static/page-login.js",
|
||||
"/static/page-settings.js")
|
||||
|
||||
|
||||
def _css(client: TestClient) -> str:
|
||||
@@ -702,7 +933,7 @@ def _fleet_field_metrics(js: str) -> list[str]:
|
||||
|
||||
def test_no_parallel_severity_vocabulary_exists_anywhere(client) -> None:
|
||||
"""One word per colour: `ok|warn|fail`, as in severity.py and the existing components."""
|
||||
for asset in (*ASSETS, *PAGES):
|
||||
for asset in (*ASSETS, *MARKUP_PAGES):
|
||||
body = _code(client.get(asset).text)
|
||||
assert ".sev-" not in body, f"{asset} invented a .sev-* class"
|
||||
assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']",
|
||||
@@ -2404,7 +2635,16 @@ PAGE_SCRIPTS = {
|
||||
"/disks": "page-disks.js",
|
||||
"/llm": "page-llm.js",
|
||||
"/ups": "page-ups.js",
|
||||
# `/settings` is audited like a dashboard even though it is not one: an id nothing reads is
|
||||
# dead markup whatever page it sits on, and this page has five new ones.
|
||||
SETTINGS_PAGE: "page-settings.js",
|
||||
}
|
||||
# `/login` is deliberately **not** in this table even though it is audited (phase 11): a
|
||||
# signed-in client asking for it gets a 303 to the dashboard, so this helper's own
|
||||
# redirect-following `get` would audit `/`'s markup and call it `/login`. The login page's
|
||||
# orphan-`id`, CDN and masthead checks live in `tests/test_auth_api.py`, fetched unsigned, and
|
||||
# `/static/page-login.js` is in `ASSETS` above.
|
||||
|
||||
SHARED_SCRIPTS = ("app.js", "charts.js")
|
||||
|
||||
|
||||
@@ -2472,7 +2712,7 @@ def test_no_page_narrates_its_own_pipeline(client, fragment: str) -> None:
|
||||
the same sentence migrating into another page's card is the same slop, and a pin that
|
||||
only guards its original file would let that through.
|
||||
"""
|
||||
for path in (*PAGES, *ASSETS):
|
||||
for path in (*MARKUP_PAGES, *ASSETS):
|
||||
body = client.get(path).text
|
||||
assert fragment not in body, f"{path} narrates the app again: {fragment!r}"
|
||||
|
||||
@@ -2484,7 +2724,7 @@ def test_pages_leave_no_empty_narration_container(client) -> None:
|
||||
renders a gap where the sentence used to be. Elements the scripts fill (`#sub`,
|
||||
`#alerts-count`) carry an `id` and are not in this category.
|
||||
"""
|
||||
for path in PAGES:
|
||||
for path in MARKUP_PAGES:
|
||||
html = _code(client.get(path).text)
|
||||
for cls in ("sub", "faint", "mono"):
|
||||
assert f'<span class="{cls}"></span>' not in html, \
|
||||
@@ -2543,8 +2783,11 @@ def test_history_returns_seeded_samples(tmp_path: Path, monkeypatch: pytest.Monk
|
||||
now = time.time()
|
||||
for i in range(30):
|
||||
store.add("host", "driveripper", "cpu.usage_pct", 10.0 + i, ts=now - 300 + i)
|
||||
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
body = client.get("/api/history", params={"source": "host", "host": "driveripper",
|
||||
"metric": "cpu.usage_pct", "range": "15m"})
|
||||
assert response_ok(body)
|
||||
@@ -2743,9 +2986,12 @@ def disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
"""
|
||||
db = str(tmp_path / "disks.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),
|
||||
Target("deskwork", DEAD_URL))), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),
|
||||
Target("deskwork", DEAD_URL))), store)
|
||||
with TestClient(app) as test_client:
|
||||
sign_in(test_client)
|
||||
now = time.time()
|
||||
snapshot = disk_snapshot("driveripper")
|
||||
snapshot["ts"] = now # "the agent answered just now", so charts are in range
|
||||
@@ -2867,7 +3113,7 @@ def test_the_disks_fleet_header_is_summed_by_the_api_not_the_page(disks_client)
|
||||
|
||||
def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
|
||||
disks_client) -> None:
|
||||
"""Invariant 1: the app has exactly one POST, and a fourth dashboard does not add one."""
|
||||
"""Invariant 1: the app writes to an **upstream** from exactly one route."""
|
||||
routes = flat_routes(disks_client.app)
|
||||
disks = [route for route in routes
|
||||
if str(getattr(route, "path", "")).startswith("/api/disks")]
|
||||
@@ -2879,7 +3125,26 @@ def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
|
||||
assert response.status_code == 405, f"{verb} /api/disks/snapshot was accepted"
|
||||
writes = sorted(str(route.path) for route in routes
|
||||
if "POST" in (getattr(route, "methods", None) or set()))
|
||||
assert writes == ["/api/ups/command"], "one write endpoint, four gates, unchanged"
|
||||
# Invariant 1 is about the fleet, and phase 11 did not touch the fleet: the three `/auth/`
|
||||
# writes create, re-credential, and clear a session in this process and dial nothing. They
|
||||
# are named here instead of filtered out of sight, because the failure this assertion catches
|
||||
# is a POST arriving quietly — a list the reader recognises is worth more than a short one.
|
||||
upstream = [path for path in writes if not path.startswith("/api/auth/")]
|
||||
session = [path for path in writes if path.startswith("/api/auth/")]
|
||||
assert upstream == ["/api/ups/command"], "one write endpoint, four gates, unchanged"
|
||||
assert session == ["/api/auth/login", "/api/auth/logout", "/api/auth/password"]
|
||||
# …and nothing anywhere grew the verbs this app has no use for, session included.
|
||||
assert not sorted(path for route in routes
|
||||
for path in [str(route.path)]
|
||||
if set(getattr(route, "methods", None) or ()) & {"PUT", "PATCH", "DELETE"})
|
||||
# The session writes are not upstream writes, but they are still the only ones: `/login`
|
||||
# is the page, not a POST target, and `/api/auth/status` stays a read.
|
||||
pages = sorted(str(route.path) for route in routes
|
||||
if "GET" in (getattr(route, "methods", None) or set())
|
||||
and not str(route.path).startswith("/api/"))
|
||||
# `/settings` (phase 11) is a fifth dashboard-shaped **page** and it grew no verb: a page
|
||||
# route is a GET, so the write surface this test guards stays the one UPS command.
|
||||
assert pages == ["/", "/disks", "/llm", "/login", "/settings", "/system", "/ups"]
|
||||
|
||||
|
||||
def test_config_prints_the_disks_cadence_the_page_explains_itself_with(
|
||||
@@ -3239,9 +3504,11 @@ def test_summary_names_the_drive_the_health_verdict_called_failed(
|
||||
"""
|
||||
db = str(tmp_path / "summary-failed.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db,
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
||||
with TestClient(app) as test_client:
|
||||
sign_in(test_client)
|
||||
now = time.time()
|
||||
snapshot = disk_snapshot("driveripper", mode="failed")
|
||||
snapshot["ts"] = now
|
||||
@@ -3261,8 +3528,10 @@ def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyP
|
||||
store = SampleStore(db)
|
||||
store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down",
|
||||
severity="error", message="deskwork is not answering"))
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
events = client.get("/api/events").json()["events"]
|
||||
assert events[0]["host"] == "deskwork"
|
||||
assert events[0]["severity"] == "error"
|
||||
@@ -3544,8 +3813,10 @@ def test_samples_written_by_the_poller_are_queryable(tmp_path: Path,
|
||||
store = SampleStore(db)
|
||||
store.add_many([Sample("llm", "gufo", "llm.decode_tokens_per_second", 40.0, time.time() - 60),
|
||||
Sample("llm", "gufo", "llm.decode_tokens_per_second", 45.0, time.time() - 30)])
|
||||
app = create_app(Settings(db_path=db), store)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
with TestClient(app) as client:
|
||||
sign_in(client)
|
||||
metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"]
|
||||
assert "llm.decode_tokens_per_second" in metrics
|
||||
points = client.get("/api/history", params={"source": "llm", "host": "gufo",
|
||||
|
||||
Reference in New Issue
Block a user