diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea27f8..6e084e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,10 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ### Added -- Admin-editable banner on `/setup` page — admins can author a Jinja2/HTML banner displayed above the auto-generated bootstrap commands. Empty by default (no banner shown). Edit at `/admin/setup-banner`. Endpoints: `GET /api/admin/setup-banner` (returns content + audit), `PUT` to set, `DELETE` to clear, `POST /api/admin/setup-banner/preview` for live preview. Useful for org-specific notes: VPN requirements, support channel, data classification, platform prerequisites. See `docs/setup-banner.md` for the full placeholder reference and security notes. -- DuckDB schema v22: `setup_banner` singleton table for the per-instance banner. Auto-migration v21→v22 on first start. -- Customizable analyst welcome prompt (`CLAUDE.md` generated by `da analyst setup`). Default ships at `config/claude_md_template.txt` (now Jinja2 syntax). Admins override per instance via the `/admin/welcome` editor or `PUT /api/admin/welcome-template`. New endpoint `GET /api/welcome` returns the rendered prompt for the calling user, with `marketplaces` filtered by RBAC. See `docs/welcome-template.md` for the full placeholder reference. -- `POST /api/admin/welcome-template/preview` — renders arbitrary template content against the calling admin's live context without persisting. Backs the editor's Preview button. +- **Agent Setup Prompt** — admins can customise the `CLAUDE.md` generated for analysts by `da analyst setup`. Default ships at `config/claude_md_template.txt` (Jinja2 syntax). Edit at `/admin/agent-prompt` or via REST: `GET /api/admin/welcome-template` returns `{content, default, updated_at, updated_by}`; `PUT` to set; `DELETE` to clear; `POST /api/admin/welcome-template/preview` for live preview without persisting. `GET /api/welcome` returns the prompt rendered for the calling user (RBAC-filtered `marketplaces`). See `docs/agent-setup-prompt.md` for the full placeholder reference. - DuckDB schema v21: `welcome_template` singleton table for the per-instance override. Auto-migration v20→v21 on first start. -- New `instance.sync_interval` setting in `instance.yaml` (default `"1 hour"`) — surfaced in the welcome prompt as `{{ sync_interval }}`. +- DuckDB schema v22: reserved (`setup_banner` table retained for forward compatibility with already-migrated instances; feature dropped). +- New `instance.sync_interval` setting in `instance.yaml` (default `"1 hour"`) — surfaced in the agent setup prompt as `{{ sync_interval }}`. ### Changed diff --git a/app/api/setup_banner.py b/app/api/setup_banner.py deleted file mode 100644 index 9456098..0000000 --- a/app/api/setup_banner.py +++ /dev/null @@ -1,114 +0,0 @@ -"""REST endpoints for the setup-page banner. - -- GET /api/admin/setup-banner : raw content + audit info (admin) -- PUT /api/admin/setup-banner : set banner (admin) -- DELETE /api/admin/setup-banner : clear banner (admin) -- POST /api/admin/setup-banner/preview : preview arbitrary content (admin) -""" - -import datetime -from typing import Optional - -import duckdb -from fastapi import APIRouter, Depends, HTTPException, Request, Response -from jinja2 import Environment, StrictUndefined, TemplateError -from pydantic import BaseModel, Field - -from app.auth.access import require_admin -from app.auth.dependencies import _get_db -from src.repositories.setup_banner import SetupBannerRepository -from src.setup_banner import build_setup_banner_context - - -router = APIRouter(tags=["setup-banner"]) - -# Stub context used to validate that a saved template renders end-to-end, -# not just that it parses. Mirrors the shape of build_setup_banner_context() output. -_VALIDATION_STUB_CONTEXT = { - "instance": {"name": "Example", "subtitle": "Example Org"}, - "server": {"url": "https://example.com", "hostname": "example.com"}, - "user": {"id": "u", "email": "user@example.com", "name": "User", "is_admin": False}, - "now": datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), - "today": "2026-01-01", -} - - -class BannerGetResponse(BaseModel): - content: Optional[str] - updated_at: Optional[str] = None - updated_by: Optional[str] = None - - -class BannerPutRequest(BaseModel): - content: str = Field(..., min_length=1, max_length=200_000) - - -class BannerPreviewRequest(BaseModel): - content: str = Field(..., min_length=1, max_length=200_000) - - -class BannerPreviewResponse(BaseModel): - content: str - - -@router.get("/api/admin/setup-banner", response_model=BannerGetResponse) -async def admin_get_banner( - user: dict = Depends(require_admin), - conn: duckdb.DuckDBPyConnection = Depends(_get_db), -): - row = SetupBannerRepository(conn).get() - return BannerGetResponse( - content=row["content"], - updated_at=row["updated_at"].isoformat() if row["updated_at"] else None, - updated_by=row["updated_by"], - ) - - -@router.put("/api/admin/setup-banner") -async def admin_put_banner( - payload: BannerPutRequest, - user: dict = Depends(require_admin), - conn: duckdb.DuckDBPyConnection = Depends(_get_db), -): - env = Environment(undefined=StrictUndefined, autoescape=True) - try: - template = env.from_string(payload.content) - # Render against a stub context so undefined placeholders or runtime - # errors are caught here, not when an analyst visits /setup. - template.render(**_VALIDATION_STUB_CONTEXT) - except TemplateError as e: - raise HTTPException(status_code=400, detail=f"Template invalid: {e}") - SetupBannerRepository(conn).set(payload.content, updated_by=user["email"]) - return {"status": "ok"} - - -@router.delete("/api/admin/setup-banner", status_code=204) -async def admin_reset_banner( - user: dict = Depends(require_admin), - conn: duckdb.DuckDBPyConnection = Depends(_get_db), -): - SetupBannerRepository(conn).reset(updated_by=user["email"]) - return Response(status_code=204) - - -@router.post("/api/admin/setup-banner/preview", response_model=BannerPreviewResponse) -async def admin_preview_banner( - payload: BannerPreviewRequest, - request: Request, - user: dict = Depends(require_admin), - conn: duckdb.DuckDBPyConnection = Depends(_get_db), -): - """Render arbitrary banner content against the live context for the - calling admin, without persisting. Used by the /admin/setup-banner editor's - Preview button so admins can see their edits before saving.""" - env = Environment(undefined=StrictUndefined, autoescape=True) - try: - template = env.from_string(payload.content) - ctx = build_setup_banner_context( - user=user, - server_url=str(request.base_url).rstrip("/"), - ) - rendered = template.render(**ctx) - except TemplateError as e: - raise HTTPException(status_code=400, detail=f"Template invalid: {e}") - return BannerPreviewResponse(content=rendered) diff --git a/app/main.py b/app/main.py index 38e4476..296135b 100644 --- a/app/main.py +++ b/app/main.py @@ -122,7 +122,6 @@ from app.api.v2_sample import router as v2_sample_router from app.api.v2_scan import router as v2_scan_router from app.api.marketplaces import router as marketplaces_router from app.api.welcome import router as welcome_router -from app.api.setup_banner import router as setup_banner_router from app.marketplace_server.router import router as marketplace_server_router from app.marketplace_server.git_router import make_git_wsgi_app from app.web.router import router as web_router @@ -530,7 +529,6 @@ def create_app() -> FastAPI: app.include_router(v2_scan_router) app.include_router(marketplaces_router) app.include_router(welcome_router) - app.include_router(setup_banner_router) app.include_router(marketplace_server_router) # Git smart-HTTP endpoint for Claude Code: /marketplace.git/* diff --git a/app/web/router.py b/app/web/router.py index 962f746..0ab31b7 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -727,17 +727,13 @@ async def setup_page( conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Setup instructions for the local agent (CLI + Claude Code).""" - from src.setup_banner import render_setup_banner - base_url = str(request.base_url).rstrip("/") - banner_html = render_setup_banner(conn, user=user, server_url=base_url) ctx = _build_context( request, user=user, conn=conn, server_url=base_url, agnes_version=os.environ.get("AGNES_VERSION", "dev"), - banner_html=banner_html, ) return templates.TemplateResponse(request, "install.html", ctx) @@ -898,8 +894,8 @@ async def admin_marketplaces_page( return templates.TemplateResponse(request, "admin_marketplaces.html", ctx) -@router.get("/admin/welcome", response_class=HTMLResponse) -async def admin_welcome_page( +@router.get("/admin/agent-prompt", response_class=HTMLResponse) +async def admin_agent_prompt_page( request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), @@ -920,25 +916,6 @@ async def admin_welcome_page( return templates.TemplateResponse(request, "admin_welcome.html", ctx) -@router.get("/admin/setup-banner", response_class=HTMLResponse) -async def admin_setup_banner_page( - request: Request, - user: dict = Depends(require_admin), - conn: duckdb.DuckDBPyConnection = Depends(_get_db), -): - from src.repositories.setup_banner import SetupBannerRepository - - row = SetupBannerRepository(conn).get() - ctx = _build_context( - request, - user=user, - current=row["content"] or "", - updated_at=row["updated_at"], - updated_by=row["updated_by"], - is_override=row["content"] is not None, - ) - return templates.TemplateResponse(request, "admin_setup_banner.html", ctx) - @router.get("/tokens", response_class=HTMLResponse) async def my_tokens_page( diff --git a/app/web/templates/admin_setup_banner.html b/app/web/templates/admin_setup_banner.html deleted file mode 100644 index c96d351..0000000 --- a/app/web/templates/admin_setup_banner.html +++ /dev/null @@ -1,499 +0,0 @@ -{% extends "base.html" %} -{% block title %}Setup Banner — {{ config.INSTANCE_NAME }}{% endblock %} - -{% block content %} - - - - - - - -
-
-
-

Setup Page Banner

-

Shown above the bootstrap commands on /setup. Use it for org-specific notes: VPN requirements, support channel, data classification, platform prerequisites. Empty by default — no banner is shown when unset.

-
-
- {% if is_override %} - - Banner active - - {% else %} - No banner - {% endif %} -
-
- -
-
-

- Author HTML or plain text with Jinja2 placeholders. The banner renders inside the /setup page — - HTML tags are allowed. Leave empty and click Remove banner to go back to no banner. - {{ "{{ user }}" }} may be null for anonymous visitors — guard with {% if user %}. -

- -
- Available placeholders -
- {{ "{{ instance.name }}" }} — instance display name -{{ "{{ instance.subtitle }}" }} — operator name -{{ "{{ server.url }}" }} — full server URL -{{ "{{ server.hostname }}" }} — host part only -{{ "{{ user.email }}" }}, {{ "{{ user.name }}" }}, {{ "{{ user.is_admin }}" }} - — null for anonymous visitors; guard with {% if user %} -{{ "{{ now }}" }}, {{ "{{ today }}" }} - -
-
- -
-
- -
-
-

Live preview

-
- -
-
-
-
- - -
-
-
- - - - -
- - -{% endblock %} diff --git a/app/web/templates/install.html b/app/web/templates/install.html index bf018ca..39ffea6 100644 --- a/app/web/templates/install.html +++ b/app/web/templates/install.html @@ -258,21 +258,6 @@ background: rgba(166, 227, 161, 0.08); } - /* ── Admin setup banner (shown when admin has set one) ── */ - .setup-banner { - background: var(--surface, #fff); - border: 1px solid var(--border, #e5e7eb); - border-left: 3px solid var(--primary, #6366f1); - border-radius: var(--radius, 8px); - padding: 14px 18px; - margin-bottom: 20px; - font-size: 14px; - color: var(--text-primary, #111827); - line-height: 1.6; - } - .setup-banner > *:first-child { margin-top: 0; } - .setup-banner > *:last-child { margin-bottom: 0; } - /* ── Anon sign-in banner (shown only when logged out) ── */ .auth-banner { background: var(--background); @@ -663,10 +648,6 @@
- {% if banner_html %} -
{{ banner_html | safe }}
- {% endif %} -
Getting started
diff --git a/docs/setup-banner.md b/docs/setup-banner.md deleted file mode 100644 index 4aa11f7..0000000 --- a/docs/setup-banner.md +++ /dev/null @@ -1,94 +0,0 @@ -# Setup page banner - -The setup banner is a block of HTML (or plain text) shown **above** the -auto-generated bootstrap commands on the `/setup` page. Use it for -org-specific operational notes that analysts need before they install the -client: VPN requirements, support channel, data-classification policy, -platform prerequisites, etc. - -The banner is empty by default — no content is shown until an admin sets one. - -## How to edit - -- **Admin UI:** `/admin/setup-banner` — split-pane editor with a placeholder - cheatsheet and a live HTML preview. Click **Save banner** to persist, - **Remove banner** to clear. -- **REST API:** - - `GET /api/admin/setup-banner` — returns `{content, updated_at, updated_by}`. - `content` is `null` when no banner is set. - - `PUT /api/admin/setup-banner` with body `{"content": "..."}` — validates - Jinja2 syntax and stores the banner. - - `DELETE /api/admin/setup-banner` — clears the banner; `/setup` shows no - banner until one is set again. - - `POST /api/admin/setup-banner/preview` with body `{"content": "..."}` — - renders arbitrary content against the calling admin's context without - persisting. Backs the editor's live preview. - -The banner lives in `system.duckdb` (table `setup_banner`, singleton row id=1). - -## Available placeholders - -| Placeholder | Type | Notes | -|---|---|---| -| `instance.name` | string | `instance.name` in `instance.yaml` | -| `instance.subtitle` | string | `instance.subtitle` in `instance.yaml` | -| `server.url` | string | full origin of the Agnes server | -| `server.hostname` | string | host part only (no port or path) | -| `user.email` | string | logged-in user, or `null` for anonymous visitors | -| `user.name` | string | logged-in user display name | -| `user.is_admin` | bool | `true` when the visitor is in the Admin group | -| `now` | datetime (UTC, tz-aware) | server time at render | -| `today` | string (`YYYY-MM-DD`) | server date at render | - -> **`user` may be `null`** — `/setup` is partly public (anonymous visitors -> get the install one-liner). Always guard user-specific placeholders: -> -> ```jinja2 -> {% if user %}Welcome back, {{ user.name }}!{% endif %} -> ``` - -## Autoescape semantics - -The Jinja2 environment runs with `autoescape=True`, which means template -**variable output** (`{{ ... }}`) is HTML-escaped automatically. Literal HTML -in the template source is passed through unchanged — that is how the banner -outputs `

` tags, ``, etc. - -To output a literal `<` or `&` from a variable, use the `| safe` filter only -when you are certain the value is trusted: - -```jinja2 -{# Safe — admin-authored constant: #} -{{ "VPN required" | safe }} - -{# Dangerous — never pipe user-controlled values through | safe: #} -{{ user.name | safe }} {# do NOT do this #} -``` - -## Security note - -Admin-authored banner content is rendered for **all `/setup` visitors**, -including anonymous users. As a defense-in-depth measure, inline ``` blocks (case-insensitive, including unclosed). - - ```` blocks. - - ``on*=`` event-handler attributes (e.g. onclick, onload, onerror). - - ``javascript:`` and ``data:`` URI schemes in href/src attributes. - """ - html = _RE_SCRIPT.sub("", html) - html = _RE_IFRAME.sub("", html) - html = _RE_ON_ATTR.sub("", html) - html = _RE_JS_URI.sub(lambda m: m.group(1) + "#" + m.group(2), html) - return html - - -def build_setup_banner_context( - *, - user: Optional[dict], - server_url: str, -) -> dict[str, Any]: - """Compose the Jinja2 render context for the setup banner. - - ``user`` may be None on the anonymous path of /setup (the page is partly - public — anonymous visitors get the curl-install one-liner). Templates - must guard for that with ``{% if user %}``. - """ - parsed = urlparse(server_url) - return { - "instance": { - "name": get_instance_name(), - "subtitle": get_instance_subtitle(), - }, - "server": { - "url": server_url, - "hostname": parsed.hostname or "", - }, - "user": ( - { - "id": user.get("id", ""), - "email": user.get("email", ""), - "name": user.get("name") or "", - "is_admin": bool(user.get("is_admin")), - } - if user - else None - ), - "now": datetime.now(timezone.utc), - "today": date.today().isoformat(), - } - - -def render_setup_banner( - conn: duckdb.DuckDBPyConnection, - *, - user: Optional[dict], - server_url: str, -) -> str: - """Render the banner. Returns "" when no override is set or render fails. - - Render failures are swallowed (logged) — a broken admin banner must NOT - break /setup for analysts. The /admin/setup-banner editor catches Jinja - errors at PUT time anyway, so this is defense-in-depth. - """ - row = SetupBannerRepository(conn).get() - source = row.get("content") - if not source: - return "" - env = Environment(undefined=StrictUndefined, autoescape=True) - try: - template = env.from_string(source) - rendered = template.render(**build_setup_banner_context(user=user, server_url=server_url)) - return _sanitize_banner_html(rendered) - except TemplateError: - _logger.warning( - "setup_banner render failed; returning empty banner. " - "Admin can fix at /admin/setup-banner." - ) - return "" diff --git a/tests/test_setup_banner_api.py b/tests/test_setup_banner_api.py deleted file mode 100644 index 9c71571..0000000 --- a/tests/test_setup_banner_api.py +++ /dev/null @@ -1,139 +0,0 @@ -"""End-to-end tests for /api/admin/setup-banner endpoints.""" - -import duckdb - -from src.db import _ensure_schema -from src.setup_banner import build_setup_banner_context - - -def _auth(token: str) -> dict[str, str]: - return {"Authorization": f"Bearer {token}"} - - -def test_admin_can_set_and_clear_banner(seeded_app): - c = seeded_app["client"] - admin = _auth(seeded_app["admin_token"]) - - # GET initial state - r = c.get("/api/admin/setup-banner", headers=admin) - assert r.status_code == 200 - body = r.json() - assert body["content"] is None - - # PUT banner - r = c.put( - "/api/admin/setup-banner", - json={"content": "

VPN required before install.

"}, - headers=admin, - ) - assert r.status_code == 200 - - # GET shows new content - r = c.get("/api/admin/setup-banner", headers=admin) - assert r.json()["content"] == "

VPN required before install.

" - assert r.json()["updated_by"] is not None - - # DELETE = clear - r = c.delete("/api/admin/setup-banner", headers=admin) - assert r.status_code == 204 - - r = c.get("/api/admin/setup-banner", headers=admin) - assert r.json()["content"] is None - - -def test_non_admin_cannot_edit_banner(seeded_app): - c = seeded_app["client"] - analyst = _auth(seeded_app["analyst_token"]) - r = c.put("/api/admin/setup-banner", json={"content": "

x

"}, headers=analyst) - assert r.status_code == 403 - - -def test_put_rejects_invalid_jinja2(seeded_app): - c = seeded_app["client"] - admin = _auth(seeded_app["admin_token"]) - r = c.put( - "/api/admin/setup-banner", - json={"content": "{% for x in y %}"}, # unclosed loop - headers=admin, - ) - assert r.status_code == 400 - assert "invalid" in r.json()["detail"].lower() - - -def test_put_rejects_undefined_placeholder(seeded_app): - """Templates that reference unknown placeholders must be rejected at PUT - time so the admin sees the error immediately.""" - c = seeded_app["client"] - admin = _auth(seeded_app["admin_token"]) - r = c.put( - "/api/admin/setup-banner", - json={"content": "Hello {{ user.emial }}"}, # typo - headers=admin, - ) - assert r.status_code == 400 - assert "emial" in r.json()["detail"] or "undefined" in r.json()["detail"].lower() - - -def test_preview_renders_arbitrary_content(seeded_app): - c = seeded_app["client"] - admin = _auth(seeded_app["admin_token"]) - r = c.post( - "/api/admin/setup-banner/preview", - json={"content": "Hello {{ user.email }}"}, - headers=admin, - ) - assert r.status_code == 200 - # autoescape=True: rendered content must contain the escaped or literal email - assert "admin@test.com" in r.json()["content"] - - -def test_preview_requires_admin(seeded_app): - c = seeded_app["client"] - analyst = _auth(seeded_app["analyst_token"]) - r = c.post( - "/api/admin/setup-banner/preview", - json={"content": "

x

"}, - headers=analyst, - ) - assert r.status_code == 403 - - -def test_preview_rejects_invalid_template(seeded_app): - c = seeded_app["client"] - admin = _auth(seeded_app["admin_token"]) - r = c.post( - "/api/admin/setup-banner/preview", - json={"content": "{% for x in y %}"}, - headers=admin, - ) - assert r.status_code == 400 - - -def test_validation_stub_matches_build_context_shape(seeded_app, tmp_path, monkeypatch): - """If build_setup_banner_context grows new keys, _VALIDATION_STUB_CONTEXT - must too — otherwise admins can save templates referencing keys the PUT - validator accepts but the live render rejects.""" - from app.api.setup_banner import _VALIDATION_STUB_CONTEXT - - monkeypatch.setenv("DATA_DIR", str(tmp_path)) - db_path = tmp_path / "system.duckdb" - conn = duckdb.connect(str(db_path)) - _ensure_schema(conn) - conn.close() - - user = {"id": "u1", "email": "admin@test.com", "name": "Admin", "is_admin": True} - real_ctx = build_setup_banner_context(user=user, server_url="https://example.com") - - # Top-level keys must match (stub has user=dict, real has user=dict when logged in) - assert set(_VALIDATION_STUB_CONTEXT.keys()) == set(real_ctx.keys()), ( - f"_VALIDATION_STUB_CONTEXT top-level keys differ from build_setup_banner_context output. " - f"Stub has: {set(_VALIDATION_STUB_CONTEXT.keys())}, " - f"real has: {set(real_ctx.keys())}" - ) - - # One level deep for nested dicts - for key in ("instance", "server", "user"): - if isinstance(real_ctx.get(key), dict): - assert set(_VALIDATION_STUB_CONTEXT[key].keys()) == set(real_ctx[key].keys()), ( - f"_VALIDATION_STUB_CONTEXT[{key!r}] drifted from build_setup_banner_context output" - ) diff --git a/tests/test_setup_banner_migration.py b/tests/test_setup_banner_migration.py deleted file mode 100644 index 8b8b3d0..0000000 --- a/tests/test_setup_banner_migration.py +++ /dev/null @@ -1,54 +0,0 @@ -"""v21 → v22 migration: adds setup_banner singleton table.""" - -from pathlib import Path - -import duckdb - -from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version - - -def _open(path: Path) -> duckdb.DuckDBPyConnection: - return duckdb.connect(str(path)) - - -def test_v22_creates_setup_banner_table(tmp_path): - db_path = tmp_path / "system.duckdb" - conn = _open(db_path) - # Pretend we're on v21: run schema then roll version back. - _ensure_schema(conn) - conn.execute("UPDATE schema_version SET version = 21") - conn.execute("DROP TABLE IF EXISTS setup_banner") - conn.close() - - # Re-open: migration ladder runs. - conn = _open(db_path) - _ensure_schema(conn) - assert get_schema_version(conn) == SCHEMA_VERSION - # Singleton row must exist with NULL content (= no banner). - rows = conn.execute( - "SELECT id, content, updated_at, updated_by FROM setup_banner" - ).fetchall() - assert len(rows) == 1 - assert rows[0][0] == 1 # singleton id - assert rows[0][1] is None # NULL = no banner - - -def test_fresh_install_seeds_setup_banner(tmp_path): - db_path = tmp_path / "system.duckdb" - conn = _open(db_path) - _ensure_schema(conn) - assert get_schema_version(conn) == SCHEMA_VERSION - rows = conn.execute("SELECT id, content FROM setup_banner").fetchall() - assert len(rows) == 1 - assert rows[0][0] == 1 - assert rows[0][1] is None - - -def test_welcome_template_unaffected_by_v22(tmp_path): - """welcome_template table must still coexist after v22 migration.""" - db_path = tmp_path / "system.duckdb" - conn = _open(db_path) - _ensure_schema(conn) - rows = conn.execute("SELECT id, content FROM welcome_template").fetchall() - assert len(rows) == 1 - assert rows[0][0] == 1 diff --git a/tests/test_setup_banner_render.py b/tests/test_setup_banner_render.py deleted file mode 100644 index e1dc571..0000000 --- a/tests/test_setup_banner_render.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Unit tests for the setup-banner renderer module.""" - -import duckdb -import pytest - -from src.db import _ensure_schema -from src.repositories.setup_banner import SetupBannerRepository -from src.setup_banner import _sanitize_banner_html, build_setup_banner_context, render_setup_banner - - -@pytest.fixture -def conn(tmp_path, monkeypatch): - monkeypatch.setenv("DATA_DIR", str(tmp_path)) - db_path = tmp_path / "system.duckdb" - c = duckdb.connect(str(db_path)) - _ensure_schema(c) - yield c - c.close() - - -def _user(email="alice@example.com"): - return {"id": "u1", "email": email, "name": "Alice", "is_admin": False} - - -def test_render_returns_empty_when_no_override(conn): - out = render_setup_banner(conn, user=_user(), server_url="https://example.com") - assert out == "" - - -def test_render_uses_override(conn): - SetupBannerRepository(conn).set( - "

VPN: {{ server.hostname }}

", updated_by="admin@example.com" - ) - out = render_setup_banner(conn, user=_user(), server_url="https://example.com") - # autoescape=True — rendered as HTML - assert "example.com" in out - assert "

" in out - - -def test_render_returns_empty_on_invalid_template_does_not_raise(conn): - """A broken admin banner must not raise; it must return "" (defense-in-depth).""" - SetupBannerRepository(conn).set( - "{{ does_not_exist }}", updated_by="admin@example.com" - ) - out = render_setup_banner(conn, user=_user(), server_url="https://example.com") - assert out == "" # swallowed, not raised - - -def test_render_with_anonymous_user(conn): - SetupBannerRepository(conn).set( - "{% if user %}{{ user.email }}{% else %}anonymous{% endif %}", - updated_by="admin@example.com", - ) - out = render_setup_banner(conn, user=None, server_url="https://example.com") - assert "anonymous" in out - - -def test_context_exposes_documented_keys(conn): - ctx = build_setup_banner_context(user=_user(), server_url="https://example.com") - for top in ("instance", "server", "user", "now", "today"): - assert top in ctx, f"missing top-level key: {top}" - assert ctx["server"]["hostname"] == "example.com" - assert ctx["user"]["email"] == "alice@example.com" - - -def test_context_with_anonymous_user_returns_none(conn): - ctx = build_setup_banner_context(user=None, server_url="https://example.com") - assert ctx["user"] is None - - -def test_autoescape_escapes_html_entities(conn): - """autoescape=True must escape < > & in template variable output.""" - SetupBannerRepository(conn).set( - "{{ server.hostname }}", updated_by="admin@example.com" - ) - out = render_setup_banner( - conn, user=_user(), server_url="https://example.com/" - ) - # hostname won't contain < > but the render must succeed without injection - assert out != "" - - -# ── Sanitizer unit tests ───────────────────────────────────────────────────── - -def test_render_strips_script_tags(conn): - """render_setup_banner must remove ', - updated_by="admin@example.com", - ) - out = render_setup_banner(conn, user=_user(), server_url="https://example.com") - assert "