feat(gui): add auth core, setup gate, and first-run operator creation

- Add migrations 007-010 for system config, operators, sessions, audit_log
- Implement argon2id password hashing via argon2-cffi
- Implement session-based authentication with database-stored tokens
- Add SetupGateMiddleware to redirect to /setup until first operator created
- Add SessionMiddleware to load session from cookie and attach operator
- Create /setup, /login, /logout, /change-password routes with CSRF protection
- Add periodic session cleanup task (hourly)
- Add audit logging for auth events
- Update systemd unit with EnvironmentFile for /etc/central/central.env
- Add comprehensive tests for auth, middleware, and audit modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-17 05:30:49 +00:00
commit f059f982bc
25 changed files with 1758 additions and 16 deletions

50
tests/conftest.py Normal file
View file

@ -0,0 +1,50 @@
"""Shared fixtures for auth tests."""
import asyncio
import tempfile
from pathlib import Path
from typing import AsyncGenerator
import asyncpg
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from central.bootstrap_config import Settings
@pytest.fixture(scope="session")
def event_loop():
"""Create an event loop for the test session."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
def mock_settings():
"""Create mock settings for testing."""
return Settings(
db_dsn="postgresql://test:test@localhost/test",
nats_url="nats://localhost:4222",
csrf_secret="test-csrf-secret-for-testing-only-32chars",
)
@pytest.fixture
def mock_pool():
"""Create a mock database pool."""
pool = MagicMock()
pool.acquire = MagicMock()
pool.close = AsyncMock()
return pool
@pytest.fixture
def mock_conn():
"""Create a mock database connection."""
conn = MagicMock()
conn.fetchrow = AsyncMock()
conn.fetchval = AsyncMock()
conn.execute = AsyncMock()
return conn

92
tests/test_audit.py Normal file
View file

@ -0,0 +1,92 @@
"""Tests for audit log module."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from central.gui.audit import (
write_audit,
AUTH_LOGIN,
AUTH_LOGIN_FAILED,
AUTH_LOGOUT,
AUTH_PASSWORD_CHANGE,
OPERATOR_CREATE,
)
class TestAuditConstants:
"""Tests for audit action constants."""
def test_auth_login(self):
assert AUTH_LOGIN == "auth.login"
def test_auth_login_failed(self):
assert AUTH_LOGIN_FAILED == "auth.login_failed"
def test_auth_logout(self):
assert AUTH_LOGOUT == "auth.logout"
def test_auth_password_change(self):
assert AUTH_PASSWORD_CHANGE == "auth.password_change"
def test_operator_create(self):
assert OPERATOR_CREATE == "operator.create"
class TestWriteAudit:
"""Tests for write_audit function."""
@pytest.mark.asyncio
async def test_write_audit_basic(self):
"""write_audit inserts basic audit record."""
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
await write_audit(mock_conn, action="auth.login", operator_id=1)
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
assert "INSERT INTO config.audit_log" in call_args[0][0]
@pytest.mark.asyncio
async def test_write_audit_with_target(self):
"""write_audit includes target when provided."""
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
await write_audit(
mock_conn,
action="operator.create",
operator_id=1,
target="newuser",
)
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
# target is the 3rd positional arg (after operator_id and action)
assert "newuser" in call_args[0]
@pytest.mark.asyncio
async def test_write_audit_with_before_after(self):
"""write_audit includes before/after when provided."""
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
await write_audit(
mock_conn,
action="config.update",
operator_id=1,
before={"value": "old"},
after={"value": "new"},
)
mock_conn.execute.assert_called_once()
@pytest.mark.asyncio
async def test_write_audit_no_operator(self):
"""write_audit works with operator_id=None."""
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
await write_audit(mock_conn, action="auth.login_failed", operator_id=None)
mock_conn.execute.assert_called_once()

183
tests/test_auth.py Normal file
View file

