diff --git a/CHANGELOG.md b/CHANGELOG.md index c2807f7..f5b37ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -306,3 +306,18 @@ All notable changes to cabin are documented here. The format is based on five SANs and measuring every element against its container at 1440px and 390px; the same probe reports the pre-0015 pages as broken, which is what makes the green result mean anything. +- Spec 0016 (dashboard): `/` stops being the stub spec 0003 called it and + becomes the page an operator opens first. Four counts (valid / expiring / + expired / revoked), each linking to the inventory filtered to it and backed + by a new `status_counts` that runs the *same* filters the inventory runs, so + a tile and the page behind it cannot disagree. The certificates expiring + within 30 days, soonest first. The CA's own expiry — flagged a year ahead, + because replacing an intermediate means touching every trust store it lives + in, and nothing surfaced that before. Whether the published CRL is still + inside its validity window, which is the difference between clients seeing a + revocation and clients silently trusting a revoked certificate. Which + services are on, for the roles that may see `/settings` — the dashboard + aggregates pages with different roles attached and keeps each one, so it + cannot become a way around authorisation. And the last five audit entries. + No new table, no background job, no new dependency: every figure is read + from what the database already holds, off one clock taken per request. diff --git a/spec/0016-dashboard.md b/spec/0016-dashboard.md new file mode 100644 index 0000000..705d0a3 --- /dev/null +++ b/spec/0016-dashboard.md @@ -0,0 +1,132 @@ +# Spec 0016 — Dashboard + +## Context + +`GET /` has been a stub since spec 0003, which called it that in as many +words. It shows the signed-in user's name and role, the cabin version, and — +when no CA exists yet — a link to `/ca`. Spec 0015 moved the name, the role +and the version into the navigation rail, so the page's entire remaining +content is a warning that is only true in the first five minutes after +installation. + +Meanwhile the question an internal CA actually gets asked is not on any page: +**what is about to stop working?** The inventory can answer it, but only if +someone thinks to filter by `status=expiring`. Nothing at all surfaces the two +expiries that take real work to fix — the intermediate's and the root's — and +nothing says whether the published CRL is still inside its validity window, +which is the difference between clients seeing revocations and clients +silently trusting a revoked certificate. + +This spec makes `/` the page an operator opens first: what expires soon, what +the CA's own clock looks like, whether revocation is being published, which +services are on, and what happened last. It adds no new capability — every +number is something the operator could already have found by hand. + +## User Stories + +- As an operator, I open cabin and see immediately which certificates expire + in the next 30 days, without constructing a filter. +- As an operator, I am warned about my intermediate and root expiring long + before it becomes an emergency, because replacing them is not a five-minute + job. +- As an operator, I can tell at a glance whether the CRL clients fetch is + still current, or whether it went stale and revocations are not reaching + anyone. +- As an operator, I see whether ACME and MCP are on, and whether the base URL + they depend on is actually set. +- As a viewer, I see the same page minus the parts my role cannot see + elsewhere — the dashboard must not become a way around authorisation. + +## Functional Requirements + +- FR-1: `GET /` renders a dashboard for any authenticated user. It is composed + of the spec 0015 primitives; it introduces no new layout of its own beyond a + `.tiles` grid for the summary counts. +- FR-2: **Expiring soon.** A table of certificates whose status is `expiring` + (`not_after` within 30 days and not yet past, the existing + `certificate_status` definition, revoked ones excluded), soonest first, at + most 10 rows, each linking to its detail page and showing CN, SANs count, + and the days remaining. Below it, a link to `/certs?status=expiring` when + more exist than are shown. When none do, the section says so plainly rather + than rendering an empty table. +- FR-3: **Inventory summary.** Four counts — valid, expiring, expired, revoked + — each linking to the inventory pre-filtered to that status. Backed by a new + `cabin.ca.certs.status_counts(db, now) -> dict[str, int]` that reuses the + same `_filters` the inventory uses, so a count and the list it links to can + never disagree. +- FR-4: **The CA's own expiry.** Intermediate and root with their + `not_after` and the days remaining, warning-tagged at 365 days or fewer and + danger-tagged once expired. When no CA is configured the whole dashboard is + replaced by the existing "set up your CA" prompt, as today. +- FR-5: **Revocation.** The stored CRL's number, when it was generated and its + `next_update`. Danger-tagged when `next_update` is in the past — the CRL + clients hold is then stale and revocations are not reaching them. Also shows + the distribution URL, or says that none is published because no base URL is + set. When no CRL has been generated yet, says that instead. +- FR-6: **Services.** Whether ACME and MCP are enabled, and the base URL they + depend on. No "enabled but no base URL" warning: spec 0010 FR-5 and spec + 0013 FR-4 already refuse to store that combination, so the state cannot + exist and a warning for it would be code that never runs. The section says + so instead. **It is rendered only for roles that may see `/settings`** — + the same `nav.settings` flag the rail uses. A viewer must not learn the + configuration from the dashboard that they are refused on the settings + page. +- FR-7: **Recent activity.** The five newest audit events (actor, action, + summary, time) via the existing `audit.list_events(db, page=1, per_page=5)`, + linking to `/audit`. Shown to any authenticated user, because `/audit` is + open to any authenticated user. +- FR-8: Every figure is derived at request time from one clock passed through + the whole view, so counts, badges and "days remaining" on one render cannot + straddle a tick — the same rule spec 0006 established for the inventory. +- FR-9: No new dependency, no new table, no background job. The dashboard is a + read-only projection of what the database already holds. + +## Acceptance Criteria + +- AC-1: With certificates at +5d, +20d, +90d, one expired and one revoked, the + dashboard lists exactly the two expiring ones, soonest first, and the counts + read valid=1, expiring=2, expired=1, revoked=1. +- AC-2: Each of the four counts links to `/certs?status=`, and + following the link yields exactly that many rows. +- AC-3: With 12 expiring certificates, 10 are listed and a link to + `/certs?status=expiring` is shown; with none, the section says so and no + table is rendered. +- AC-4: An intermediate expiring in 300 days is warning-tagged; one expiring + in 400 days is not; an expired one is danger-tagged. +- AC-5: A CRL generated 8 days ago (validity is 7) is danger-tagged as stale; + one generated an hour ago is not. With no CRL row, the section says none has + been generated. +- AC-6: A viewer's dashboard contains no services section — not the ACME + state, not the MCP state, not the base URL — while an admin's does. A viewer + still sees expiring certificates, the counts, the CA expiry and recent + activity. +- AC-7: `POST /settings` with ACME enabled and an empty base URL is rejected + with 400 and stores nothing, so the dashboard cannot show that combination. + With both set, the services section reports ACME as enabled and shows the + URL. +- AC-8: With no CA configured, `/` shows the setup prompt and none of the + sections, and does not raise. +- AC-9: `status_counts` agrees with `list_certificates` for every status at + the same instant, including at the exact 30-day boundary. + +## Test list + +test_dashboard_lists_expiring_soonest_first, +test_dashboard_counts_match_inventory, test_status_counts_boundary_30d, +test_status_counts_agree_with_list_certificates, +test_dashboard_caps_expiring_list_at_ten, +test_dashboard_empty_expiring_says_so, test_dashboard_ca_expiry_is_shown, +test_dashboard_ca_expiry_warns_within_a_year, +test_dashboard_ca_far_out_is_not_warned, test_dashboard_crl_stale_is_danger, +test_dashboard_crl_absent_says_so, test_dashboard_hides_services_from_viewer, +test_settings_refuses_acme_without_base_url_so_dashboard_cannot_show_it, +test_dashboard_without_ca_shows_setup_prompt, +test_dashboard_recent_activity_lists_five + +## Out of Scope + +Charts and graphs. Any write action from the dashboard. Notifications, email +or webhooks on expiry — this spec surfaces the state, it does not act on it. +Auto-renewal. A per-user configurable layout. Changing the 30-day `expiring` +definition, which spec 0006 set and the inventory shares. OCSP, which cabin +does not have. diff --git a/src/cabin/ca/certs.py b/src/cabin/ca/certs.py index 3e87797..cc0b4af 100644 --- a/src/cabin/ca/certs.py +++ b/src/cabin/ca/certs.py @@ -396,3 +396,43 @@ def list_certificates( .offset((page - 1) * per_page) ).all() return list(rows), total + + +def status_counts(db: Session, now: datetime | None = None) -> dict[str, int]: + """How many certificates are in each state at ``now`` (spec 0016 FR-3). + + Every count runs the same :func:`_filters` the inventory runs, so a + dashboard tile and the page it links to cannot disagree -- the alternative, + a second expression of "what expiring means", is exactly the kind of + duplicate that drifts. + """ + moment = now or datetime.now(UTC) + return { + status.value: db.scalar( + select(sa.func.count()).select_from(Certificate).where(*_filters("", status, moment)) + ) + or 0 + for status in ( + CertStatus.valid, + CertStatus.expiring, + CertStatus.expired, + CertStatus.revoked, + ) + } + + +def expiring_soon(db: Session, now: datetime | None = None, limit: int = 10) -> list[Certificate]: + """The certificates about to lapse, soonest first (FR-2). + + Ordered by expiry rather than by creation, which is what the inventory + does: on this page the question is what runs out next, not what was made + last. + """ + moment = now or datetime.now(UTC) + rows = db.scalars( + select(Certificate) + .where(*_filters("", CertStatus.expiring, moment)) + .order_by(Certificate.not_after.asc(), Certificate.id.asc()) + .limit(limit) + ).all() + return list(rows) diff --git a/src/cabin/web/static/cabin.css b/src/cabin/web/static/cabin.css index 98f77e2..bb09bcf 100644 --- a/src/cabin/web/static/cabin.css +++ b/src/cabin/web/static/cabin.css @@ -545,6 +545,7 @@ th a:hover { /* Status values map to three roles. A tag whose value has no rule here — a role, an actor kind, where a certificate came from — stays neutral on purpose: those are facts, not alarms. */ +.tag-ok, .tag-valid, .tag-active, .tag-bound { @@ -552,11 +553,13 @@ th a:hover { border-color: color-mix(in srgb, var(--ok) 45%, transparent); } +.tag-warn, .tag-expiring { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); } +.tag-bad, .tag-expired, .tag-revoked { color: var(--bad); @@ -639,3 +642,35 @@ footer { grid-template-columns: minmax(0, 1fr); } } + +/* ---- dashboard tiles ---- */ + +.tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: var(--space-3); + margin: var(--space-4) 0; +} + +.tile { + display: flex; + flex-direction: column; + gap: var(--space-1); + align-items: flex-start; + padding: var(--space-3); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + text-decoration: none; + color: var(--text); +} + +.tile:hover { + border-color: var(--line-strong); +} + +.tile-count { + font-size: 1.75rem; + font-weight: 600; + line-height: 1; +} diff --git a/src/cabin/web/templates/dashboard.html b/src/cabin/web/templates/dashboard.html index c5b464f..9f5e18b 100644 --- a/src/cabin/web/templates/dashboard.html +++ b/src/cabin/web/templates/dashboard.html @@ -5,11 +5,203 @@

