Add persistent settings system with database backend and comprehensive UI

- New Setting ORM model (key-value store with category, value_type, audit fields)
- Alembic migration to create the settings table
- Settings API endpoints: GET/PUT /api/v1/settings/{key}, GET /api/v1/settings (list+filter), POST /api/v1/settings/bulk
- Default seeding (17 sensible defaults across general/dmarc/dns/cloudflare/notifications categories)
- Secret redaction for cloudflare.api_token and notifications.smtp_password
- Updated settings.html: General, DMARC Policy Defaults, DNS Resolver, Cloudflare Integration, Email Notifications sections
- All forms wired to the API via Alpine.js with flash feedback
- 12 new tests for the settings model and endpoints

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/19dbc6cd-07cb-406e-b3b6-411f7721f737

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-30 07:58:51 +00:00
parent 12305fbaef
commit 241713083b
9 changed files with 957 additions and 66 deletions
+150
View File
@@ -0,0 +1,150 @@
"""
Tests for the Settings model and /api/v1/settings endpoints.
"""
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.setting import Setting
class TestSettingModel:
"""Unit tests for the Setting ORM model."""
def test_create_setting(self, db_session: Session):
row = Setting(
key="general.app_name",
value="TestApp",
description="App name",
value_type="string",
category="general",
)
db_session.add(row)
db_session.commit()
db_session.refresh(row)
assert row.key == "general.app_name"
assert row.value == "TestApp"
assert row.category == "general"
assert row.value_type == "string"
def test_repr(self, db_session: Session):
row = Setting(key="dns.resolver", value="system", category="dns")
db_session.add(row)
db_session.commit()
assert "dns.resolver" in repr(row)
assert "dns" in repr(row)
class TestSettingsAPI:
"""Integration tests for /api/v1/settings endpoints."""
def test_list_settings_seeds_defaults(self, authed_client: TestClient):
"""GET /api/v1/settings returns seeded defaults on first call."""
res = authed_client.get("/api/v1/settings")
assert res.status_code == 200
data = res.json()
assert isinstance(data, list)
keys = {row["key"] for row in data}
assert "general.app_name" in keys
assert "dmarc.default_policy" in keys
assert "cloudflare.api_token" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
res = authed_client.get("/api/v1/settings?category=dmarc")
assert res.status_code == 200
data = res.json()
for row in data:
assert row["category"] == "dmarc"
def test_get_single_setting(self, authed_client: TestClient):
"""GET /api/v1/settings/{key} returns a single setting."""
# Seed defaults first
authed_client.get("/api/v1/settings")
res = authed_client.get("/api/v1/settings/general.app_name")
assert res.status_code == 200
assert res.json()["key"] == "general.app_name"
assert res.json()["value"] == "DMARQ"
def test_get_missing_setting_returns_404(self, authed_client: TestClient):
"""GET /api/v1/settings/{key} returns 404 for unknown keys."""
authed_client.get("/api/v1/settings") # seed
res = authed_client.get("/api/v1/settings/nonexistent.key")
assert res.status_code == 404
def test_update_setting(self, authed_client: TestClient):
"""PUT /api/v1/settings/{key} updates a setting value."""
authed_client.get("/api/v1/settings") # seed
res = authed_client.put(
"/api/v1/settings/general.app_name",
json={"value": "MyDMARQ"},
)
assert res.status_code == 200
assert res.json()["value"] == "MyDMARQ"
# Verify persistence
res2 = authed_client.get("/api/v1/settings/general.app_name")
assert res2.json()["value"] == "MyDMARQ"
def test_update_setting_upserts(self, authed_client: TestClient):
"""PUT /api/v1/settings/{key} creates the row if it doesn't exist yet."""
res = authed_client.put(
"/api/v1/settings/general.custom_key",
json={"value": "hello"},
)
assert res.status_code == 200
assert res.json()["value"] == "hello"
def test_bulk_update(self, authed_client: TestClient):
"""POST /api/v1/settings/bulk updates multiple settings at once."""
authed_client.get("/api/v1/settings") # seed
res = authed_client.post(
"/api/v1/settings/bulk",
json={
"settings": {
"dmarc.default_policy": "quarantine",
"dmarc.default_percentage": "80",
}
},
)
assert res.status_code == 200
data = {row["key"]: row["value"] for row in res.json()}
assert data["dmarc.default_policy"] == "quarantine"
assert data["dmarc.default_percentage"] == "80"
def test_secret_is_redacted_in_response(self, authed_client: TestClient):
"""cloudflare.api_token value is redacted in GET responses."""
authed_client.get("/api/v1/settings") # seed
# Store a real token
authed_client.put(
"/api/v1/settings/cloudflare.api_token",
json={"value": "super-secret-token"},
)
res = authed_client.get("/api/v1/settings/cloudflare.api_token")
assert res.status_code == 200
assert res.json()["value"] == "**redacted**"
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
"""Sending **redacted** back to PUT should not overwrite the stored value."""
authed_client.get("/api/v1/settings")
authed_client.put(
"/api/v1/settings/cloudflare.api_token",
json={"value": "real-token-value"},
)
# Simulate round-trip with redacted placeholder
authed_client.put(
"/api/v1/settings/cloudflare.api_token",
json={"value": "**redacted**"},
)
# Direct DB check via a fresh GET the value should still be "real-token-value"
# (GET always redacts, so we check via the list endpoint's category filter)
res = authed_client.get("/api/v1/settings?category=cloudflare")
cf = {row["key"]: row["value"] for row in res.json()}
# Value should remain redacted (which means the underlying value is still set)
assert cf["cloudflare.api_token"] == "**redacted**"
def test_unauthenticated_returns_403(self, client: TestClient):
"""Unauthenticated requests to settings endpoints return 403."""
res = client.get("/api/v1/settings")
assert res.status_code in (401, 403)