@ -0,0 +1,183 @@
"""Tests for auth module."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from datetime import datetime, timezone
from central.gui.auth import (
hash_password,
verify_password,
validate_password,
generate_token,
create_session,
get_session,
delete_session,
get_operator_by_username,
create_operator,
Operator,
)
class TestPasswordHashing:
"""Tests for password hashing functions."""
def test_hash_password_returns_string(self):
"""hash_password returns a string."""
result = hash_password("testpassword")
assert isinstance(result, str)
def test_hash_password_includes_argon2id(self):
"""hash_password uses argon2id algorithm."""
result = hash_password("testpassword")
assert result.startswith("$argon2id$")
def test_hash_password_different_each_time(self):
"""hash_password produces different hashes for same password."""
hash1 = hash_password("testpassword")
hash2 = hash_password("testpassword")
assert hash1 != hash2
def test_verify_password_correct(self):
"""verify_password returns True for correct password."""
password = "testpassword"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_verify_password_incorrect(self):
"""verify_password returns False for wrong password."""
hashed = hash_password("testpassword")
assert verify_password("wrongpassword", hashed) is False
def test_verify_password_empty(self):
"""verify_password handles empty strings."""
hashed = hash_password("testpassword")
assert verify_password("", hashed) is False
class TestPasswordValidation:
"""Tests for password validation."""
def test_valid_password(self):
"""validate_password passes for valid password."""
validate_password("password123") # No exception
def test_short_password(self):
"""validate_password raises for short password."""
with pytest.raises(ValueError) as exc_info:
validate_password("short")
assert "8 characters" in str(exc_info.value)
class TestTokenGeneration:
"""Tests for token generation."""
def test_generate_token_length(self):
"""generate_token produces expected length."""
token = generate_token()
# URL-safe base64 of 32 bytes is 43 characters
assert len(token) == 43
def test_generate_token_unique(self):
"""generate_token produces unique tokens."""
tokens = [generate_token() for _ in range(100)]
assert len(set(tokens)) == 100
class TestSessionManagement:
"""Tests for session creation and retrieval."""
@pytest.mark.asyncio
async def test_create_session(self):
"""create_session inserts a session record."""
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
token, expires_at = await create_session(mock_conn, operator_id=1, lifetime_days=90)
assert len(token) == 43
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
assert "INSERT INTO config.sessions" in call_args[0][0]
@pytest.mark.asyncio
async def test_get_session_found(self):
"""get_session returns Operator when session exists."""
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={
"id": 1,
"username": "testuser",
"created_at": datetime.now(timezone.utc),
"password_changed_at": datetime.now(timezone.utc),
})
operator = await get_session(mock_conn, "valid-token")
assert operator is not None
assert operator.id == 1
assert operator.username == "testuser"
@pytest.mark.asyncio
async def test_get_session_not_found(self):
"""get_session returns None when session doesn\'t exist."""
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value=None)
operator = await get_session(mock_conn, "invalid-token")
assert operator is None
@pytest.mark.asyncio
async def test_delete_session(self):
"""delete_session removes the session."""
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
await delete_session(mock_conn, "some-token")
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
assert "DELETE FROM config.sessions" in call_args[0][0]
class TestOperatorManagement:
"""Tests for operator creation and retrieval."""
@pytest.mark.asyncio
async def test_get_operator_by_username_found(self):
"""get_operator_by_username returns operator when found."""
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={
"id": 1,
"username": "admin",
"password_hash": "somehash",
"created_at": datetime.now(timezone.utc),
"password_changed_at": datetime.now(timezone.utc),
})
result = await get_operator_by_username(mock_conn, "admin")
assert result is not None
assert result["username"] == "admin"
@pytest.mark.asyncio
async def test_get_operator_by_username_not_found(self):
"""get_operator_by_username returns None when not found."""
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value=None)
result = await get_operator_by_username(mock_conn, "nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_create_operator(self):
"""create_operator inserts and returns operator ID."""
mock_conn = MagicMock()
mock_conn.fetchval = AsyncMock(return_value=1)
operator_id = await create_operator(mock_conn, "newuser", "password123")
assert operator_id == 1
mock_conn.fetchval.assert_called_once()
call_args = mock_conn.fetchval.call_args
assert "INSERT INTO config.operators" in call_args[0][0]

173
tests/test_session_auth.py Normal file
View file

@ -0,0 +1,173 @@
"""Tests for session authentication middleware."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone
from starlette.testclient import TestClient
from fastapi import FastAPI, Request
from central.gui.middleware import SessionMiddleware
from central.gui.auth import Operator
class TestSessionMiddleware:
"""Tests for SessionMiddleware."""
@pytest.mark.asyncio
async def test_no_cookie_sets_none_on_exempt_path(self):
"""SessionMiddleware sets operator=None when no session cookie on exempt path."""
mock_pool = MagicMock()
mock_pool.acquire = MagicMock()
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/health")
async def health(request: Request):
return {"operator": getattr(request.state, "operator", "missing")}
app.add_middleware(SessionMiddleware)
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.json()["operator"] is None
@pytest.mark.asyncio
async def test_valid_cookie_sets_operator_on_exempt_path(self):
"""SessionMiddleware sets operator when valid session cookie on exempt path."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={
"id": 1,
"username": "admin",
"created_at": datetime.now(timezone.utc),
"password_changed_at": datetime.now(timezone.utc),
})
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("/health")
async def health(request: Request):
op = getattr(request.state, "operator", None)
if op:
return {"username": op.username}
return {"operator": None}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "valid-token"})
response = client.get("/health")
assert response.status_code == 200
assert response.json()["username"] == "admin"
@pytest.mark.asyncio
async def test_no_cookie_redirects_on_protected_path(self):
"""SessionMiddleware redirects to /login when no cookie on protected path."""
mock_pool = MagicMock()
mock_pool.acquire = MagicMock()
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/")
async def index(request: Request):
return {"message": "home"}
@app.get("/login")
async def login():
return {"message": "login"}
app.add_middleware(SessionMiddleware)
client = TestClient(app, follow_redirects=False)
response = client.get("/")
assert response.status_code == 302
assert response.headers["location"] == "/login"
@pytest.mark.asyncio
async def test_valid_cookie_allows_protected_path(self):
"""SessionMiddleware allows protected path with valid session."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={
"id": 1,
"username": "admin",
"created_at": datetime.now(timezone.utc),
"password_changed_at": datetime.now(timezone.utc),
})
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("/")
async def index(request: Request):
op = request.state.operator
return {"message": "home", "user": op.username}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "valid-token"})
response = client.get("/")
assert response.status_code == 200
assert response.json()["user"] == "admin"
@pytest.mark.asyncio
async def test_invalid_cookie_redirects_on_protected_path(self):
"""SessionMiddleware redirects when session is invalid/expired."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value=None) # No session found
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("/")
async def index(request: Request):
return {"operator": getattr(request.state, "operator", "missing")}
@app.get("/login")
async def login():
return {"message": "login"}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "expired-token"}, follow_redirects=False)
response = client.get("/")
assert response.status_code == 302
assert response.headers["location"] == "/login"
@pytest.mark.asyncio
async def test_middleware_handles_db_error(self):
"""SessionMiddleware handles database errors gracefully on exempt path."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(side_effect=Exception("DB error"))
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("/health")
async def health(request: Request):
return {"operator": getattr(request.state, "operator", "missing")}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "some-token"})
response = client.get("/health")
# Should not crash, just set operator to None
assert response.status_code == 200
assert response.json()["operator"] is None

162
tests/test_setup_gate.py Normal file
View file

@ -0,0 +1,162 @@
"""Tests for setup gate middleware."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from starlette.testclient import TestClient
from fastapi import FastAPI
from central.gui.middleware import SetupGateMiddleware
class TestSetupGateMiddleware:
"""Tests for SetupGateMiddleware."""
@pytest.mark.asyncio
async def test_allows_setup_route_when_incomplete(self):
"""SetupGateMiddleware allows /setup when setup_complete=False."""
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("/setup")
async def setup():
return {"message": "setup"}
app.add_middleware(SetupGateMiddleware)
client = TestClient(app)
response = client.get("/setup")
assert response.status_code == 200
assert response.json() == {"message": "setup"}
@pytest.mark.asyncio
async def test_allows_health_when_incomplete(self):
"""SetupGateMiddleware allows /health regardless of setup state."""
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("/health")
async def health():
return {"status": "ok"}
app.add_middleware(SetupGateMiddleware)
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_redirects_other_routes_when_incomplete(self):
"""SetupGateMiddleware redirects non-setup routes when setup_complete=False."""
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("/")
async def index():
return {"message": "home"}
@app.get("/setup")
async def setup():
return {"message": "setup"}
app.add_middleware(SetupGateMiddleware)
client = TestClient(app, follow_redirects=False)
response = client.get("/")
assert response.status_code == 307
assert response.headers["location"] == "/setup"
@pytest.mark.asyncio
async def test_allows_all_routes_when_complete(self):
"""SetupGateMiddleware allows all routes when setup_complete=True."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": True})
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("/")
async def index():
return {"message": "home"}
app.add_middleware(SetupGateMiddleware)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "home"}
@pytest.mark.asyncio
async def test_allows_static_when_incomplete(self):
"""SetupGateMiddleware allows /static routes when setup_complete=False."""
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("/static/test.css")
async def static():
return "css"
app.add_middleware(SetupGateMiddleware)
client = TestClient(app)
response = client.get("/static/test.css")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_redirects_setup_when_complete(self):
"""SetupGateMiddleware redirects /setup to / when setup_complete=True."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": True})
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("/")
async def index():
return {"message": "home"}
@app.get("/setup")
async def setup():
return {"message": "setup"}
app.add_middleware(SetupGateMiddleware)
client = TestClient(app, follow_redirects=False)
response = client.get("/setup")
assert response.status_code == 302
assert response.headers["location"] == "/"