Dashboard

-

Logged in as {{ user.username }} ({{ user.role }}).

+

What needs attention, and what happened last.

+ {% if not ca_configured %}
CA: not set up
+

+ Create or import a certificate authority and this page will summarise it. +

+{% else %} + +
+ {% for name, count in [("valid", counts.valid), ("expiring", counts.expiring), + ("expired", counts.expired), ("revoked", counts.revoked)] %} + + {{ count }} + {{ name }} + + {% endfor %} +
+ +
+
+

Expiring soon

+

+ Certificates whose validity ends within the next 30 days, soonest first. +

+
+
+ {% if expiring %} +
+ + + + + + + + + + + {% for cert in expiring %} + + + + + + + {% endfor %} + +
Common nameSANsExpiresDays left
{{ cert.subject_cn or "(no subject)" }}{{ cert.sans }}{{ cert.not_after }}{{ cert.days }}
+
+ {% if expiring_more %} +

See all expiring certificates

+ {% endif %} + {% else %} +

Nothing expires in the next 30 days.

+ {% endif %} +
+
+ +
+
+

The CA itself

+

+ Replacing a CA means touching every trust store it is installed in, so + its expiry is flagged a year ahead. +

+
+
+
+ + + {% for ca in ca_certs %} + + + + + + {% endfor %} + +
{{ ca.kind | capitalize }}{{ ca.not_after }} + + {% if ca.days <= 0 %}expired{% else %}{{ ca.days }} days{% endif %} + +
+
+
+
+ +
+
+

