From f060ab7dda6e083aec530bbd0579c96a3e2b155e Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Mon, 3 Aug 2026 12:51:20 +0200 Subject: [PATCH 1/2] docs(spec): add spec 0016 dashboard --- spec/0016-dashboard.md | 127 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 spec/0016-dashboard.md diff --git a/spec/0016-dashboard.md b/spec/0016-dashboard.md new file mode 100644 index 0000000..1c5f69b --- /dev/null +++ b/spec/0016-dashboard.md @@ -0,0 +1,127 @@ +# 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 whether a base URL + is set, with an explicit warning for the combination "enabled but no base + URL", which is the one that silently does not work. **This section 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: With ACME enabled and no base URL, the services section warns; with + both set, it does not. +- 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_warning, +test_dashboard_ca_expired_is_danger, test_dashboard_crl_stale_is_danger, +test_dashboard_crl_absent_says_so, test_dashboard_hides_services_from_viewer, +test_dashboard_warns_acme_enabled_without_base_url, +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. From ff0fcc266304e3dc90d9e7554bdfa8fc06482a7f Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Mon, 3 Aug 2026 13:04:27 +0200 Subject: [PATCH 2/2] feat(web): make the dashboard show what needs attention Replaces the spec 0003 stub with expiring certificates, inventory counts, the CA's own expiry, CRL freshness, service state and recent activity. The counts run the same filters the inventory runs, so a tile and the page it links to cannot disagree. The services section keeps /settings' role: a viewer must not learn configuration here that they are refused there. --- CHANGELOG.md | 15 + spec/0016-dashboard.md | 27 +- src/cabin/ca/certs.py | 40 +++ src/cabin/web/static/cabin.css | 35 ++ src/cabin/web/templates/dashboard.html | 196 ++++++++++- src/cabin/web/ui.py | 109 +++++- tests/test_web_dashboard.py | 443 +++++++++++++++++++++++++ 7 files changed, 850 insertions(+), 15 deletions(-) create mode 100644 tests/test_web_dashboard.py 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 index 1c5f69b..705d0a3 100644 --- a/spec/0016-dashboard.md +++ b/spec/0016-dashboard.md @@ -63,12 +63,14 @@ number is something the operator could already have found by hand. 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 whether a base URL - is set, with an explicit warning for the combination "enabled but no base - URL", which is the one that silently does not work. **This section 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-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 @@ -98,8 +100,10 @@ number is something the operator could already have found by hand. 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: With ACME enabled and no base URL, the services section warns; with - both set, it does not. +- 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 @@ -111,10 +115,11 @@ 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_warning, -test_dashboard_ca_expired_is_danger, test_dashboard_crl_stale_is_danger, +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_dashboard_warns_acme_enabled_without_base_url, +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 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 %} +

+ 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}"