mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-22 02:24:38 +02:00
Compare commits
2 commits
e8dc9f6063
...
d4c4750488
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c4750488 | ||
|
78b6fcf150 |
12 changed files with 1989 additions and 1066 deletions
|
|
@ -1,11 +1,10 @@
|
||||||
"""Pre-auth CSRF protection for login and setup/operator pages.
|
"""Pre-auth CSRF protection for login and setup pages.
|
||||||
|
|
||||||
These routes cannot use session-bound CSRF because no session exists yet.
|
These routes cannot use session-bound CSRF because no session exists yet.
|
||||||
Uses a simple cookie-based pattern with short-lived tokens.
|
Uses a simple cookie-based pattern with short-lived tokens.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
@ -34,6 +33,34 @@ def generate_pre_auth_csrf(secret_key: str) -> tuple[str, str]:
|
||||||
return plain_token, signed_token
|
return plain_token, signed_token
|
||||||
|
|
||||||
|
|
||||||
|
def reuse_or_generate_pre_auth_csrf(
|
||||||
|
request: Request,
|
||||||
|
secret_key: str,
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
"""Reuse an existing valid pre-auth CSRF token, or generate new.
|
||||||
|
|
||||||
|
Returns (plain_token, signed_token_for_cookie).
|
||||||
|
If signed_token_for_cookie is None, the existing cookie is
|
||||||
|
still valid and caller should not call set_pre_auth_csrf_cookie.
|
||||||
|
If non-None, caller MUST call set_pre_auth_csrf_cookie with
|
||||||
|
it to persist the new value.
|
||||||
|
"""
|
||||||
|
cookie_value = request.cookies.get(PRE_AUTH_CSRF_COOKIE)
|
||||||
|
if cookie_value:
|
||||||
|
serializer = _get_serializer(secret_key)
|
||||||
|
try:
|
||||||
|
plain_token = serializer.loads(
|
||||||
|
cookie_value,
|
||||||
|
max_age=PRE_AUTH_CSRF_MAX_AGE,
|
||||||
|
)
|
||||||
|
return plain_token, None # reuse existing
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
pass # fall through to generate
|
||||||
|
|
||||||
|
plain_token, signed_token = generate_pre_auth_csrf(secret_key)
|
||||||
|
return plain_token, signed_token
|
||||||
|
|
||||||
|
|
||||||
def set_pre_auth_csrf_cookie(response: Response, signed_token: str) -> None:
|
def set_pre_auth_csrf_cookie(response: Response, signed_token: str) -> None:
|
||||||
"""Set the pre-auth CSRF cookie on a response."""
|
"""Set the pre-auth CSRF cookie on a response."""
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,15 @@ SETUP_EXEMPT_PREFIXES = ("/static/", "/setup")
|
||||||
|
|
||||||
# Paths that don't require authentication
|
# Paths that don't require authentication
|
||||||
AUTH_EXEMPT_PATHS = {"/setup/operator", "/login", "/health"}
|
AUTH_EXEMPT_PATHS = {"/setup/operator", "/login", "/health"}
|
||||||
AUTH_EXEMPT_PREFIXES = ("/static/",)
|
AUTH_EXEMPT_PREFIXES = ("/static/", "/setup/")
|
||||||
|
|
||||||
|
# Browser-noise paths that trigger CSRF race conditions
|
||||||
|
BROWSER_NOISE_PATHS = {
|
||||||
|
"/favicon.ico",
|
||||||
|
"/apple-touch-icon.png",
|
||||||
|
"/apple-touch-icon-precomposed.png",
|
||||||
|
"/robots.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_exempt(path: str, exempt_paths: set, exempt_prefixes: tuple) -> bool:
|
def _is_exempt(path: str, exempt_paths: set, exempt_prefixes: tuple) -> bool:
|
||||||
|
|
@ -29,33 +37,14 @@ def _is_exempt(path: str, exempt_paths: set, exempt_prefixes: tuple) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _get_wizard_redirect_step(conn) -> str:
|
def _get_wizard_redirect_from_cookie(request: Request, csrf_secret: str) -> str:
|
||||||
"""Determine which wizard step to redirect to based on DB state."""
|
"""Determine wizard redirect step from cookie state."""
|
||||||
# Check if any operators exist
|
from central.gui.wizard import get_wizard_state, get_step_route
|
||||||
op_count = await conn.fetchval("SELECT COUNT(*) FROM config.operators")
|
|
||||||
if op_count == 0:
|
state = get_wizard_state(request, csrf_secret)
|
||||||
|
if state is None:
|
||||||
return "/setup/operator"
|
return "/setup/operator"
|
||||||
|
return get_step_route(state.wizard_step)
|
||||||
# Check if system settings have been configured (map_tile_url not default)
|
|
||||||
sys_row = await conn.fetchrow(
|
|
||||||
"SELECT map_tile_url FROM config.system WHERE id = true"
|
|
||||||
)
|
|
||||||
default_tile = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
if sys_row is None or sys_row["map_tile_url"] == default_tile:
|
|
||||||
return "/setup/system"
|
|
||||||
|
|
||||||
# Keys step is optional, so check adapters have been reviewed
|
|
||||||
# We consider adapters reviewed if any adapter has a non-null updated_at
|
|
||||||
# (meaning it was explicitly saved during setup)
|
|
||||||
adapters_touched = await conn.fetchval(
|
|
||||||
"SELECT COUNT(*) FROM config.adapters WHERE updated_at IS NOT NULL"
|
|
||||||
)
|
|
||||||
if adapters_touched == 0:
|
|
||||||
# Go to keys first, then adapters
|
|
||||||
return "/setup/keys"
|
|
||||||
|
|
||||||
# All steps done, go to finish
|
|
||||||
return "/setup/finish"
|
|
||||||
|
|
||||||
|
|
||||||
class SetupGateMiddleware(BaseHTTPMiddleware):
|
class SetupGateMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
@ -64,6 +53,10 @@ class SetupGateMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
|
# Short-circuit browser-noise requests that cause CSRF races
|
||||||
|
if path in BROWSER_NOISE_PATHS:
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
# Check setup status from database
|
# Check setup status from database
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
if pool is None:
|
if pool is None:
|
||||||
|
|
@ -85,13 +78,16 @@ class SetupGateMiddleware(BaseHTTPMiddleware):
|
||||||
if not setup_complete:
|
if not setup_complete:
|
||||||
# Setup not complete - only allow setup paths and static/health
|
# Setup not complete - only allow setup paths and static/health
|
||||||
if path.startswith("/setup"):
|
if path.startswith("/setup"):
|
||||||
# Allow all /setup/* paths (handler will enforce auth)
|
# Allow all /setup/* paths
|
||||||
# But /setup with no subpath should redirect to appropriate step
|
# But /setup with no subpath should redirect to appropriate step
|
||||||
if path == "/setup" or path == "/setup/":
|
if path == "/setup" or path == "/setup/":
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
from central.bootstrap_config import get_settings
|
||||||
redirect_step = await _get_wizard_redirect_step(conn)
|
settings = get_settings()
|
||||||
return RedirectResponse(url=redirect_step, status_code=302)
|
redirect_step = _get_wizard_redirect_from_cookie(
|
||||||
|
request, settings.csrf_secret
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=redirect_step, status_code=302)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to determine wizard step", exc_info=True)
|
logger.warning("Failed to determine wizard step", exc_info=True)
|
||||||
return RedirectResponse(url="/setup/operator", status_code=302)
|
return RedirectResponse(url="/setup/operator", status_code=302)
|
||||||
|
|
@ -118,6 +114,11 @@ class SessionMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
|
# Short-circuit browser-noise requests (already handled by SetupGateMiddleware,
|
||||||
|
# but this protects if middleware order changes)
|
||||||
|
if path in BROWSER_NOISE_PATHS:
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
# Initialize state
|
# Initialize state
|
||||||
request.state.operator = None
|
request.state.operator = None
|
||||||
request.state.csrf_token = None
|
request.state.csrf_token = None
|
||||||
|
|
@ -139,7 +140,7 @@ class SessionMiddleware(BaseHTTPMiddleware):
|
||||||
request.state.operator = None
|
request.state.operator = None
|
||||||
request.state.csrf_token = None
|
request.state.csrf_token = None
|
||||||
|
|
||||||
# Check if auth is required
|
# Check if auth is required - setup paths are exempt during wizard
|
||||||
if not _is_exempt(path, AUTH_EXEMPT_PATHS, AUTH_EXEMPT_PREFIXES):
|
if not _is_exempt(path, AUTH_EXEMPT_PATHS, AUTH_EXEMPT_PREFIXES):
|
||||||
if request.state.operator is None:
|
if request.state.operator is None:
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
50
src/central/gui/templates/_events_rows.html
Normal file
50
src/central/gui/templates/_events_rows.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{% if filter_error %}
|
||||||
|
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<strong>Filter Error:</strong> {{ filter_error }}
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if events %}
|
||||||
|
<table class="events-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Adapter</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Geometry</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in events %}
|
||||||
|
<tr data-row-idx="{{ loop.index0 }}"
|
||||||
|
{% if event.geometry %}data-geometry="{{ event.geometry | tojson | e }}"{% endif %}>
|
||||||
|
<td>{{ event.time }}</td>
|
||||||
|
<td>{{ event.adapter }}</td>
|
||||||
|
<td>{{ event.category }}</td>
|
||||||
|
<td>{{ event.geometry_summary }}</td>
|
||||||
|
<td>{{ event.subject or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pagination-info">
|
||||||
|
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
|
||||||
|
{% if next_cursor %}
|
||||||
|
<a href="/events?cursor={{ next_cursor }}{% if filter_values.adapter %}&adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&region_north={{ filter_values.region_north }}&region_south={{ filter_values.region_south }}&region_east={{ filter_values.region_east }}&region_west={{ filter_values.region_west }}{% endif %}&limit={{ filter_values.limit }}"
|
||||||
|
role="button"
|
||||||
|
hx-get="/events/rows?cursor={{ next_cursor }}{% if filter_values.adapter %}&adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&region_north={{ filter_values.region_north }}&region_south={{ filter_values.region_south }}&region_east={{ filter_values.region_east }}&region_west={{ filter_values.region_west }}{% endif %}&limit={{ filter_values.limit }}"
|
||||||
|
hx-target="#events-rows"
|
||||||
|
hx-push-url="true">
|
||||||
|
Next →
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span><em>End of results</em></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<article>
|
||||||
|
<p>No events match the filters.</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" integrity="sha512-gc3xjCmIy673V6MyOAZhIW93xhM9ei1I+gLbmFjUHIjocENRsLX/QUE1htk5q1XV2D/iie/VQ8DXI6Uj8GB1Og==" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" integrity="sha512-gc3xjCmIy673V6MyOAZhIW93xhM9ei1I+gLbmFjUHIjocENRsLX/QUE1htk5q1XV2D/iie/VQ8DXI6Vu8bexvQ==" crossorigin="anonymous">
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js" integrity="sha512-ozq8xQKq6urvuU6jNgkfqAmT7jKN2XumbrX1JiB3TnF7tI48DPI4Ber9dLJ0ikXiRg9G9Vl2jXwqjZ5LDGQ3g==" crossorigin="anonymous"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js" integrity="sha512-ozq8xQKq6urvuU6jNgkfqAmT7jKN2XumbrX1JiB3TnF7tI48DPI4Gy1GXKD/V3EExgAs1V+pRO7vwtS1LHg0Gw==" crossorigin="anonymous"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
{% if operator %}
|
{% if operator %}
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/adapters">Adapters</a></li>
|
<li><a href="/adapters">Adapters</a></li>
|
||||||
|
<li><a href="/events">Events</a></li>
|
||||||
<li><a href="/streams">Streams</a></li>
|
<li><a href="/streams">Streams</a></li>
|
||||||
<li><a href="/api-keys">API Keys</a></li>
|
<li><a href="/api-keys">API Keys</a></li>
|
||||||
<li>{{ operator.username }}</li>
|
<li>{{ operator.username }}</li>
|
||||||
|
|
|
||||||
378
src/central/gui/templates/events_list.html
Normal file
378
src/central/gui/templates/events_list.html
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Events - Central{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||||
|
<style>
|
||||||
|
#events-map {
|
||||||
|
height: 400px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
}
|
||||||
|
.events-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.events-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.events-table tr:hover {
|
||||||
|
background-color: var(--pico-primary-focus);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.events-table tr.highlighted {
|
||||||
|
background-color: var(--pico-primary-background);
|
||||||
|
}
|
||||||
|
.filter-form .grid {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.filter-form label {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.filter-form input, .filter-form select {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.region-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.region-inputs input {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.region-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.pagination-info {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Events</h1>
|
||||||
|
|
||||||
|
{% if filter_error %}
|
||||||
|
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<strong>Filter Error:</strong> {{ filter_error }}
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>Filters</summary>
|
||||||
|
<form class="filter-form" action="/events" method="get"
|
||||||
|
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="adapter">Adapter</label>
|
||||||
|
<select id="adapter" name="adapter">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="nws" {% if filter_values.adapter == 'nws' %}selected{% endif %}>nws</option>
|
||||||
|
<option value="firms" {% if filter_values.adapter == 'firms' %}selected{% endif %}>firms</option>
|
||||||
|
<option value="usgs_quake" {% if filter_values.adapter == 'usgs_quake' %}selected{% endif %}>usgs_quake</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="category">Category</label>
|
||||||
|
<input type="text" id="category" name="category" placeholder="Exact match"
|
||||||
|
value="{{ filter_values.category }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="since">From</label>
|
||||||
|
<input type="datetime-local" id="since" name="since"
|
||||||
|
value="{{ filter_values.since }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="until">To</label>
|
||||||
|
<input type="datetime-local" id="until" name="until"
|
||||||
|
value="{{ filter_values.until }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>Region (draw on map or enter coordinates)</label>
|
||||||
|
<div class="region-controls">
|
||||||
|
<button type="button" id="clear-region-btn" class="outline secondary" style="width: auto; padding: 0.25rem 0.75rem;">
|
||||||
|
Clear Region
|
||||||
|
</button>
|
||||||
|
<small>Draw a rectangle on the map to filter by region</small>
|
||||||
|
</div>
|
||||||
|
<div class="region-inputs">
|
||||||
|
<div>
|
||||||
|
<label for="region_north">N</label>
|
||||||
|
<input type="number" id="region_north" name="region_north" step="0.0001" min="-90" max="90" readonly
|
||||||
|
value="{{ filter_values.region_north }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="region_south">S</label>
|
||||||
|
<input type="number" id="region_south" name="region_south" step="0.0001" min="-90" max="90" readonly
|
||||||
|
value="{{ filter_values.region_south }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="region_east">E</label>
|
||||||
|
<input type="number" id="region_east" name="region_east" step="0.0001" min="-180" max="180" readonly
|
||||||
|
value="{{ filter_values.region_east }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="region_west">W</label>
|
||||||
|
<input type="number" id="region_west" name="region_west" step="0.0001" min="-180" max="180" readonly
|
||||||
|
value="{{ filter_values.region_west }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="limit" value="{{ filter_values.limit }}">
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
<a href="/events" role="button" class="outline">Clear Filters</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div id="events-map"></div>
|
||||||
|
|
||||||
|
<div id="events-rows">
|
||||||
|
{% include "_events_rows.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const tileUrl = {{ tile_url | tojson }};
|
||||||
|
const tileAttr = {{ tile_attribution | tojson }};
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
const map = L.map('events-map').setView([39, -98], 4);
|
||||||
|
|
||||||
|
L.tileLayer(tileUrl, {
|
||||||
|
attribution: tileAttr,
|
||||||
|
maxZoom: 18
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Layer groups for event geometries
|
||||||
|
const eventLayers = {};
|
||||||
|
let highlightedRow = null;
|
||||||
|
let highlightedLayer = null;
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const defaultStyle = {
|
||||||
|
color: '#3388ff',
|
||||||
|
weight: 2,
|
||||||
|
fillOpacity: 0.2
|
||||||
|
};
|
||||||
|
const highlightStyle = {
|
||||||
|
color: '#ff3333',
|
||||||
|
weight: 4,
|
||||||
|
fillOpacity: 0.4
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region filter rectangle
|
||||||
|
let filterRect = null;
|
||||||
|
const drawnItems = new L.FeatureGroup();
|
||||||
|
map.addLayer(drawnItems);
|
||||||
|
|
||||||
|
// Draw control for region filter
|
||||||
|
const drawControl = new L.Control.Draw({
|
||||||
|
draw: {
|
||||||
|
rectangle: { shapeOptions: { color: '#ff7800', weight: 2, fillOpacity: 0.1 } },
|
||||||
|
polyline: false,
|
||||||
|
polygon: false,
|
||||||
|
circle: false,
|
||||||
|
marker: false,
|
||||||
|
circlemarker: false
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
featureGroup: drawnItems,
|
||||||
|
edit: false,
|
||||||
|
remove: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.addControl(drawControl);
|
||||||
|
|
||||||
|
// Region input elements
|
||||||
|
const northInput = document.getElementById('region_north');
|
||||||
|
const southInput = document.getElementById('region_south');
|
||||||
|
const eastInput = document.getElementById('region_east');
|
||||||
|
const westInput = document.getElementById('region_west');
|
||||||
|
|
||||||
|
// Update inputs from rectangle
|
||||||
|
function updateRegionInputs(bounds) {
|
||||||
|
northInput.value = bounds.getNorth().toFixed(4);
|
||||||
|
southInput.value = bounds.getSouth().toFixed(4);
|
||||||
|
eastInput.value = bounds.getEast().toFixed(4);
|
||||||
|
westInput.value = bounds.getWest().toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new rectangle drawn
|
||||||
|
map.on(L.Draw.Event.CREATED, function(e) {
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
filterRect = e.layer;
|
||||||
|
filterRect.setStyle({ color: '#ff7800', weight: 2, fillOpacity: 0.1 });
|
||||||
|
drawnItems.addLayer(filterRect);
|
||||||
|
updateRegionInputs(filterRect.getBounds());
|
||||||
|
// Auto-submit the form via HTMX
|
||||||
|
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear region button
|
||||||
|
document.getElementById('clear-region-btn').addEventListener('click', function() {
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
filterRect = null;
|
||||||
|
northInput.value = '';
|
||||||
|
southInput.value = '';
|
||||||
|
eastInput.value = '';
|
||||||
|
westInput.value = '';
|
||||||
|
// Auto-submit to refresh
|
||||||
|
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// If region values exist, show the filter rectangle
|
||||||
|
if (northInput.value && southInput.value && eastInput.value && westInput.value) {
|
||||||
|
const bounds = L.latLngBounds(
|
||||||
|
L.latLng(parseFloat(southInput.value), parseFloat(westInput.value)),
|
||||||
|
L.latLng(parseFloat(northInput.value), parseFloat(eastInput.value))
|
||||||
|
);
|
||||||
|
filterRect = L.rectangle(bounds, { color: '#ff7800', weight: 2, fillOpacity: 0.1 });
|
||||||
|
drawnItems.addLayer(filterRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to add event geometries to map
|
||||||
|
function loadEventGeometries() {
|
||||||
|
// Clear existing event layers
|
||||||
|
Object.values(eventLayers).forEach(layer => map.removeLayer(layer));
|
||||||
|
Object.keys(eventLayers).forEach(key => delete eventLayers[key]);
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('#events-rows tr[data-geometry]');
|
||||||
|
const bounds = L.latLngBounds();
|
||||||
|
let hasGeometries = false;
|
||||||
|
|
||||||
|
rows.forEach((row, idx) => {
|
||||||
|
const geomStr = row.dataset.geometry;
|
||||||
|
if (!geomStr || geomStr === '') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geom = JSON.parse(geomStr);
|
||||||
|
if (!geom) return;
|
||||||
|
|
||||||
|
const layer = L.geoJSON(geom, {
|
||||||
|
style: defaultStyle,
|
||||||
|
pointToLayer: function(feature, latlng) {
|
||||||
|
return L.circleMarker(latlng, {
|
||||||
|
radius: 8,
|
||||||
|
...defaultStyle
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.addTo(map);
|
||||||
|
eventLayers[idx] = layer;
|
||||||
|
|
||||||
|
// Extend bounds
|
||||||
|
try {
|
||||||
|
bounds.extend(layer.getBounds());
|
||||||
|
hasGeometries = true;
|
||||||
|
} catch (e) {
|
||||||
|
// Point geometries might not have getBounds
|
||||||
|
if (geom.type === 'Point' && geom.coordinates) {
|
||||||
|
bounds.extend(L.latLng(geom.coordinates[1], geom.coordinates[0]));
|
||||||
|
hasGeometries = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on geometry highlights row
|
||||||
|
layer.on('click', function() {
|
||||||
|
highlightRow(row, layer);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing geometry:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit map to all geometries
|
||||||
|
if (hasGeometries) {
|
||||||
|
map.fitBounds(bounds.pad(0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight a row and its geometry
|
||||||
|
function highlightRow(row, layer) {
|
||||||
|
// Reset previous highlight
|
||||||
|
if (highlightedRow) {
|
||||||
|
highlightedRow.classList.remove('highlighted');
|
||||||
|
}
|
||||||
|
if (highlightedLayer) {
|
||||||
|
highlightedLayer.setStyle(defaultStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new highlight
|
||||||
|
row.classList.add('highlighted');
|
||||||
|
highlightedRow = row;
|
||||||
|
|
||||||
|
if (layer) {
|
||||||
|
layer.setStyle(highlightStyle);
|
||||||
|
highlightedLayer = layer;
|
||||||
|
// Pan to geometry
|
||||||
|
try {
|
||||||
|
map.fitBounds(layer.getBounds().pad(0.2));
|
||||||
|
} catch (e) {
|
||||||
|
// For points
|
||||||
|
const geom = JSON.parse(row.dataset.geometry);
|
||||||
|
if (geom && geom.type === 'Point' && geom.coordinates) {
|
||||||
|
map.setView([geom.coordinates[1], geom.coordinates[0]], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row hover/click handlers
|
||||||
|
function attachRowHandlers() {
|
||||||
|
const rows = document.querySelectorAll('#events-rows tr[data-row-idx]');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const idx = parseInt(row.dataset.rowIdx);
|
||||||
|
row.addEventListener('click', function() {
|
||||||
|
const layer = eventLayers[idx];
|
||||||
|
highlightRow(row, layer);
|
||||||
|
});
|
||||||
|
row.addEventListener('mouseenter', function() {
|
||||||
|
const layer = eventLayers[idx];
|
||||||
|
if (layer && layer !== highlightedLayer) {
|
||||||
|
layer.setStyle({ ...defaultStyle, weight: 3 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.addEventListener('mouseleave', function() {
|
||||||
|
const layer = eventLayers[idx];
|
||||||
|
if (layer && layer !== highlightedLayer) {
|
||||||
|
layer.setStyle(defaultStyle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadEventGeometries();
|
||||||
|
attachRowHandlers();
|
||||||
|
|
||||||
|
// Re-attach handlers after HTMX swap
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
if (evt.detail.target.id === 'events-rows') {
|
||||||
|
loadEventGeometries();
|
||||||
|
attachRowHandlers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix map rendering after container shows
|
||||||
|
setTimeout(function() { map.invalidateSize(); }, 100);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" integrity="sha512-gc3xjCmIy673V6MyOAZhIW93xhM9ei1I+gLbmFjUHIjocENRsLX/QUE1htk5q1XV2D/iie/VQ8DXI6Uj8GB1Og==" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" integrity="sha512-gc3xjCmIy673V6MyOAZhIW93xhM9ei1I+gLbmFjUHIjocENRsLX/QUE1htk5q1XV2D/iie/VQ8DXI6Vu8bexvQ==" crossorigin="anonymous">
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js" integrity="sha512-ozq8xQKq6urvuU6jNgkfqAmT7jKN2XumbrX1JiB3TnF7tI48DPI4Ber9dLJ0ikXiRg9G9Vl2jXwqjZ5LDGQ3g==" crossorigin="anonymous"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js" integrity="sha512-ozq8xQKq6urvuU6jNgkfqAmT7jKN2XumbrX1JiB3TnF7tI48DPI4Gy1GXKD/V3EExgAs1V+pRO7vwtS1LHg0Gw==" crossorigin="anonymous"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,6 @@
|
||||||
{% include "_wizard_header.html" %}
|
{% include "_wizard_header.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% if existing_operator %}
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<h1>Operator Already Configured</h1>
|
|
||||||
</header>
|
|
||||||
<p>The operator account <strong>{{ existing_operator.username }}</strong> has been created.</p>
|
|
||||||
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
|
||||||
<a href="/setup/system" role="button">Next →</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% else %}
|
|
||||||
<article>
|
<article>
|
||||||
<header>
|
<header>
|
||||||
<h1>Create Operator Account</h1>
|
<h1>Create Operator Account</h1>
|
||||||
|
|
@ -53,5 +42,4 @@
|
||||||
<button type="submit">Create Operator →</button>
|
<button type="submit">Create Operator →</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
131
src/central/gui/wizard.py
Normal file
131
src/central/gui/wizard.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""Wizard state management for deferred-commit setup flow.
|
||||||
|
|
||||||
|
The wizard collects configuration across 5 steps and commits everything
|
||||||
|
atomically at the final step. State is carried in a signed cookie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
|
# 1 hour max age for wizard cookie
|
||||||
|
WIZARD_MAX_AGE = 3600
|
||||||
|
WIZARD_COOKIE = "central_wizard"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WizardOperator:
|
||||||
|
"""Operator data collected in step 1."""
|
||||||
|
username: str
|
||||||
|
password_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WizardSystem:
|
||||||
|
"""System settings collected in step 2."""
|
||||||
|
map_tile_url: str
|
||||||
|
map_attribution: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WizardApiKey:
|
||||||
|
"""API key collected in step 3."""
|
||||||
|
alias: str
|
||||||
|
encrypted_value_b64: str # base64-encoded encrypted value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WizardAdapter:
|
||||||
|
"""Adapter config collected in step 4."""
|
||||||
|
enabled: bool
|
||||||
|
cadence_s: int
|
||||||
|
settings: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WizardState:
|
||||||
|
"""Complete wizard state carried across all steps."""
|
||||||
|
wizard_step: int = 1
|
||||||
|
operator: dict | None = None
|
||||||
|
system: dict | None = None
|
||||||
|
api_keys: list[dict] = field(default_factory=list)
|
||||||
|
adapters: dict[str, dict] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"wizard_step": self.wizard_step,
|
||||||
|
"operator": self.operator,
|
||||||
|
"system": self.system,
|
||||||
|
"api_keys": self.api_keys,
|
||||||
|
"adapters": self.adapters,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "WizardState":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
return cls(
|
||||||
|
wizard_step=data.get("wizard_step", 1),
|
||||||
|
operator=data.get("operator"),
|
||||||
|
system=data.get("system"),
|
||||||
|
api_keys=data.get("api_keys", []),
|
||||||
|
adapters=data.get("adapters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_wizard_serializer(secret_key: str) -> URLSafeTimedSerializer:
|
||||||
|
"""Get a timed serializer for wizard state."""
|
||||||
|
return URLSafeTimedSerializer(secret_key, salt="wizard-state")
|
||||||
|
|
||||||
|
|
||||||
|
def get_wizard_state(request: Request, secret_key: str) -> WizardState | None:
|
||||||
|
"""Decode wizard state from cookie.
|
||||||
|
|
||||||
|
Returns WizardState if valid, None if missing/invalid/expired.
|
||||||
|
"""
|
||||||
|
cookie_value = request.cookies.get(WIZARD_COOKIE)
|
||||||
|
if not cookie_value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
serializer = _get_wizard_serializer(secret_key)
|
||||||
|
try:
|
||||||
|
data = serializer.loads(cookie_value, max_age=WIZARD_MAX_AGE)
|
||||||
|
return WizardState.from_dict(data)
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_wizard_cookie(response: Response, state: WizardState, secret_key: str) -> None:
|
||||||
|
"""Set the wizard state cookie on a response."""
|
||||||
|
serializer = _get_wizard_serializer(secret_key)
|
||||||
|
signed_value = serializer.dumps(state.to_dict())
|
||||||
|
response.set_cookie(
|
||||||
|
WIZARD_COOKIE,
|
||||||
|
signed_value,
|
||||||
|
max_age=WIZARD_MAX_AGE,
|
||||||
|
path="/setup",
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_wizard_cookie(response: Response) -> None:
|
||||||
|
"""Remove the wizard state cookie."""
|
||||||
|
response.delete_cookie(WIZARD_COOKIE, path="/setup")
|
||||||
|
|
||||||
|
|
||||||
|
def get_step_route(step: int) -> str:
|
||||||
|
"""Get the route for a wizard step number."""
|
||||||
|
routes = {
|
||||||
|
1: "/setup/operator",
|
||||||
|
2: "/setup/system",
|
||||||
|
3: "/setup/keys",
|
||||||
|
4: "/setup/adapters",
|
||||||
|
5: "/setup/finish",
|
||||||
|
}
|
||||||
|
return routes.get(step, "/setup/operator")
|
||||||
460
tests/test_events_feed_frontend.py
Normal file
460
tests/test_events_feed_frontend.py
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
"""Tests for events feed frontend routes."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from central.gui.routes import events_list, events_rows
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventsFeedFrontendUnauthenticated:
|
||||||
|
"""Test events feed frontend without authentication."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_unauthenticated_redirects(self):
|
||||||
|
"""GET /events without auth redirects to /login."""
|
||||||
|
# This test verifies the session middleware behavior
|
||||||
|
# In practice, the middleware redirects before the route is called
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = None
|
||||||
|
# The middleware would redirect, verified via integration tests
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventsFeedFrontendAuthenticated:
|
||||||
|
"""Test events feed frontend with authentication."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_no_filters_returns_html(self):
|
||||||
|
"""GET /events authenticated, no filters returns HTML with events."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf_token"
|
||||||
|
mock_request.query_params = {}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": f"event_{i}",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Weather Alert",
|
||||||
|
"subject": f"Test Alert {i}",
|
||||||
|
"geometry": '{"type": "Point", "coordinates": [-122.4, 37.8]}' if i % 2 == 0 else None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
}
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
mock_conn.fetchrow.return_value = {
|
||||||
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OpenStreetMap",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_list(mock_request)
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
||||||
|
assert "events" in context
|
||||||
|
assert context["filter_error"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_adapter_filter(self):
|
||||||
|
"""GET /events?adapter=nws returns only nws events."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf_token"
|
||||||
|
mock_request.query_params = {"adapter": "nws"}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": "nws_event_1",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Alert",
|
||||||
|
"subject": "NWS Alert",
|
||||||
|
"geometry": None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
mock_conn.fetchrow.return_value = {
|
||||||
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OpenStreetMap",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_list(mock_request)
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
|
assert context["filter_values"]["adapter"] == "nws"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_since_until_filter(self):
|
||||||
|
"""GET /events?since=...&until=... filters by time window."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf_token"
|
||||||
|
mock_request.query_params = {
|
||||||
|
"since": "2026-05-17T00:00:00",
|
||||||
|
"until": "2026-05-17T12:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": "in_range",
|
||||||
|
"time": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Alert",
|
||||||
|
"subject": "In Range",
|
||||||
|
"geometry": None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
mock_conn.fetchrow.return_value = {
|
||||||
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OpenStreetMap",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_list(mock_request)
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_region_filter(self):
|
||||||
|
"""GET /events with full region bbox filters by location."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf_token"
|
||||||
|
mock_request.query_params = {
|
||||||
|
"region_north": "49.5",
|
||||||
|
"region_south": "31",
|
||||||
|
"region_east": "-102",
|
||||||
|
"region_west": "-124.5",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": "in_bbox",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Alert",
|
||||||
|
"subject": "In BBox",
|
||||||
|
"geometry": '{"type": "Point", "coordinates": [-120, 40]}',
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
mock_conn.fetchrow.return_value = {
|
||||||
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OpenStreetMap",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_list(mock_request)
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_partial_region_shows_error_banner(self):
|
||||||
|
"""GET /events with partial region shows error banner, not 400."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf_token"
|
||||||
|
mock_request.query_params = {"region_north": "49"}
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetchrow.return_value = {
|
||||||
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OpenStreetMap",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_list(mock_request)
|
||||||
|
|
||||||
|
# Should be 200, not 400
|
||||||
|
assert result.status_code == 200
|
||||||
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
|
assert context["filter_error"] is not None
|
||||||
|
assert "region" in context["filter_error"].lower()
|
||||||
|
# Events should be empty due to validation error
|
||||||
|
assert context["events"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_with_limit_shows_next_button(self):
|
||||||
|
"""GET /events?limit=5 shows Next button when more events exist."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.state.csrf_token = "test_csrf_token"
|
||||||
|
mock_request.query_params = {"limit": "5"}
|
||||||
|
|
||||||
|
# Return 6 events (limit+1) to trigger pagination
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": f"event_{i}",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Alert",
|
||||||
|
"subject": f"Event {i}",
|
||||||
|
"geometry": None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
}
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
mock_conn.fetchrow.return_value = {
|
||||||
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OpenStreetMap",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_list(mock_request)
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
|
assert context["next_cursor"] is not None
|
||||||
|
assert len(context["events"]) == 5 # Should be trimmed to limit
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventsRowsFragment:
|
||||||
|
"""Test /events/rows HTMX fragment."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_rows_returns_fragment(self):
|
||||||
|
"""GET /events/rows returns table fragment, not full page."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.query_params = {"limit": "5"}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": "event_1",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Alert",
|
||||||
|
"subject": "Event 1",
|
||||||
|
"geometry": None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_rows(mock_request)
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
# Verify it uses the fragment template
|
||||||
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
|
assert call_args.kwargs.get("name") == "_events_rows.html"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeometrySummary:
|
||||||
|
"""Test geometry summary function."""
|
||||||
|
|
||||||
|
def test_geometry_summary_polygon(self):
|
||||||
|
"""Polygon geometry shows point count."""
|
||||||
|
from central.gui.routes import _geometry_summary
|
||||||
|
|
||||||
|
geom = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]
|
||||||
|
}
|
||||||
|
summary = _geometry_summary(geom)
|
||||||
|
assert "Polygon" in summary
|
||||||
|
assert "5 pts" in summary
|
||||||
|
|
||||||
|
def test_geometry_summary_point(self):
|
||||||
|
"""Point geometry shows 'Point'."""
|
||||||
|
from central.gui.routes import _geometry_summary
|
||||||
|
|
||||||
|
geom = {"type": "Point", "coordinates": [-122.4, 37.8]}
|
||||||
|
summary = _geometry_summary(geom)
|
||||||
|
assert summary == "Point"
|
||||||
|
|
||||||
|
def test_geometry_summary_linestring(self):
|
||||||
|
"""LineString geometry shows point count."""
|
||||||
|
from central.gui.routes import _geometry_summary
|
||||||
|
|
||||||
|
geom = {
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": [[-122, 37], [-121, 38], [-120, 39]]
|
||||||
|
}
|
||||||
|
summary = _geometry_summary(geom)
|
||||||
|
assert "Line" in summary
|
||||||
|
assert "3 pts" in summary
|
||||||
|
|
||||||
|
def test_geometry_summary_none(self):
|
||||||
|
"""None geometry shows 'None'."""
|
||||||
|
from central.gui.routes import _geometry_summary
|
||||||
|
|
||||||
|
summary = _geometry_summary(None)
|
||||||
|
assert summary == "None"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataGeometryAttribute:
|
||||||
|
"""Test that rows have valid geometry data attributes."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_with_geometry_has_valid_json(self):
|
||||||
|
"""Events with geometry have parseable JSON in data-geometry."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.query_params = {}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": "geom_event",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Alert",
|
||||||
|
"subject": "With Geometry",
|
||||||
|
"geometry": '{"type": "Polygon", "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]}',
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_rows(mock_request)
|
||||||
|
|
||||||
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
|
event = context["events"][0]
|
||||||
|
# Geometry should be parsed dict, not string
|
||||||
|
assert isinstance(event["geometry"], dict)
|
||||||
|
assert event["geometry"]["type"] == "Polygon"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_without_geometry_has_none(self):
|
||||||
|
"""Events without geometry have None for geometry field."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.query_params = {}
|
||||||
|
|
||||||
|
mock_events = [
|
||||||
|
{
|
||||||
|
"id": "no_geom_event",
|
||||||
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "nws",
|
||||||
|
"category": "Alert",
|
||||||
|
"subject": "No Geometry",
|
||||||
|
"geometry": None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = mock_events
|
||||||
|
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_templates = MagicMock()
|
||||||
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
||||||
|
|
||||||
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
result = await events_rows(mock_request)
|
||||||
|
|
||||||
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
|
event = context["events"][0]
|
||||||
|
assert event["geometry"] is None
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Tests for the first-run setup wizard."""
|
"""Tests for the first-run setup wizard with deferred-commit pattern."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
@ -11,60 +11,38 @@ from central.gui.routes import (
|
||||||
setup_system_submit,
|
setup_system_submit,
|
||||||
setup_keys_form,
|
setup_keys_form,
|
||||||
setup_keys_submit,
|
setup_keys_submit,
|
||||||
setup_adapters_form,
|
|
||||||
setup_adapters_submit,
|
|
||||||
setup_finish_form,
|
setup_finish_form,
|
||||||
setup_finish_submit,
|
setup_finish_submit,
|
||||||
)
|
)
|
||||||
from central.gui.middleware import SetupGateMiddleware, _get_wizard_redirect_step
|
from central.gui.middleware import SetupGateMiddleware
|
||||||
|
from central.gui.wizard import WizardState, get_wizard_state, set_wizard_cookie
|
||||||
|
|
||||||
|
|
||||||
class TestWizardStepRedirect:
|
class TestWizardStepRedirect:
|
||||||
"""Test wizard step redirect logic."""
|
"""Test wizard step redirect logic based on cookie state."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_no_cookie_redirects_to_operator(self):
|
||||||
async def test_no_operators_redirects_to_operator(self):
|
"""When no wizard cookie exists, redirect to /setup/operator."""
|
||||||
"""When no operators exist, redirect to /setup/operator."""
|
from central.gui.middleware import _get_wizard_redirect_from_cookie
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchval.side_effect = [0] # No operators
|
|
||||||
|
|
||||||
result = await _get_wizard_redirect_step(mock_conn)
|
mock_request = MagicMock()
|
||||||
|
mock_request.cookies = {}
|
||||||
|
|
||||||
|
result = _get_wizard_redirect_from_cookie(mock_request, "testsecret")
|
||||||
assert result == "/setup/operator"
|
assert result == "/setup/operator"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_cookie_step_2_redirects_to_system(self):
|
||||||
async def test_default_tile_url_redirects_to_system(self):
|
"""When wizard_step=2 in cookie, redirect to /setup/system."""
|
||||||
"""When map_tile_url is default, redirect to /setup/system."""
|
from central.gui.wizard import get_step_route
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchval.side_effect = [1] # Has operator
|
|
||||||
mock_conn.fetchrow.return_value = {
|
|
||||||
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await _get_wizard_redirect_step(mock_conn)
|
result = get_step_route(2)
|
||||||
assert result == "/setup/system"
|
assert result == "/setup/system"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_cookie_step_5_redirects_to_finish(self):
|
||||||
async def test_no_adapters_touched_redirects_to_keys(self):
|
"""When wizard_step=5 in cookie, redirect to /setup/finish."""
|
||||||
"""When no adapters have been updated, redirect to /setup/keys."""
|
from central.gui.wizard import get_step_route
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchval.side_effect = [1, 0] # Has operator, no adapters touched
|
|
||||||
mock_conn.fetchrow.return_value = {
|
|
||||||
"map_tile_url": "https://custom.example.com/{z}/{x}/{y}.png"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await _get_wizard_redirect_step(mock_conn)
|
result = get_step_route(5)
|
||||||
assert result == "/setup/keys"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_steps_complete_redirects_to_finish(self):
|
|
||||||
"""When all steps done, redirect to /setup/finish."""
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchval.side_effect = [1, 1] # Has operator, adapters touched
|
|
||||||
mock_conn.fetchrow.return_value = {
|
|
||||||
"map_tile_url": "https://custom.example.com/{z}/{x}/{y}.png"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await _get_wizard_redirect_step(mock_conn)
|
|
||||||
assert result == "/setup/finish"
|
assert result == "/setup/finish"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,63 +50,26 @@ class TestSetupOperatorForm:
|
||||||
"""Test operator creation form (step 1)."""
|
"""Test operator creation form (step 1)."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_returns_form(self):
|
async def test_get_returns_form_without_prefill(self):
|
||||||
"""GET /setup/operator returns the form when no operator exists."""
|
"""GET /setup/operator returns the form when no wizard cookie exists."""
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
|
mock_request.cookies = {}
|
||||||
|
|
||||||
mock_templates = MagicMock()
|
mock_templates = MagicMock()
|
||||||
mock_templates.TemplateResponse.return_value = MagicMock()
|
mock_templates.TemplateResponse.return_value = MagicMock()
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchrow.return_value = None # No operator exists
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
with patch("central.gui.routes.get_settings") as mock_settings:
|
||||||
with patch("central.gui.routes.get_settings") as mock_settings:
|
mock_settings.return_value.csrf_secret = "testsecret12345678901234567890ab"
|
||||||
mock_settings.return_value.csrf_secret = "testsecret"
|
with patch("central.gui.routes.reuse_or_generate_pre_auth_csrf", return_value=("test_token", "signed_token")):
|
||||||
with patch("central.gui.routes.generate_pre_auth_csrf", return_value=("test_token", "signed_token")):
|
result = await setup_operator_form(mock_request)
|
||||||
result = await setup_operator_form(mock_request)
|
|
||||||
|
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
||||||
assert "csrf_token" in context and context["csrf_token"]
|
assert "csrf_token" in context and context["csrf_token"]
|
||||||
assert context["error"] is None
|
assert context["error"] is None
|
||||||
assert context["existing_operator"] is None
|
assert context["form_data"] is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_returns_confirmation_when_operator_exists(self):
|
|
||||||
"""GET /setup/operator shows confirmation when operator already exists."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
|
|
||||||
mock_templates = MagicMock()
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.body = b"Operator Already Configured"
|
|
||||||
mock_templates.TemplateResponse.return_value = mock_response
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchrow.return_value = {"username": "admin"} # Operator exists
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
with patch("central.gui.routes.get_settings") as mock_settings:
|
|
||||||
mock_settings.return_value.csrf_secret = "testsecret"
|
|
||||||
with patch("central.gui.routes.generate_pre_auth_csrf", return_value=("test_token", "signed_token")):
|
|
||||||
result = await setup_operator_form(mock_request)
|
|
||||||
|
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
|
||||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
||||||
assert context["existing_operator"] == {"username": "admin"}
|
|
||||||
assert context["error"] is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupOperatorSubmit:
|
class TestSetupOperatorSubmit:
|
||||||
|
|
@ -138,28 +79,17 @@ class TestSetupOperatorSubmit:
|
||||||
async def test_password_mismatch_shows_error(self):
|
async def test_password_mismatch_shows_error(self):
|
||||||
"""POST with password mismatch re-renders with error."""
|
"""POST with password mismatch re-renders with error."""
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
mock_request.state.csrf_token = "test_csrf"
|
mock_request.cookies = {}
|
||||||
mock_request.form = AsyncMock(return_value={
|
mock_request.form = AsyncMock(return_value={"csrf_token": "test_csrf"})
|
||||||
"csrf_token": "test_csrf",
|
|
||||||
"username": "testuser",
|
|
||||||
"password": "password1",
|
|
||||||
"confirm_password": "password2", # Mismatch
|
|
||||||
})
|
|
||||||
mock_templates = MagicMock()
|
mock_templates = MagicMock()
|
||||||
mock_templates.TemplateResponse.return_value = MagicMock()
|
mock_templates.TemplateResponse.return_value = MagicMock()
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchval.return_value = 0 # No existing operators
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
with patch("central.gui.routes.validate_pre_auth_csrf", return_value=True):
|
||||||
with patch("central.gui.routes.validate_pre_auth_csrf", return_value=True):
|
with patch("central.gui.routes.get_settings") as mock_settings:
|
||||||
with patch("central.gui.routes.get_settings") as mock_settings:
|
mock_settings.return_value.csrf_secret = "testsecret12345678901234567890ab"
|
||||||
mock_settings.return_value.csrf_secret = "testsecret"
|
with patch("central.gui.routes.reuse_or_generate_pre_auth_csrf", return_value=("test_token", "signed")):
|
||||||
result = await setup_operator_submit(
|
result = await setup_operator_submit(
|
||||||
mock_request,
|
mock_request,
|
||||||
username="testuser",
|
username="testuser",
|
||||||
|
|
@ -172,374 +102,43 @@ class TestSetupOperatorSubmit:
|
||||||
assert context["error"] == "Passwords do not match"
|
assert context["error"] == "Passwords do not match"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_valid_creates_operator_and_redirects(self):
|
async def test_valid_creates_wizard_cookie_and_redirects(self):
|
||||||
"""POST with valid data creates operator and redirects to /setup/system."""
|
"""POST with valid data creates wizard cookie and redirects to /setup/system."""
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
mock_request.state.csrf_token = "test_csrf"
|
mock_request.cookies = {}
|
||||||
mock_request.form = AsyncMock(return_value={
|
mock_request.form = AsyncMock(return_value={"csrf_token": "test_csrf"})
|
||||||
"csrf_token": "test_csrf",
|
|
||||||
"username": "testuser",
|
|
||||||
"password": "password123",
|
|
||||||
"confirm_password": "password123",
|
|
||||||
})
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
with patch("central.gui.routes.validate_pre_auth_csrf", return_value=True):
|
||||||
mock_conn.fetchval.return_value = 0 # No existing operators
|
with patch("central.gui.routes.get_settings") as mock_settings:
|
||||||
mock_conn.fetchrow.side_effect = [
|
mock_settings.return_value.csrf_secret = "testsecret12345678901234567890ab"
|
||||||
{"id": 1}, # INSERT RETURNING id
|
with patch("central.gui.routes.hash_password", return_value="hashed_pw"):
|
||||||
{"session_lifetime_days": 90}, # system settings
|
result = await setup_operator_submit(
|
||||||
]
|
mock_request,
|
||||||
|
username="testuser",
|
||||||
mock_pool = MagicMock()
|
password="password123",
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
confirm_password="password123",
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
)
|
||||||
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
with patch("central.gui.routes.validate_pre_auth_csrf", return_value=True):
|
|
||||||
with patch("central.gui.routes.get_settings") as mock_settings:
|
|
||||||
mock_settings.return_value.csrf_secret = "testsecret"
|
|
||||||
with patch("central.gui.routes.hash_password", return_value="hashed"):
|
|
||||||
with patch("central.gui.routes.create_session", new_callable=AsyncMock) as mock_session:
|
|
||||||
mock_session.return_value = ("session_token", datetime.now(), "csrf_token")
|
|
||||||
with patch("central.gui.routes.write_audit", new_callable=AsyncMock):
|
|
||||||
result = await setup_operator_submit(
|
|
||||||
mock_request,
|
|
||||||
username="testuser",
|
|
||||||
password="password123",
|
|
||||||
confirm_password="password123",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.status_code == 302
|
assert result.status_code == 302
|
||||||
assert result.headers["location"] == "/setup/system"
|
assert result.headers["location"] == "/setup/system"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_post_when_operator_exists_shows_confirmation(self):
|
|
||||||
"""POST when operator exists returns 200 with confirmation, no insert."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.form = AsyncMock(return_value={
|
|
||||||
"csrf_token": "test_csrf",
|
|
||||||
"username": "testuser",
|
|
||||||
"password": "password123",
|
|
||||||
"confirm_password": "password123",
|
|
||||||
})
|
|
||||||
|
|
||||||
mock_templates = MagicMock()
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_templates.TemplateResponse.return_value = mock_response
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchval.return_value = 1 # Operator already exists
|
|
||||||
mock_conn.fetchrow.return_value = {"username": "existing_admin"}
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
mock_request.state.csrf_token = "test_csrf"
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
with patch("central.gui.routes.validate_pre_auth_csrf", return_value=True):
|
|
||||||
with patch("central.gui.routes.get_settings") as mock_settings:
|
|
||||||
mock_settings.return_value.csrf_secret = "testsecret"
|
|
||||||
with patch("central.gui.routes.write_audit", new_callable=AsyncMock) as mock_audit:
|
|
||||||
result = await setup_operator_submit(
|
|
||||||
mock_request,
|
|
||||||
username="testuser",
|
|
||||||
password="password123",
|
|
||||||
confirm_password="password123",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return 200, not 500 or redirect
|
|
||||||
assert result.status_code == 200
|
|
||||||
|
|
||||||
# Should render confirmation state
|
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
|
||||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
||||||
assert context["existing_operator"] == {"username": "existing_admin"}
|
|
||||||
|
|
||||||
# Should NOT call write_audit (no insert happened)
|
|
||||||
mock_audit.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupSystemForm:
|
class TestSetupSystemForm:
|
||||||
"""Test system settings form (step 2)."""
|
"""Test system settings form (step 2)."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unauthenticated_redirects_to_operator(self):
|
async def test_no_wizard_cookie_redirects_to_operator(self):
|
||||||
"""GET /setup/system without auth redirects to /setup/operator."""
|
"""GET /setup/system without wizard cookie redirects to /setup/operator."""
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
mock_request.state.operator = None
|
mock_request.cookies = {}
|
||||||
result = await setup_system_form(mock_request)
|
|
||||||
|
with patch("central.gui.routes.get_settings") as mock_settings:
|
||||||
|
mock_settings.return_value.csrf_secret = "testsecret12345678901234567890ab"
|
||||||
|
result = await setup_system_form(mock_request)
|
||||||
|
|
||||||
assert result.status_code == 302
|
assert result.status_code == 302
|
||||||
assert result.headers["location"] == "/setup/operator"
|
assert result.headers["location"] == "/setup/operator"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_authenticated_returns_form(self):
|
|
||||||
"""GET /setup/system with auth returns the form."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
||||||
|
|
||||||
mock_templates = MagicMock()
|
|
||||||
mock_templates.TemplateResponse.return_value = MagicMock()
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchrow.return_value = {
|
|
||||||
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
"map_attribution": "© OpenStreetMap contributors",
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
result = await setup_system_form(mock_request)
|
|
||||||
|
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupSystemSubmit:
|
|
||||||
"""Test system settings submission."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_placeholders_shows_error(self):
|
|
||||||
"""POST without {z},{x},{y} placeholders shows error."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
||||||
mock_request.state.csrf_token = "test_csrf_token"
|
|
||||||
|
|
||||||
form_data = MagicMock()
|
|
||||||
form_data.get = lambda k, default="": {
|
|
||||||
"csrf_token": "test_csrf_token",
|
|
||||||
"map_tile_url": "https://example.com/tiles",
|
|
||||||
"map_attribution": "Test",
|
|
||||||
}.get(k, default)
|
|
||||||
mock_request.form = AsyncMock(return_value=form_data)
|
|
||||||
|
|
||||||
mock_templates = MagicMock()
|
|
||||||
mock_templates.TemplateResponse.return_value = MagicMock()
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchrow.return_value = {
|
|
||||||
"map_tile_url": "",
|
|
||||||
"map_attribution": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
result = await setup_system_submit(mock_request)
|
|
||||||
|
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
|
||||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
||||||
assert "map_tile_url" in context["errors"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_valid_updates_and_redirects(self):
|
|
||||||
"""POST with valid data updates system and redirects to /setup/keys."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
||||||
mock_request.state.csrf_token = "test_csrf_token"
|
|
||||||
|
|
||||||
form_data = MagicMock()
|
|
||||||
form_data.get = lambda k, default="": {
|
|
||||||
"csrf_token": "test_csrf_token",
|
|
||||||
"map_tile_url": "https://example.com/{z}/{x}/{y}.png",
|
|
||||||
"map_attribution": "Test Attribution",
|
|
||||||
}.get(k, default)
|
|
||||||
mock_request.form = AsyncMock(return_value=form_data)
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchrow.return_value = {
|
|
||||||
"map_tile_url": "old_url",
|
|
||||||
"map_attribution": "old_attr",
|
|
||||||
}
|
|
||||||
mock_conn.execute = AsyncMock()
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
with patch("central.gui.routes.write_audit", new_callable=AsyncMock):
|
|
||||||
result = await setup_system_submit(mock_request)
|
|
||||||
|
|
||||||
assert result.status_code == 302
|
|
||||||
assert result.headers["location"] == "/setup/keys"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupKeysForm:
|
|
||||||
"""Test API keys form (step 3)."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unauthenticated_redirects_to_operator(self):
|
|
||||||
"""GET /setup/keys without auth redirects to /setup/operator."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = None
|
|
||||||
result = await setup_keys_form(mock_request)
|
|
||||||
assert result.status_code == 302
|
|
||||||
assert result.headers["location"] == "/setup/operator"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupKeysSubmit:
|
|
||||||
"""Test API keys submission."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_next_action_redirects_to_adapters(self):
|
|
||||||
"""POST with action=next redirects to /setup/adapters."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
||||||
mock_request.state.csrf_token = "test_csrf_token"
|
|
||||||
|
|
||||||
form_data = MagicMock()
|
|
||||||
form_data.get = lambda k, default="": {
|
|
||||||
"csrf_token": "test_csrf_token",
|
|
||||||
"action": "next",
|
|
||||||
}.get(k, default)
|
|
||||||
mock_request.form = AsyncMock(return_value=form_data)
|
|
||||||
|
|
||||||
# No need to mock get_pool since action="next" returns before it's called
|
|
||||||
result = await setup_keys_submit(mock_request)
|
|
||||||
assert result.status_code == 302
|
|
||||||
assert result.headers["location"] == "/setup/adapters"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_key_creates_and_rerenders(self):
|
|
||||||
"""POST with action=add creates key and re-renders with success."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
||||||
mock_request.state.csrf_token = "test_csrf_token"
|
|
||||||
|
|
||||||
form_data = MagicMock()
|
|
||||||
form_data.get = lambda k, default="": {
|
|
||||||
"csrf_token": "test_csrf_token",
|
|
||||||
"action": "add",
|
|
||||||
"alias": "testkey",
|
|
||||||
"plaintext_key": "secret123",
|
|
||||||
}.get(k, default)
|
|
||||||
mock_request.form = AsyncMock(return_value=form_data)
|
|
||||||
|
|
||||||
mock_templates = MagicMock()
|
|
||||||
mock_templates.TemplateResponse.return_value = MagicMock()
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchrow.side_effect = [
|
|
||||||
None, # No existing key
|
|
||||||
{"created_at": datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc)},
|
|
||||||
]
|
|
||||||
mock_conn.fetch.side_effect = [
|
|
||||||
[], # First list
|
|
||||||
[{"alias": "testkey", "created_at": datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc)}], # After insert
|
|
||||||
]
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
with patch("central.crypto.encrypt", return_value=b"encrypted"):
|
|
||||||
with patch("central.gui.routes.write_audit", new_callable=AsyncMock):
|
|
||||||
result = await setup_keys_submit(mock_request)
|
|
||||||
|
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
|
||||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
||||||
assert context["success"] == "API key 'testkey' added successfully."
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupAdaptersForm:
|
|
||||||
"""Test adapters configuration form (step 4)."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unauthenticated_redirects_to_operator(self):
|
|
||||||
"""GET /setup/adapters without auth redirects to /setup/operator."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = None
|
|
||||||
result = await setup_adapters_form(mock_request)
|
|
||||||
assert result.status_code == 302
|
|
||||||
assert result.headers["location"] == "/setup/operator"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupFinishForm:
|
|
||||||
"""Test finish page (step 5)."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unauthenticated_redirects_to_operator(self):
|
|
||||||
"""GET /setup/finish without auth redirects to /setup/operator."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = None
|
|
||||||
result = await setup_finish_form(mock_request)
|
|
||||||
assert result.status_code == 302
|
|
||||||
assert result.headers["location"] == "/setup/operator"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_authenticated_shows_summary(self):
|
|
||||||
"""GET /setup/finish with auth shows summary."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
||||||
|
|
||||||
mock_templates = MagicMock()
|
|
||||||
mock_templates.TemplateResponse.return_value = MagicMock()
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.fetchval.side_effect = [1, 2] # 1 operator, 2 keys
|
|
||||||
mock_conn.fetchrow.return_value = {"map_tile_url": "https://example.com/{z}/{x}/{y}.png"}
|
|
||||||
mock_conn.fetch.return_value = [
|
|
||||||
{"name": "nws", "enabled": True, "cadence_s": 300},
|
|
||||||
{"name": "firms", "enabled": False, "cadence_s": 600},
|
|
||||||
]
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
result = await setup_finish_form(mock_request)
|
|
||||||
|
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
|
||||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
||||||
assert context["operator_count"] == 1
|
|
||||||
assert context["key_count"] == 2
|
|
||||||
assert len(context["adapters"]) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupFinishSubmit:
|
|
||||||
"""Test setup completion."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_marks_setup_complete_and_redirects(self):
|
|
||||||
"""POST /setup/finish marks setup_complete=true and redirects to /."""
|
|
||||||
mock_request = MagicMock()
|
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
||||||
mock_request.state.csrf_token = "test_csrf_token"
|
|
||||||
|
|
||||||
# Mock form with CSRF token
|
|
||||||
form_data = MagicMock()
|
|
||||||
form_data.get = lambda k, default="": {"csrf_token": "test_csrf_token"}.get(k, default)
|
|
||||||
mock_request.form = AsyncMock(return_value=form_data)
|
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
|
||||||
mock_conn.execute = AsyncMock()
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
|
||||||
mock_pool.acquire.return_value.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
||||||
with patch("central.gui.routes.write_audit", new_callable=AsyncMock) as mock_audit:
|
|
||||||
result = await setup_finish_submit(mock_request)
|
|
||||||
|
|
||||||
assert result.status_code == 302
|
|
||||||
assert result.headers["location"] == "/"
|
|
||||||
mock_conn.execute.assert_called_once()
|
|
||||||
mock_audit.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupGateMiddlewareWizard:
|
class TestSetupGateMiddlewareWizard:
|
||||||
"""Test SetupGateMiddleware with wizard paths."""
|
"""Test SetupGateMiddleware with wizard paths."""
|
||||||
|
|
@ -570,69 +169,6 @@ class TestSetupGateMiddlewareWizard:
|
||||||
response = client.get("/setup/operator")
|
response = client.get("/setup/operator")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_redirects_base_setup_to_wizard_step(self):
|
|
||||||
"""SetupGateMiddleware redirects /setup to appropriate wizard step."""
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_conn = MagicMock()
|
|
||||||
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": False})
|
|
||||||
mock_conn.fetchval = AsyncMock(return_value=0) # No operators
|
|
||||||
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
||||||
mock_conn.__aexit__ = AsyncMock()
|
|
||||||
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
|
||||||
|
|
||||||
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.get("/setup")
|
|
||||||
async def setup():
|
|
||||||
return {"message": "base setup"}
|
|
||||||
|
|
||||||
@app.get("/setup/operator")
|
|
||||||
async def setup_operator():
|
|
||||||
return {"message": "operator"}
|
|
||||||
|
|
||||||
app.add_middleware(SetupGateMiddleware)
|
|
||||||
client = TestClient(app, follow_redirects=False)
|
|
||||||
|
|
||||||
response = client.get("/setup")
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.headers["location"] == "/setup/operator"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_redirects_login_to_setup_when_incomplete(self):
|
|
||||||
"""SetupGateMiddleware redirects /login to /setup when setup_complete=False."""
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_conn = MagicMock()
|
|
||||||
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": False})
|
|
||||||
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
||||||
mock_conn.__aexit__ = AsyncMock()
|
|
||||||
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
|
||||||
|
|
||||||
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.get("/login")
|
|
||||||
async def login():
|
|
||||||
return {"message": "login"}
|
|
||||||
|
|
||||||
@app.get("/setup")
|
|
||||||
async def setup():
|
|
||||||
return {"message": "setup"}
|
|
||||||
|
|
||||||
app.add_middleware(SetupGateMiddleware)
|
|
||||||
client = TestClient(app, follow_redirects=False)
|
|
||||||
|
|
||||||
response = client.get("/login")
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.headers["location"] == "/setup"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redirects_all_setup_paths_when_complete(self):
|
async def test_redirects_all_setup_paths_when_complete(self):
|
||||||
"""SetupGateMiddleware redirects /setup/* to / when setup_complete=True."""
|
"""SetupGateMiddleware redirects /setup/* to / when setup_complete=True."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue