diff --git a/CHANGELOG.md b/CHANGELOG.md
index 898af87..82fa0ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ 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.
+- 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.
- DuckDB schema v21: `welcome_template` singleton table for the per-instance override. Auto-migration v20→v21 on first start.
diff --git a/app/api/setup_banner.py b/app/api/setup_banner.py
new file mode 100644
index 0000000..9456098
--- /dev/null
+++ b/app/api/setup_banner.py
@@ -0,0 +1,114 @@
+"""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 296135b..38e4476 100644
--- a/app/main.py
+++ b/app/main.py
@@ -122,6 +122,7 @@ 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
@@ -529,6 +530,7 @@ 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 fb12c40..87abbd5 100644
--- a/app/web/router.py
+++ b/app/web/router.py
@@ -727,13 +727,17 @@ 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)
@@ -911,6 +915,26 @@ 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(
request: Request,
diff --git a/app/web/templates/_app_header.html b/app/web/templates/_app_header.html
index ce38243..168ecbe 100644
--- a/app/web/templates/_app_header.html
+++ b/app/web/templates/_app_header.html
@@ -14,7 +14,7 @@
Setup local agent
{% if session.user.is_admin %}
Marketplaces
- {% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/welcome') %}
+ {% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/welcome') or _path.startswith('/admin/setup-banner') %}
{% endif %}
diff --git a/app/web/templates/admin_setup_banner.html b/app/web/templates/admin_setup_banner.html
new file mode 100644
index 0000000..ace18dd
--- /dev/null
+++ b/app/web/templates/admin_setup_banner.html
@@ -0,0 +1,465 @@
+{% extends "base.html" %}
+{% block title %}Setup Banner — {{ config.INSTANCE_NAME }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 }}" }}
+ Copy
+
+
+
+
+
+
+ Remove banner
+ Save banner
+
+
+
+
+
+
+
+
Remove the banner?
+
/setup will go back to showing no banner. This cannot be undone.
+
+ Cancel
+ Remove
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/web/templates/install.html b/app/web/templates/install.html
index 39ffea6..bf018ca 100644
--- a/app/web/templates/install.html
+++ b/app/web/templates/install.html
@@ -258,6 +258,21 @@
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);
@@ -648,6 +663,10 @@
+ {% if banner_html %}
+ {{ banner_html | safe }}
+ {% endif %}
+
Getting started
diff --git a/src/db.py b/src/db.py
index adb0cf0..0a521ed 100644
--- a/src/db.py
+++ b/src/db.py
@@ -39,7 +39,7 @@ def _maybe_instrument(con, db_tag: str):
_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
-SCHEMA_VERSION = 21
+SCHEMA_VERSION = 22
_SYSTEM_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_version (
@@ -417,6 +417,16 @@ CREATE TABLE IF NOT EXISTS welcome_template (
updated_by VARCHAR,
CONSTRAINT singleton CHECK (id = 1)
);
+
+-- v22: customizable banner shown above setup commands on /setup page.
+-- Singleton row (id=1). NULL content means "no banner".
+CREATE TABLE IF NOT EXISTS setup_banner (
+ id INTEGER PRIMARY KEY DEFAULT 1,
+ content TEXT,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR,
+ CONSTRAINT singleton CHECK (id = 1)
+);
"""
@@ -1637,6 +1647,17 @@ _V20_TO_V21_MIGRATIONS = [
"INSERT INTO welcome_template (id, content) VALUES (1, NULL) ON CONFLICT (id) DO NOTHING",
]
+_V21_TO_V22_MIGRATIONS = [
+ """CREATE TABLE IF NOT EXISTS setup_banner (
+ id INTEGER PRIMARY KEY DEFAULT 1,
+ content TEXT,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR,
+ CONSTRAINT singleton CHECK (id = 1)
+ )""",
+ "INSERT INTO setup_banner (id, content) VALUES (1, NULL) ON CONFLICT (id) DO NOTHING",
+]
+
def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
"""Create tables if they don't exist. Apply migrations if schema version changed.
@@ -1699,6 +1720,10 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
"INSERT INTO welcome_template (id, content) VALUES (1, NULL) "
"ON CONFLICT (id) DO NOTHING"
)
+ conn.execute(
+ "INSERT INTO setup_banner (id, content) VALUES (1, NULL) "
+ "ON CONFLICT (id) DO NOTHING"
+ )
# Fresh-install seed is handled by the unconditional
# _seed_core_roles call at the bottom of _ensure_schema —
# left as a no-op branch here so the migration ladder still
@@ -1779,6 +1804,9 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
if current < 21:
for sql in _V20_TO_V21_MIGRATIONS:
conn.execute(sql)
+ if current < 22:
+ for sql in _V21_TO_V22_MIGRATIONS:
+ conn.execute(sql)
conn.execute(
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
[SCHEMA_VERSION],
diff --git a/src/repositories/setup_banner.py b/src/repositories/setup_banner.py
new file mode 100644
index 0000000..5443754
--- /dev/null
+++ b/src/repositories/setup_banner.py
@@ -0,0 +1,53 @@
+"""Repository for the per-instance setup-page banner override (singleton row)."""
+
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+import duckdb
+
+
+class SetupBannerRepository:
+ def __init__(self, conn: duckdb.DuckDBPyConnection):
+ self.conn = conn
+
+ def get(self) -> dict[str, Any]:
+ """Return the singleton row. Always exists post-migration; content
+ is None when no banner is set."""
+ row = self.conn.execute(
+ "SELECT id, content, updated_at, updated_by FROM setup_banner WHERE id = 1"
+ ).fetchone()
+ if row is None:
+ # Defensive: re-seed if a previous admin manually deleted it.
+ self.conn.execute(
+ "INSERT INTO setup_banner (id, content) VALUES (1, NULL) "
+ "ON CONFLICT (id) DO NOTHING"
+ )
+ return {"id": 1, "content": None, "updated_at": None, "updated_by": None}
+ return {
+ "id": row[0],
+ "content": row[1],
+ "updated_at": row[2],
+ "updated_by": row[3],
+ }
+
+ def set(self, content: str, *, updated_by: str) -> None:
+ now = datetime.now(timezone.utc)
+ self.conn.execute(
+ """INSERT INTO setup_banner (id, content, updated_at, updated_by)
+ VALUES (1, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET
+ content = excluded.content,
+ updated_at = excluded.updated_at,
+ updated_by = excluded.updated_by""",
+ [content, now, updated_by],
+ )
+
+ def reset(self, *, updated_by: str) -> None:
+ """Clear the banner; /setup will show no banner."""
+ now = datetime.now(timezone.utc)
+ self.conn.execute(
+ """UPDATE setup_banner
+ SET content = NULL, updated_at = ?, updated_by = ?
+ WHERE id = 1""",
+ [now, updated_by],
+ )
diff --git a/src/setup_banner.py b/src/setup_banner.py
new file mode 100644
index 0000000..e4d11a1
--- /dev/null
+++ b/src/setup_banner.py
@@ -0,0 +1,85 @@
+"""Render the admin-editable setup-page banner.
+
+Smaller surface than welcome_template — only instance/server/user context.
+Setup banner is for organization-specific operational notes (VPN, support,
+data classification), not for analyst-side content.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import date, datetime, timezone
+from typing import Any, Optional
+from urllib.parse import urlparse
+
+import duckdb
+from jinja2 import Environment, StrictUndefined, TemplateError
+
+from app.instance_config import get_instance_name, get_instance_subtitle
+from src.repositories.setup_banner import SetupBannerRepository
+
+_logger = logging.getLogger(__name__)
+
+
+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)
+ return template.render(**build_setup_banner_context(user=user, server_url=server_url))
+ 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
new file mode 100644
index 0000000..9e1c0aa
--- /dev/null
+++ b/tests/test_setup_banner_api.py
@@ -0,0 +1,104 @@
+"""End-to-end tests for /api/admin/setup-banner endpoints."""
+
+
+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
diff --git a/tests/test_setup_banner_migration.py b/tests/test_setup_banner_migration.py
new file mode 100644
index 0000000..8b8b3d0
--- /dev/null
+++ b/tests/test_setup_banner_migration.py
@@ -0,0 +1,54 @@
+"""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
new file mode 100644
index 0000000..fb7c0ed
--- /dev/null
+++ b/tests/test_setup_banner_render.py
@@ -0,0 +1,80 @@
+"""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 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 != ""
diff --git a/tests/test_setup_banner_repo.py b/tests/test_setup_banner_repo.py
new file mode 100644
index 0000000..fb421f6
--- /dev/null
+++ b/tests/test_setup_banner_repo.py
@@ -0,0 +1,49 @@
+"""Unit tests for SetupBannerRepository."""
+
+import duckdb
+import pytest
+
+from src.db import _ensure_schema
+from src.repositories.setup_banner import SetupBannerRepository
+
+
+@pytest.fixture
+def conn(tmp_path):
+ db_path = tmp_path / "system.duckdb"
+ c = duckdb.connect(str(db_path))
+ _ensure_schema(c)
+ yield c
+ c.close()
+
+
+def test_get_returns_none_on_fresh_install(conn):
+ repo = SetupBannerRepository(conn)
+ row = repo.get()
+ assert row is not None
+ assert row["content"] is None # no banner by default
+
+
+def test_set_stores_content(conn):
+ repo = SetupBannerRepository(conn)
+ repo.set("VPN required
", updated_by="admin@example.com")
+ row = repo.get()
+ assert row["content"] == "VPN required
"
+ assert row["updated_by"] == "admin@example.com"
+ assert row["updated_at"] is not None
+
+
+def test_reset_clears_content(conn):
+ repo = SetupBannerRepository(conn)
+ repo.set("Note
", updated_by="admin@example.com")
+ repo.reset(updated_by="admin@example.com")
+ row = repo.get()
+ assert row["content"] is None
+
+
+def test_set_overwrites_existing(conn):
+ repo = SetupBannerRepository(conn)
+ repo.set("first", updated_by="a@example.com")
+ repo.set("second", updated_by="b@example.com")
+ row = repo.get()
+ assert row["content"] == "second"
+ assert row["updated_by"] == "b@example.com"