feat(admin): drop setup_banner feature; consolidate into single editor

Remove the setup_banner feature (admin-editable /setup page banner) and
all associated code: API router, repository, renderer, admin template,
tests, and docs. The setup_page handler no longer calls render_setup_banner;
the install.html template no longer renders banner_html. The setup_banner
DuckDB table (v22) is kept intact for forward-compat with already-migrated
instances — only the application code is removed.

CHANGELOG updated: setup_banner bullets removed; Agent Setup Prompt
(welcome-template feature) now stands alone as the single editable prompt.
This commit is contained in:
ZdenekSrotyr 2026-05-02 21:46:47 +02:00
parent 0ee22f8fb0
commit c7b14fb120
14 changed files with 7 additions and 1295 deletions

View file

@ -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

View file

@ -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)

View file

@ -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/*

View file

@ -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(

View file

@ -1,499 +0,0 @@
{% extends "base.html" %}
{% block title %}Setup Banner — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"
integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ=="
crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"
integrity="sha512-2OhXH4Il3n2tHKwLLSDPhrkgnLBC+6lHGGQzSFi3chgVB6DJ/v6+nbx+XYO9CugQyHVF/8D/0k3Hx1eaUK2K9g=="
crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
crossorigin="anonymous"></script>
<style>
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
.welcome-page { max-width: 1400px; margin: 0 auto; padding: 0; }
.welcome-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.welcome-title { margin: 0; font-size: 22px; font-weight: 600; }
.welcome-sub { color: var(--text-secondary, #6b7280); font-size: 13px; margin-top: 4px; margin-bottom: 0; }
.origin-chip {
display: inline-block;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px;
}
.origin-override { background: #ede9fe; color: #6d28d9; }
.origin-default { background: #f3f4f6; color: #6b7280; }
.welcome-card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
.welcome-card-body { padding: 20px 22px; }
.welcome-desc { font-size: 13px; color: var(--text-secondary, #6b7280); margin: 0 0 16px; }
/* Placeholder cheatsheet — collapsible */
details.welcome-cheatsheet { margin-bottom: 18px; }
details.welcome-cheatsheet summary {
cursor: pointer; font-size: 13px; font-weight: 500;
color: var(--text-primary, #111827); list-style: none;
display: inline-flex; align-items: center; gap: 6px;
}
details.welcome-cheatsheet summary::-webkit-details-marker { display: none; }
details.welcome-cheatsheet summary::before {
content: "▶"; font-size: 10px; color: var(--text-secondary, #6b7280);
transition: transform 0.15s;
}
details.welcome-cheatsheet[open] summary::before { transform: rotate(90deg); }
/* Dark code block */
.code-block {
background: #1e1e2e;
border-radius: 8px;
padding: 14px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 13px;
color: #cdd6f4;
line-height: 1.6;
margin-top: 10px;
position: relative;
}
.code-block .code-body { flex: 1; white-space: pre; overflow-x: auto; }
.btn-copy {
padding: 6px 14px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
cursor: pointer;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
font-family: inherit;
transition: all 0.15s ease;
flex-shrink: 0;
}
.btn-copy:hover { border-color: #89b4fa; color: #89b4fa; background: rgba(137, 180, 250, 0.08); }
.btn-copy.copied { border-color: #a6e3a1; color: #a6e3a1; background: rgba(166, 227, 161, 0.08); }
/* CodeMirror editor styling */
.welcome-page .CodeMirror {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
font-size: 13px;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
}
/* Split layout: editor left, preview right */
.welcome-editor-row {
display: flex;
gap: 16px;
margin-top: 0;
}
.welcome-editor-col, .welcome-preview-col {
flex: 1 1 50%;
min-width: 0;
}
.welcome-editor-col {
min-height: 480px;
}
.welcome-preview-col {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
background: var(--surface, #fff);
color: var(--text-primary, #111827);
padding: 16px;
font-size: 14px;
line-height: 1.6;
overflow: auto;
max-height: 600px;
}
.welcome-preview-col h4 {
color: var(--text-secondary, #6b7280); margin: 0 0 8px; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.welcome-preview-empty {
color: var(--text-secondary, #6b7280);
font-style: italic;
font-size: 13px;
}
.welcome-preview-error {
background: rgba(234, 88, 12, 0.15);
color: #b91c1c;
border: 1px solid rgba(234, 88, 12, 0.4);
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
white-space: pre-wrap;
}
@media (max-width: 1100px) {
.welcome-editor-row { flex-direction: column; }
}
/* Action row */
.welcome-actions {
padding: 14px 22px;
background: var(--border-light, #fafafa);
border-top: 1px solid var(--border, #e5e7eb);
display: flex; gap: 8px; justify-content: flex-end;
}
.welcome-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer; transition: all 0.15s;
}
.welcome-btn:hover { background: var(--border-light, #f9fafb); }
.welcome-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
.welcome-btn.primary:hover { filter: brightness(1.05); }
.welcome-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.welcome-btn.danger:hover { filter: brightness(1.05); }
.welcome-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Modal */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
display: none; align-items: center; justify-content: center; z-index: 1000;
padding: 16px;
}
.modal-backdrop.is-open { display: flex; }
.modal-card {
background: var(--surface, #fff); border-radius: 12px;
padding: 24px; width: 100%; max-width: 440px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
.modal-card h3 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
.modal-card p.sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.modal-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer;
}
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
/* Toast stack */
.toast-stack {
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
}
.toast {
background: #111827; color: #fff; padding: 10px 16px;
border-radius: 8px; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
opacity: 0; transform: translateY(8px); transition: opacity 0.2s, transform 0.2s;
pointer-events: auto; max-width: 380px;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { background: #047857; }
.toast.error { background: #b91c1c; }
</style>
<div class="welcome-page">
<div class="welcome-toolbar">
<div>
<h2 class="welcome-title">Setup Page Banner</h2>
<p class="welcome-sub">Shown above the bootstrap commands on <code>/setup</code>. Use it for org-specific notes: VPN requirements, support channel, data classification, platform prerequisites. Empty by default — no banner is shown when unset.</p>
</div>
<div id="status-chip">
{% if is_override %}
<span class="origin-chip origin-override"
title="Overridden by {{ updated_by }} on {{ updated_at.strftime('%Y-%m-%d %H:%M UTC') if updated_at else '—' }}">
Banner active
</span>
{% else %}
<span class="origin-chip origin-default">No banner</span>
{% endif %}
</div>
</div>
<div class="welcome-card">
<div class="welcome-card-body">
<p class="welcome-desc">
Author HTML or plain text with Jinja2 placeholders. The banner renders inside the <code>/setup</code> page —
HTML tags are allowed. Leave empty and click <strong>Remove banner</strong> to go back to no banner.
<code>{{ "{{ user }}" }}</code> may be <code>null</code> for anonymous visitors — guard with <code>{% if user %}</code>.
</p>
<details class="welcome-cheatsheet">
<summary>Available placeholders</summary>
<div class="code-block">
<span id="placeholder-text" class="code-body">{{ "{{ 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 }}" }}</span>
<button class="btn-copy" data-copy-target="placeholder-text">Copy</button>
</div>
</details>
<div class="welcome-editor-row">
<div class="welcome-editor-col">
<textarea id="content" name="content">{{ current }}</textarea>
</div>
<div class="welcome-preview-col">
<h4>Live preview</h4>
<div id="preview-content"></div>
<div id="preview-error" class="welcome-preview-error" hidden></div>
</div>
</div>
</div>
<div class="welcome-actions">
<button type="button" class="welcome-btn danger" id="reset-btn">Remove banner</button>
<button type="button" class="welcome-btn primary" id="save-btn">Save banner</button>
</div>
</div>
</div>
<!-- Remove confirmation modal -->
<div class="modal-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-modal-title">
<div class="modal-card">
<h3 id="reset-modal-title">Remove the banner?</h3>
<p class="sub"><code>/setup</code> will go back to showing no banner. This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" data-close-modal="reset-modal">Cancel</button>
<button class="modal-btn danger" id="reset-confirm-btn">Remove</button>
</div>
</div>
</div>
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<script>
const API = "/api/admin/setup-banner";
// ── CodeMirror editor (with graceful CDN fallback) ────────────────────
const ta = document.getElementById("content");
if (typeof CodeMirror === "undefined") {
// CDN unreachable or SRI mismatch — degrade to plain textarea + warn.
ta.style.display = "block";
ta.style.width = "100%";
ta.style.minHeight = "480px";
ta.style.fontFamily = "var(--font-mono)";
ta.style.fontSize = "13px";
ta.style.padding = "9px 12px";
ta.style.border = "1px solid var(--border)";
ta.style.borderRadius = "8px";
// Polyfill the `editor` interface so save/remove/preview still work.
window.editor = {
getValue: () => ta.value,
setValue: (v) => { ta.value = v; },
on: () => {},
setSize: () => {},
};
// toast is defined below; defer so the DOM is ready and toast stack exists.
setTimeout(() => toast("Code editor failed to load — using plain textarea. Check network/CSP.", "error"), 0);
} else {
// Normal path
window.editor = CodeMirror.fromTextArea(ta, {
mode: "jinja2",
lineNumbers: true,
lineWrapping: true,
theme: "material-darker",
indentUnit: 2,
tabSize: 2,
});
editor.setSize("100%", "calc(100vh - 320px)");
}
// ── Live preview (debounced) ──────────────────────────────────────────
let previewTimer = null;
function schedulePreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 500);
}
async function renderPreview() {
const content = editor.getValue();
const previewBox = document.getElementById("preview-content");
const previewErr = document.getElementById("preview-error");
if (!content.trim()) {
previewBox.innerHTML = '<span class="welcome-preview-empty">(no banner)</span>';
previewErr.hidden = true;
return;
}
try {
const r = await fetch(API + "/preview", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (r.ok) {
const j = await r.json();
previewBox.innerHTML = j.content;
previewErr.hidden = true;
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
previewBox.innerHTML = "";
previewErr.textContent = detail;
previewErr.hidden = false;
}
} catch (e) {
previewErr.textContent = "Network error: " + e.message;
previewErr.hidden = false;
}
}
editor.on("change", schedulePreview);
renderPreview(); // initial render on load
// ── Toast ─────────────────────────────────────────────────────────────
function toast(msg, kind) {
const el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
document.getElementById("toast-stack").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3500);
}
// ── Modal helpers ─────────────────────────────────────────────────────
function openModal(id) { document.getElementById(id).classList.add("is-open"); }
function closeModal(id) { document.getElementById(id).classList.remove("is-open"); }
document.querySelectorAll("[data-close-modal]").forEach(el =>
el.addEventListener("click", () => closeModal(el.dataset.closeModal)));
document.querySelectorAll(".modal-backdrop").forEach(el => {
el.addEventListener("click", e => { if (e.target === el) el.classList.remove("is-open"); });
});
document.addEventListener("keydown", e => {
if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.is-open").forEach(m => m.classList.remove("is-open"));
});
// ── Status chip ───────────────────────────────────────────────────────
function setStatusChip(data) {
const wrap = document.getElementById("status-chip");
if (data.content !== null && data.content !== undefined) {
const when = data.updated_at
? new Date(data.updated_at).toISOString().slice(0, 16).replace("T", " ") + " UTC"
: "—";
const who = data.updated_by || "—";
wrap.innerHTML = `<span class="origin-chip origin-override" title="Overridden by ${who} on ${when}">Banner active</span>`;
} else {
wrap.innerHTML = `<span class="origin-chip origin-default">No banner</span>`;
}
}
// ── Refresh status + editor content from API ─────────────────────────
async function refreshStatus() {
const r = await fetch(API, { credentials: "include" });
if (!r.ok) return;
const data = await r.json();
setStatusChip(data);
editor.setValue(data.content !== null && data.content !== undefined ? data.content : "");
renderPreview();
}
// ── Save ──────────────────────────────────────────────────────────────
document.getElementById("save-btn").addEventListener("click", async () => {
const btn = document.getElementById("save-btn");
const content = editor.getValue();
if (!content.trim()) {
toast("Nothing to save — editor is empty. Use Remove banner to clear.", "error");
return;
}
btn.disabled = true;
try {
const r = await fetch(API, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (r.ok) {
toast("Banner saved.", "success");
await refreshStatus();
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
toast("Save failed: " + detail, "error");
}
} catch (e) {
toast("Save failed: " + e.message, "error");
} finally {
btn.disabled = false;
}
});
// ── Remove (with confirm modal) ───────────────────────────────────────
document.getElementById("reset-btn").addEventListener("click", () => openModal("reset-modal"));
document.getElementById("reset-confirm-btn").addEventListener("click", async () => {
closeModal("reset-modal");
const btn = document.getElementById("reset-btn");
btn.disabled = true;
try {
const r = await fetch(API, { method: "DELETE", credentials: "include" });
if (r.ok) {
toast("Banner removed.", "success");
await refreshStatus();
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
toast("Remove failed: " + detail, "error");
}
} catch (e) {
toast("Remove failed: " + e.message, "error");
} finally {
btn.disabled = false;
}
});
// ── Copy widget ───────────────────────────────────────────────────────
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;left:-9999px;top:-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
return new Promise((resolve, reject) => {
try { document.execCommand("copy") ? resolve() : reject(); }
finally { document.body.removeChild(ta); }
});
}
function flashCopied(button) {
const original = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => { button.textContent = original; button.classList.remove("copied"); }, 1500);
}
document.querySelectorAll(".btn-copy[data-copy-target]").forEach(btn => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.getAttribute("data-copy-target"));
if (!target) return;
copyToClipboard(target.textContent.trim())
.then(() => flashCopied(btn))
.catch(() => {
const range = document.createRange();
range.selectNodeContents(target);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
});
});
</script>
{% endblock %}

View file

@ -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 @@
<main class="main">
{% if banner_html %}
<div class="setup-banner">{{ banner_html | safe }}</div>
{% endif %}
<!-- ═══════════════ HERO ═══════════════ -->
<section class="hero">
<div class="hero-eyebrow">Getting started</div>

View file

@ -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 `<p>` tags, `<strong>`, 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: #}
{{ "<strong>VPN required</strong>" | 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 `<script>`
tags, `<iframe>` blocks, `on*=` event handlers, and `javascript:`/`data:`
URI schemes are stripped from the rendered output before it reaches the
browser.
This is **not a full sandbox** — a determined admin can still author arbitrary
HTML with CSS tricks or external resource loads. The stripping is a safety net
against accidental inclusion of dangerous markup (copy-paste from an untrusted
source, etc.), not a substitute for trust in your admin users.
For a stricter posture, add a `Content-Security-Policy` header that disallows
inline scripts and restricts `connect-src`.
## Difference from the welcome template
| | Setup banner | Welcome template |
|---|---|---|
| Location | `/setup` page (partly public) | `CLAUDE.md` in analyst workspace |
| Format | HTML (rendered in browser) | Markdown (consumed by Claude Code) |
| Default | No banner | Ships a default at `config/claude_md_template.txt` |
| Context | `instance`, `server`, `user` (nullable), `now`, `today` | All of the above plus `tables`, `metrics`, `marketplaces`, `sync_interval`, `data_source` |
| RBAC filtering | None — same for all visitors | `marketplaces` filtered per user's group memberships |
See `docs/welcome-template.md` for the welcome-template reference.

View file

@ -418,8 +418,8 @@ CREATE TABLE IF NOT EXISTS welcome_template (
CONSTRAINT singleton CHECK (id = 1)
);
-- v22: customizable banner shown above setup commands on /setup page.
-- Singleton row (id=1). NULL content means "no banner".
-- v22: reserved (formerly setup_banner feature dropped, table kept for
-- forward compatibility with already-migrated instances).
CREATE TABLE IF NOT EXISTS setup_banner (
id INTEGER PRIMARY KEY DEFAULT 1,
content TEXT,

View file

@ -1,53 +0,0 @@
"""Repository for the per-instance setup-page banner override (singleton row)."""
from datetime import datetime, timezone
from typing import Any
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],
)

View file

@ -1,119 +0,0 @@
"""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
import re
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__)
# Patterns used by _sanitize_banner_html.
_RE_SCRIPT = re.compile(r"<\s*script[\s\S]*?(?:</\s*script\s*>|$)", re.IGNORECASE)
_RE_IFRAME = re.compile(r"<\s*iframe[\s\S]*?(?:</\s*iframe\s*>|$)", re.IGNORECASE)
_RE_ON_ATTR = re.compile(r'\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*)', re.IGNORECASE)
_RE_JS_URI = re.compile(
r'((?:href|src)\s*=\s*["\'])(?:javascript|data):[^"\']*(["\'])',
re.IGNORECASE,
)
def _sanitize_banner_html(html: str) -> str:
"""Strip the most dangerous markup patterns from rendered banner HTML.
Threat model: admins are trusted to author banner content, but mistakes
happen (copy-paste from untrusted sources, accidental script inclusion).
This is defense-in-depth, NOT a full XSS defense for that, render
markdown only or add a strict Content-Security-Policy. The whitelist of
bad patterns is intentionally narrow so legitimate admin HTML is not
mangled.
What is stripped:
- ``<script>...</script>`` blocks (case-insensitive, including unclosed).
- ``<iframe>...</iframe>`` 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 ""

View file

@ -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": "<p>VPN required before install.</p>"},
headers=admin,
)
assert r.status_code == 200
# GET shows new content
r = c.get("/api/admin/setup-banner", headers=admin)
assert r.json()["content"] == "<p>VPN required before install.</p>"
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": "<p>x</p>"}, 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": "<b>Hello {{ user.email }}</b>"},
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": "<p>x</p>"},
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"
)

View file

@ -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

View file

@ -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(
"<p>VPN: {{ server.hostname }}</p>", 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 "<p>" 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/<test>"
)
# 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 <script> blocks from the output."""
SetupBannerRepository(conn).set(
'<p>Hello</p><script>alert(1)</script>',
updated_by="admin@example.com",
)
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
assert "<script>" not in out
assert "alert" not in out
# Safe content preserved
assert "Hello" in out
def test_render_strips_event_handlers(conn):
"""render_setup_banner must strip on* event-handler attributes."""
SetupBannerRepository(conn).set(
'<button onclick="evil()">Click me</button>',
updated_by="admin@example.com",
)
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
assert "onclick" not in out
assert "evil" not in out
# Button text preserved
assert "Click me" in out
def test_render_strips_javascript_uri(conn):
"""render_setup_banner must strip javascript: URI schemes from href/src."""
SetupBannerRepository(conn).set(
'<a href="javascript:evil()">link</a>',
updated_by="admin@example.com",
)
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
assert "javascript:" not in out
assert "evil" not in out
# Link text preserved
assert "link" in out

View file

@ -1,49 +0,0 @@
"""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("<p>VPN required</p>", updated_by="admin@example.com")
row = repo.get()
assert row["content"] == "<p>VPN required</p>"
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("<p>Note</p>", 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"