Revocation

+

+ Clients only learn about a revocation if the CRL they fetch is still + inside its validity window. +

+
+
+ {% if crl %} +
+ + + + + + + + + +
CRL number{{ crl.number }}
Generated{{ crl.generated_at }}
Next update + {{ crl.next_update }} + {% if crl.stale %}stale{% endif %} +
+
+ {% if crl.stale %} +

+ This CRL is past its next update, so clients holding it no longer see + recent revocations. It is rebuilt on the next revocation or restart. +

+ {% endif %} + {% else %} +

No CRL has been generated yet — nothing has been revoked.

+ {% endif %} + {% if crl_url %} +

Published at {{ crl_url }}.

+ {% else %} +

+ No base URL is set, so no distribution point is published and newly + issued certificates do not point at one. +

+ {% endif %} +
+
+ +{% if services %} +
+
+

Services

+

What this cabin exposes beyond the web interface.

+
+
+
+ + + + + + + + + + + + + + + +
ACME + + {{ "enabled" if services.acme else "off" }} + +
MCP + + {{ "enabled" if services.mcp else "off" }} + +
Base URL{{ services.base_url or "—" }}
+
+

+ Both need a base URL, and settings refuses to enable either without one + — so an enabled service here always has an address to hand out. +

+
+
+{% endif %} + +
+
+

Recent activity

+

The last five entries of the audit log.

+
+
+
+ + + {% for event in events %} + + + + + + + {% endfor %} + +
{{ event.occurred_at }}{{ event.actor_label }}{{ event.action }}{{ event.summary }}
+
+
+
{% endif %} -

cabin version {{ version }}.

{% endblock %} diff --git a/src/cabin/web/ui.py b/src/cabin/web/ui.py index 2a655a5..0774c23 100644 --- a/src/cabin/web/ui.py +++ b/src/cabin/web/ui.py @@ -5,17 +5,23 @@ user exists. """ +import json import threading +from datetime import UTC, datetime +from cryptography import x509 from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from starlette.responses import Response -from cabin import audit, sessions, users +from cabin import audit, sessions, settings, users from cabin.audit import Actor, ActorKind, AuditAction +from cabin.ca import certs as certs_service +from cabin.ca import crl as crl_service from cabin.ca import service as ca_service +from cabin.ca.service import CACertificate from cabin.users import ( InvalidCredentialsError, LastSuperadminError, @@ -41,6 +47,13 @@ router = APIRouter() +#: How many expiring certificates the dashboard lists before deferring to the +#: inventory, and how many audit events it shows (spec 0016 FR-2/FR-7). +EXPIRING_SHOWN = 10 +RECENT_EVENTS = 5 +#: A CA replacement needs planning, so its warning starts a year out (FR-4). +CA_WARN_DAYS = 365 + require_superadmin = require_role(Role.superadmin) # Serializes first-run setup's check-then-create so two near-simultaneous @@ -205,14 +218,106 @@ def logout( # --- dashboard --------------------------------------------------------------- +def _days_until(moment: datetime) -> int: + """Whole days from now until ``moment``, negative once it is past.""" + return (moment - datetime.now(UTC)).days + + +def _ca_expiry(row: CACertificate, now: datetime) -> dict[str, object]: + """One CA certificate as the dashboard states it (spec 0016 FR-4).""" + cert = x509.load_pem_x509_certificate(row.cert_pem.encode("utf-8")) + not_after = cert.not_valid_after_utc + days = (not_after - now).days + return { + "kind": row.kind, + "not_after": not_after.replace(microsecond=0).isoformat(), + "days": days, + # Replacing a CA is not a five-minute job, so the warning comes a + # year out rather than at the 30 days a leaf gets. + "tag": "tag-bad" if days <= 0 else ("tag-warn" if days <= CA_WARN_DAYS else ""), + } + + @router.get("/") def dashboard( request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> Response: + """FR-1: what an operator needs to see first — what expires, whether + revocation is being published, and what happened last. + + One clock is taken here and passed to every figure on the page, so a + count, a badge and a "days remaining" on the same render cannot straddle + a tick (FR-8, the rule spec 0006 set for the inventory). + """ + now = datetime.now(UTC) + hierarchy = ca_service.get_ca(db) context = base_context(request, user) - context["ca_configured"] = ca_service.get_ca(db) is not None + context["ca_configured"] = hierarchy is not None + if hierarchy is None: + # Nothing to summarise before there is a CA (AC-8). + return templates.TemplateResponse(request, "dashboard.html", context) + + expiring = certs_service.expiring_soon(db, now, limit=EXPIRING_SHOWN) + counts = certs_service.status_counts(db, now) + crl_state = crl_service.stored_crl(db) + events, _ = audit.list_events(db, page=1, per_page=RECENT_EVENTS) + context.update( + { + "expiring": [ + { + "id": row.id, + "subject_cn": row.subject_cn, + "sans": len(json.loads(row.sans_json or "[]")), + "not_after": row.not_after, + "days": _days_until(datetime.fromisoformat(row.not_after)), + } + for row in expiring + ], + "expiring_more": counts["expiring"] > len(expiring), + "counts": counts, + "ca_certs": [ + _ca_expiry(hierarchy.intermediate, now), + _ca_expiry(hierarchy.root, now), + ], + "crl": ( + { + "number": crl_state.crl_number, + "generated_at": crl_state.generated_at.replace( + tzinfo=UTC, microsecond=0 + ).isoformat(), + "next_update": crl_state.next_update.replace(microsecond=0).isoformat(), + "stale": crl_state.next_update <= now, + } + if crl_state is not None + else None + ), + "crl_url": crl_service.distribution_url(db), + "events": [ + { + "occurred_at": event.occurred_at, + "actor_label": event.actor_label, + "action": event.action, + "summary": event.summary, + } + for event in events + ], + } + ) + # FR-6: the services section repeats what /settings says, so it keeps + # /settings' role. Aggregating must not hand a viewer configuration they + # are refused one page over. + may_see_settings = bool(context["nav"]["settings"]) # type: ignore[index] + context["services"] = ( + { + "base_url": settings.get_setting(db, settings.BASE_URL) or "", + "acme": settings.get_flag(db, settings.ACME_ENABLED), + "mcp": settings.get_flag(db, settings.MCP_ENABLED), + } + if may_see_settings + else None + ) return templates.TemplateResponse(request, "dashboard.html", context) diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py new file mode 100644 index 0000000..11dd97e --- /dev/null +++ b/tests/test_web_dashboard.py @@ -0,0 +1,443 @@ +"""Tests for spec 0016: the dashboard. + +Two things are being protected here. The counts and the lists they link to +must agree — a dashboard that says "3 expiring" and links to a page showing +two is worse than no dashboard. And the page must not become a way around +authorisation: it aggregates data from pages with different roles attached, +so every section has to keep the role its source page has. +""" + +import json +import re +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from cabin.app import create_app +from cabin.ca.certs import Certificate, CertStatus, list_certificates, status_counts +from cabin.config import Config +from cabin.sessions import get_session +from cabin.store import create_session_factory + + +@pytest.fixture +def cfg(tmp_path: Path) -> Config: + data_dir = tmp_path / "data" + return Config(port=8080, data_dir=data_dir, db_url=f"sqlite:///{data_dir}/cabin.db") + + +@pytest.fixture +def client(cfg: Config) -> Iterator[TestClient]: + with TestClient(create_app(cfg), follow_redirects=False) as c: + yield c + + +def _db(cfg: Config) -> Session: + return create_session_factory(cfg.db_url)() + + +def _csrf(client: TestClient, cfg: Config) -> str: + db = _db(cfg) + try: + row = get_session(db, client.cookies["cabin_session"]) + assert row is not None + return row.csrf_token + finally: + db.close() + + +def _superadmin(client: TestClient) -> None: + assert ( + client.post("/setup", data={"username": "alice", "password": "correcthorse1"}).status_code + == 303 + ) + + +def _create_ca(client: TestClient, cfg: Config, intermediate_years: int = 10) -> None: + assert ( + client.post( + "/ca/create", + data={ + "name": "cabin", + "key_type": "ecdsa-p256", + "root_years": 20, + "intermediate_years": intermediate_years, + "csrf_token": _csrf(client, cfg), + }, + ).status_code + == 303 + ) + + +def _insert( + cfg: Config, + name: str, + *, + expires_in: timedelta, + revoked: bool = False, + sans: int = 1, +) -> None: + """A row straight into the table: the dashboard only reads columns, and + a real issuance per fixture would buy nothing but runtime.""" + db = _db(cfg) + # X.509 validity is second-granular and so is every not_after cabin + # stores; keeping microseconds here would make the fixture compare + # differently to a real certificate at the exact 30-day boundary. + now = datetime.now(UTC).replace(microsecond=0) + try: + db.add( + Certificate( + serial_hex=f"beef{abs(hash(name)) % 10**12:012x}", + subject_cn=name, + sans_json=json.dumps([f"DNS:{name}"] * sans), + profile="server", + not_before=now.isoformat(), + not_after=(now + expires_in).isoformat(), + cert_pem="-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----\n", + key_sealed=None, + created_at=now.replace(tzinfo=None), + revoked_at=(now.replace(tzinfo=None) if revoked else None), + revocation_reason=("superseded" if revoked else None), + ) + ) + db.commit() + finally: + db.close() + + +def _spread(cfg: Config) -> None: + """The AC-1 fixture: one of each state.""" + _insert(cfg, "soon.lan", expires_in=timedelta(days=5)) + _insert(cfg, "later.lan", expires_in=timedelta(days=20)) + _insert(cfg, "fine.lan", expires_in=timedelta(days=90)) + _insert(cfg, "gone.lan", expires_in=timedelta(days=-1)) + _insert(cfg, "dead.lan", expires_in=timedelta(days=200), revoked=True) + + +# --- FR-3 / AC-9: the counts --------------------------------------------------- + + +def test_status_counts_agree_with_list_certificates(client: TestClient, cfg: Config) -> None: + """A count that disagrees with the page it links to is a lie. + + Takes ``client`` for the migrated schema its startup creates, not for HTTP. + """ + _spread(cfg) + now = datetime.now(UTC) + db = _db(cfg) + try: + counts = status_counts(db, now) + for status in ( + CertStatus.valid, + CertStatus.expiring, + CertStatus.expired, + CertStatus.revoked, + ): + _, total = list_certificates(db, q="", status=status, page=1, per_page=1, now=now) + assert counts[status] == total, status + assert counts == {"valid": 1, "expiring": 2, "expired": 1, "revoked": 1} + finally: + db.close() + + +def test_status_counts_boundary_30d(client: TestClient, cfg: Config) -> None: + """AC-9: exactly 30 days out is expiring, a second more is valid — the + same boundary `certificate_status` draws.""" + _insert(cfg, "exactly.lan", expires_in=timedelta(days=30)) + _insert(cfg, "justover.lan", expires_in=timedelta(days=30, seconds=30)) + db = _db(cfg) + try: + counts = status_counts(db, datetime.now(UTC)) + assert counts["expiring"] == 1 + assert counts["valid"] == 1 + finally: + db.close() + + +# --- FR-2 / AC-1..AC-3: expiring soon ------------------------------------------ + + +def test_dashboard_lists_expiring_soonest_first(client: TestClient, cfg: Config) -> None: + _superadmin(client) + _create_ca(client, cfg) + _spread(cfg) + + page = client.get("/") + assert page.status_code == 200 + body = page.text + assert "soon.lan" in body and "later.lan" in body + assert body.index("soon.lan") < body.index("later.lan"), "not soonest first" + # Only the expiring ones belong in that table. + assert "fine.lan" not in body + assert "gone.lan" not in body + + +def test_dashboard_counts_match_inventory(client: TestClient, cfg: Config) -> None: + """AC-2: each count links to the inventory filtered to it, and the + inventory then shows exactly that many rows.""" + _superadmin(client) + _create_ca(client, cfg) + _spread(cfg) + + page = client.get("/").text + expected = {"valid": 1, "expiring": 2, "expired": 1, "revoked": 1} + for status, count in expected.items(): + assert f'href="/certs?status={status}"' in page, status + listing = client.get("/certs", params={"status": status}).text + assert f"{count} certificate(s)" in listing, status + + +def test_dashboard_caps_expiring_list_at_ten(client: TestClient, cfg: Config) -> None: + _superadmin(client) + _create_ca(client, cfg) + for i in range(12): + _insert(cfg, f"host{i:02d}.lan", expires_in=timedelta(days=i + 1)) + + page = client.get("/").text + shown = [f"host{i:02d}.lan" for i in range(12) if f"host{i:02d}.lan" in page] + assert len(shown) == 10, shown + assert 'href="/certs?status=expiring"' in page + + +def test_dashboard_empty_expiring_says_so(client: TestClient, cfg: Config) -> None: + _superadmin(client) + _create_ca(client, cfg) + _insert(cfg, "fine.lan", expires_in=timedelta(days=200)) + + page = client.get("/").text + assert "Nothing expires in the next 30 days" in page + + +# --- FR-4 / AC-4: the CA's own expiry ------------------------------------------ + + +def test_dashboard_ca_expiry_is_shown(client: TestClient, cfg: Config) -> None: + _superadmin(client) + _create_ca(client, cfg) + page = client.get("/").text + assert "Intermediate" in page + assert "Root" in page + + +def test_dashboard_ca_expiry_warns_within_a_year(client: TestClient, cfg: Config) -> None: + """AC-4: replacing an intermediate is not a five-minute job, so the + warning has to come a long time before the expiry does.""" + _superadmin(client) + _create_ca(client, cfg, intermediate_years=1) + page = client.get("/").text + assert "tag-warn" in page + + +def test_dashboard_ca_far_out_is_not_warned(client: TestClient, cfg: Config) -> None: + _superadmin(client) + _create_ca(client, cfg, intermediate_years=10) + page = client.get("/").text + # No CA warning; the only tags on a quiet dashboard are neutral ones. + assert "tag-warn" not in page + + +# --- FR-5 / AC-5: revocation --------------------------------------------------- + + +def test_dashboard_crl_absent_says_so(client: TestClient, cfg: Config) -> None: + _superadmin(client) + _create_ca(client, cfg) + assert "No CRL has been generated yet" in client.get("/").text + + +def test_dashboard_crl_stale_is_danger(client: TestClient, cfg: Config) -> None: + """A CRL past its nextUpdate is the difference between clients seeing a + revocation and clients silently trusting a revoked certificate.""" + _superadmin(client) + _create_ca(client, cfg) + _insert(cfg, "dead.lan", expires_in=timedelta(days=100)) + # Revoking generates a CRL; age it past its 7-day validity. + db = _db(cfg) + try: + row = db.query(Certificate).filter(Certificate.subject_cn == "dead.lan").one() + cert_id = row.id + finally: + db.close() + assert ( + client.post( + f"/certs/{cert_id}/revoke", + data={ + "reason": "superseded", + "confirm": "on", + "csrf_token": _csrf(client, cfg), + }, + ).status_code + == 303 + ) + assert "tag-bad" not in client.get("/").text.split("Revocation")[-1][:600] + + from cabin.ca.crl import CRLState + + db = _db(cfg) + try: + state = db.query(CRLState).one() + state.generated_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=8) + db.commit() + finally: + db.close() + assert "stale" in client.get("/").text + + +# --- FR-6 / AC-6, AC-7: services, and who may see them ------------------------- + + +def test_dashboard_hides_services_from_viewer(client: TestClient, cfg: Config) -> None: + """AC-6: the dashboard aggregates pages with different roles attached. It + must keep those roles, or it becomes a way around them.""" + _superadmin(client) + _create_ca(client, cfg) + _insert(cfg, "soon.lan", expires_in=timedelta(days=5)) + client.post( + "/users", + data={ + "username": "vera", + "password": "correcthorse1x", + "role": "viewer", + "csrf_token": _csrf(client, cfg), + }, + ) + + admin_page = client.get("/").text + assert "Services" in admin_page + + client.post("/logout", data={"csrf_token": _csrf(client, cfg)}) + client.post("/login", data={"username": "vera", "password": "correcthorse1x"}) + viewer_page = client.get("/").text + assert "Services" not in viewer_page + assert "ACME" not in viewer_page + assert "MCP" not in viewer_page + # ...but a viewer still gets the part of the page that is theirs to see. + assert "soon.lan" in viewer_page + + +def test_settings_refuses_acme_without_base_url_so_dashboard_cannot_show_it( + client: TestClient, cfg: Config +) -> None: + """AC-7: the dashboard has no "enabled but unreachable" warning because + the state cannot exist — spec 0010 FR-5 refuses to store it. Asserted + here so that, if that gate is ever relaxed, this test says the dashboard + now needs the warning back. + """ + _superadmin(client) + _create_ca(client, cfg) + + rejected = client.post( + "/settings", + data={"acme_enabled": "on", "base_url": "", "csrf_token": _csrf(client, cfg)}, + ) + assert rejected.status_code == 400 + assert "set a base URL before enabling the ACME server" in rejected.text + + accepted = client.post( + "/settings", + data={ + "acme_enabled": "on", + "base_url": "https://ca.example.org", + "csrf_token": _csrf(client, cfg), + }, + ) + assert accepted.status_code == 303 + page = client.get("/").text + assert "enabled" in page + assert "https://ca.example.org" in page + + +# --- FR-7, AC-8 ---------------------------------------------------------------- + + +def test_dashboard_recent_activity_lists_five(client: TestClient, cfg: Config) -> None: + _superadmin(client) + _create_ca(client, cfg) + page = client.get("/").text + assert "Recent activity" in page + assert 'href="/audit"' in page + assert "ca_created" in page + + +def test_dashboard_without_ca_shows_setup_prompt(client: TestClient, cfg: Config) -> None: + """AC-8: before there is a CA there is nothing to summarise.""" + _superadmin(client) + page = client.get("/") + assert page.status_code == 200 + assert "CA: not set up" in page.text + assert "Expiring soon" not in page.text + + +#: Tags that carry a fact rather than an alarm — where a certificate came +#: from, which role someone has. Deliberately unstyled; everything else needs +#: a rule or it renders grey. +NEUTRAL_TAGS = { + "tag-source-ui", + "tag-source-acme", + "tag-source-api", + "tag-source-mcp", + "tag-superadmin", + "tag-admin", + "tag-viewer", + "tag-user", + "tag-system", + "tag-token", + "tag-acme", + "tag-unused", +} + +CSS = Path(__file__).resolve().parents[1] / "src/cabin/web/static/cabin.css" + + +def test_every_rendered_tag_class_has_a_rule(client: TestClient, cfg: Config) -> None: + """A tag class with no rule in the stylesheet renders grey — the warning + is emitted, satisfies an `in page` assertion, and is invisible. + + That is how "expires in 364 days" first shipped colourless: spec 0015 + renamed the rules to value names while this view still emitted role + names. So the fixture below deliberately drives every alarm state — + expiring, revoked, a CA inside its warning year, a stale CRL — because a + test that never reaches a state cannot check its colour. + """ + _superadmin(client) + _create_ca(client, cfg, intermediate_years=1) # -> tag-warn + _insert(cfg, "soon.lan", expires_in=timedelta(days=3)) # -> tag-expiring + _insert(cfg, "gone.lan", expires_in=timedelta(days=-1)) # -> tag-expired + _insert(cfg, "dead.lan", expires_in=timedelta(days=100)) + db = _db(cfg) + try: + cert_id = db.query(Certificate).filter(Certificate.subject_cn == "dead.lan").one().id + finally: + db.close() + client.post( + f"/certs/{cert_id}/revoke", + data={"reason": "superseded", "confirm": "on", "csrf_token": _csrf(client, cfg)}, + ) + from cabin.ca.crl import CRLState + + db = _db(cfg) + try: # age the CRL past its validity -> tag-bad + db.query(CRLState).one().generated_at = datetime.now(UTC).replace(tzinfo=None) - timedelta( + days=8 + ) + db.commit() + finally: + db.close() + + emitted: set[str] = set() + for path in ("/", "/certs", f"/certs/{cert_id}", "/users", "/audit", "/tokens"): + resp = client.get(path) + assert resp.status_code == 200, path + for classes in re.findall(r'class="tag ([^"]*)"', resp.text): + emitted.update(c for c in classes.split() if c.startswith("tag-")) + + # The fixture must actually have produced the states, or this proves nothing. + assert {"tag-warn", "tag-bad", "tag-expiring", "tag-revoked"} <= emitted, emitted + + defined = set(re.findall(r"\.(tag-[\w-]+)", CSS.read_text())) + unstyled = emitted - defined - NEUTRAL_TAGS + assert unstyled == set(), f"emitted with no rule and not declared neutral: {unstyled}"