M1.5 backend: admin role + DB-backed settings, DB-URL-only install
- users.is_admin; first registered user becomes admin; registration gated by the allow_registration setting (first account always allowed). is_admin in /api/auth/* responses; require_admin guard (live DB check). - settings table + code registry (site_name, allow_registration, session_ttl_days) with typed defaults — empty table = all defaults (rule 26). get/set/validate service; GET /api/config (public) + GET/PATCH /api/settings (admin), live session-TTL apply with no restart (rule 25). - Cookie-signing secret now persisted in the DB (before_serving load-or-create), so sessions survive restarts with no volume. Config: DATABASE_URL is the only required env; SECRET_KEY + DATA_DIR are optional break-glass items. - Migration 0003; DB-free tests for settings validation + admin guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""roles + settings
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-07-19
|
||||
|
||||
Adds the admin role bit and the DB-backed settings table. No rows are seeded —
|
||||
defaults live in the code registry (thoughtsync.settings), so an empty table
|
||||
means every setting is at its default.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("key", sa.Text(), primary_key=True),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("settings")
|
||||
op.drop_column("users", "is_admin")
|
||||
+33
-6
@@ -1,34 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from quart import Quart, jsonify, send_from_directory
|
||||
|
||||
from . import __version__
|
||||
from .auth import bp as auth_bp
|
||||
from .config import Config
|
||||
from .db import session_scope
|
||||
from .notes import bp as notes_bp
|
||||
from .settings import get_public_config, get_setting, load_or_create_secret_key
|
||||
from .settings_api import bp as settings_bp
|
||||
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
# static_folder=None: the SPA catch-all below owns static serving instead of
|
||||
# Quart's default /static handler.
|
||||
# static_folder=None: the SPA catch-all below owns static serving.
|
||||
app = Quart(__name__, static_folder=None)
|
||||
app.secret_key = Config.secret_key()
|
||||
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
|
||||
# database. before_serving swaps in the real, DB-persisted key before serving.
|
||||
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
|
||||
app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(notes_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
|
||||
@app.before_serving
|
||||
async def _bootstrap() -> None:
|
||||
# Load (or generate + persist) the real signing secret and the live session
|
||||
# lifetime from the DB, before any request is served.
|
||||
async with session_scope() as db:
|
||||
app.secret_key = await load_or_create_secret_key(db)
|
||||
try:
|
||||
days = int(await get_setting(db, "session_ttl_days"))
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pass
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})
|
||||
|
||||
# Serve the built Vue SPA (baked into static/ by the Docker build) with a
|
||||
# history-fallback to index.html. In dev the Vite server proxies /api here, so
|
||||
# a missing static/ dir is expected and simply 404s the frontend.
|
||||
@app.get("/api/config")
|
||||
async def public_config():
|
||||
# Public: the login/register screen reads site name + whether signups are open.
|
||||
async with session_scope() as db:
|
||||
data = await get_public_config(db)
|
||||
data["version"] = app.config["APP_VERSION"]
|
||||
return jsonify(data)
|
||||
|
||||
@app.get("/", defaults={"path": ""})
|
||||
@app.get("/<path:path>")
|
||||
async def spa(path: str):
|
||||
|
||||
+52
-8
@@ -4,11 +4,12 @@ import functools
|
||||
import uuid
|
||||
|
||||
from quart import Blueprint, g, jsonify, request, session
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from .db import session_scope
|
||||
from .models.user import User
|
||||
from .security import hash_password, verify_password
|
||||
from .settings import get_setting
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
@@ -22,22 +23,52 @@ def _serialize_user(user: User) -> dict:
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"email_verified": user.email_verified,
|
||||
"is_admin": user.is_admin,
|
||||
}
|
||||
|
||||
|
||||
def _session_user_id() -> uuid.UUID | None:
|
||||
raw = session.get(SESSION_KEY)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except (ValueError, TypeError):
|
||||
session.pop(SESSION_KEY, None)
|
||||
return None
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
"""Guard: 401 unless a valid session is present. Sets g.user_id for the view."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
raw = session.get(SESSION_KEY)
|
||||
if not raw:
|
||||
uid = _session_user_id()
|
||||
if uid is None:
|
||||
return jsonify({"error": "authentication required"}), 401
|
||||
try:
|
||||
g.user_id = uuid.UUID(raw)
|
||||
except (ValueError, TypeError):
|
||||
session.pop(SESSION_KEY, None)
|
||||
g.user_id = uid
|
||||
return await fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_admin(fn):
|
||||
"""Guard: 401 unauthenticated, 403 non-admin. Checks is_admin live from the DB
|
||||
so a demoted admin loses access immediately."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
uid = _session_user_id()
|
||||
if uid is None:
|
||||
return jsonify({"error": "authentication required"}), 401
|
||||
async with session_scope() as db:
|
||||
user = await db.get(User, uid)
|
||||
if user is None:
|
||||
session.pop(SESSION_KEY, None)
|
||||
return jsonify({"error": "authentication required"}), 401
|
||||
if not user.is_admin:
|
||||
return jsonify({"error": "admin access required"}), 403
|
||||
g.user_id = uid
|
||||
return await fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -58,14 +89,26 @@ async def register():
|
||||
display_name = email.split("@", 1)[0]
|
||||
|
||||
async with session_scope() as db:
|
||||
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
is_first = user_count == 0
|
||||
# The first account bootstraps the admin and is always allowed, even when
|
||||
# registration is otherwise closed.
|
||||
if not is_first and not await get_setting(db, "allow_registration"):
|
||||
return jsonify({"error": "registration is closed"}), 403
|
||||
existing = await db.scalar(select(User).where(User.email == email))
|
||||
if existing is not None:
|
||||
return jsonify({"error": "an account with that email already exists"}), 409
|
||||
user = User(email=email, password_hash=hash_password(password), display_name=display_name)
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
display_name=display_name,
|
||||
is_admin=is_first,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
session[SESSION_KEY] = str(user.id)
|
||||
session.permanent = True
|
||||
return jsonify(_serialize_user(user)), 201
|
||||
|
||||
|
||||
@@ -80,6 +123,7 @@ async def login():
|
||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
session[SESSION_KEY] = str(user.id)
|
||||
session.permanent = True
|
||||
return jsonify(_serialize_user(user))
|
||||
|
||||
|
||||
|
||||
+14
-29
@@ -1,52 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Config:
|
||||
"""Runtime configuration.
|
||||
"""Bootstrap configuration.
|
||||
|
||||
Values come from environment variables with working local-dev defaults, so the
|
||||
app boots with zero configuration (family rule 26 — integrations default to
|
||||
working, never coerce setup). As the product grows, anything an operator wants
|
||||
to tune moves into a DB-backed Settings UI (rule 25); env is bootstrap only.
|
||||
For a basic install, ``THOUGHTSYNC_DATABASE_URL`` is the ONLY required env var —
|
||||
every other tunable lives in the DB-backed Settings UI (rule 25). The remaining
|
||||
env vars are optional "break-glass" / bootstrap items:
|
||||
|
||||
- ``THOUGHTSYNC_SECRET_KEY`` — optional override for the cookie-signing secret.
|
||||
If unset, a key is generated and persisted in the DB (see
|
||||
``thoughtsync.settings.load_or_create_secret_key``), so sessions survive
|
||||
restarts with no volume required.
|
||||
- ``THOUGHTSYNC_DATA_DIR`` — optional, defaults to ``/var/thoughtsync``. Only
|
||||
used for uploaded media (M2); irrelevant to a basic text-notes install.
|
||||
"""
|
||||
|
||||
# Where runtime-generated secrets + uploaded media live.
|
||||
DATA_DIR = os.environ.get("THOUGHTSYNC_DATA_DIR", "/var/thoughtsync")
|
||||
# Empty -> defaults to <DATA_DIR>/media (see media_root()).
|
||||
MEDIA_ROOT = os.environ.get("THOUGHTSYNC_MEDIA_ROOT", "")
|
||||
|
||||
# postgresql+asyncpg URL. Mirrors alembic.ini's local-dev default.
|
||||
DATABASE_URL = os.environ.get(
|
||||
"THOUGHTSYNC_DATABASE_URL",
|
||||
"postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync",
|
||||
)
|
||||
|
||||
# Auth session cookie: a capture app should rarely log you out, so keep it long.
|
||||
AUTH_TTL_SECONDS = 60 * 60 * 24 * 30 # 30 days
|
||||
|
||||
@classmethod
|
||||
def media_root(cls) -> Path:
|
||||
return Path(cls.MEDIA_ROOT or os.path.join(cls.DATA_DIR, "media"))
|
||||
|
||||
@classmethod
|
||||
def secret_key(cls) -> bytes:
|
||||
"""Secret used to sign the auth session cookie.
|
||||
|
||||
Read from env if set; otherwise read/generate a persistent key file under
|
||||
DATA_DIR so signed sessions survive restarts (no forced re-login on deploy).
|
||||
"""
|
||||
env = os.environ.get("THOUGHTSYNC_SECRET_KEY")
|
||||
if env:
|
||||
return env.encode()
|
||||
data_dir = Path(cls.DATA_DIR)
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
key_file = data_dir / "secret_key"
|
||||
if key_file.exists():
|
||||
return key_file.read_bytes()
|
||||
key = secrets.token_bytes(32)
|
||||
key_file.write_bytes(key)
|
||||
return key
|
||||
def secret_key_env(cls) -> str | None:
|
||||
"""Optional break-glass override for the cookie-signing secret."""
|
||||
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
Imported for side effects only (model registration on Base.metadata).
|
||||
"""
|
||||
|
||||
from . import group, note, share, user # noqa: F401
|
||||
from . import group, note, settings, share, user # noqa: F401
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from . import Base
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""A single persisted key/value setting. Values are JSON-encoded text; the
|
||||
typed defaults + metadata live in the code registry (thoughtsync.settings), so
|
||||
an empty table means "all defaults" (rule 26)."""
|
||||
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(Text(), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -17,6 +17,8 @@ class User(Base):
|
||||
# CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere.
|
||||
email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||
# First registered user becomes admin (see auth.register); admin gates Settings.
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||
# Nullable: leaves room for external-identity-only accounts later (rule 26).
|
||||
password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from .models.settings import Setting
|
||||
|
||||
SettingType = Literal["string", "bool", "int"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingDef:
|
||||
key: str
|
||||
type: SettingType
|
||||
default: Any
|
||||
label: str
|
||||
description: str
|
||||
group: str
|
||||
|
||||
|
||||
# The source of truth for every user-facing setting. Add a row here and it appears
|
||||
# in the admin Settings UI with a working default — no migration, no env var.
|
||||
REGISTRY: list[SettingDef] = [
|
||||
SettingDef(
|
||||
"site_name", "string", "ThoughtSync", "Site name", "Shown in the header and the browser tab.", "General"
|
||||
),
|
||||
SettingDef(
|
||||
"allow_registration",
|
||||
"bool",
|
||||
True,
|
||||
"Allow new registrations",
|
||||
"When off, only existing users can sign in. The first account is always allowed.",
|
||||
"Access",
|
||||
),
|
||||
SettingDef(
|
||||
"session_ttl_days",
|
||||
"int",
|
||||
30,
|
||||
"Session length (days)",
|
||||
"How long a signed-in session stays valid before another login is required.",
|
||||
"Access",
|
||||
),
|
||||
]
|
||||
|
||||
_BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY}
|
||||
|
||||
# Internal, non-UI reserved key: the persisted cookie-signing secret. Stored in the
|
||||
# same table but never listed in the registry, so it never shows in the Settings UI.
|
||||
SECRET_KEY_SETTING = "secret_key"
|
||||
|
||||
|
||||
def _coerce_bool(raw: Any) -> bool:
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
return bool(raw)
|
||||
|
||||
|
||||
def _coerce(defn: SettingDef, raw: Any) -> Any:
|
||||
if defn.type == "bool":
|
||||
return _coerce_bool(raw)
|
||||
if defn.type == "int":
|
||||
try:
|
||||
return int(raw)
|
||||
except (ValueError, TypeError):
|
||||
return defn.default
|
||||
return str(raw)
|
||||
|
||||
|
||||
async def _load_raw(db, key: str) -> Any:
|
||||
row = await db.get(Setting, key)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row.value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
async def _upsert(db, key: str, value: Any) -> None:
|
||||
row = await db.get(Setting, key)
|
||||
payload = json.dumps(value)
|
||||
if row is None:
|
||||
db.add(Setting(key=key, value=payload))
|
||||
else:
|
||||
row.value = payload
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def get_setting(db, key: str) -> Any:
|
||||
defn = _BY_KEY.get(key)
|
||||
if defn is None:
|
||||
raise KeyError(key)
|
||||
raw = await _load_raw(db, key)
|
||||
return defn.default if raw is None else _coerce(defn, raw)
|
||||
|
||||
|
||||
async def get_public_config(db) -> dict:
|
||||
"""Non-sensitive settings the unauthenticated login/register screen needs."""
|
||||
return {
|
||||
"site_name": await get_setting(db, "site_name"),
|
||||
"allow_registration": await get_setting(db, "allow_registration"),
|
||||
}
|
||||
|
||||
|
||||
async def get_admin_settings(db) -> list[dict]:
|
||||
"""Every registry setting with its current value + metadata, for the admin UI."""
|
||||
result: list[dict] = []
|
||||
for d in REGISTRY:
|
||||
result.append(
|
||||
{
|
||||
"key": d.key,
|
||||
"type": d.type,
|
||||
"value": await get_setting(db, d.key),
|
||||
"default": d.default,
|
||||
"label": d.label,
|
||||
"description": d.description,
|
||||
"group": d.group,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def validate_updates(updates: dict) -> tuple[dict, str | None]:
|
||||
"""Coerce/validate a {key: value} dict against the registry. Returns
|
||||
(clean_values, error_message). An unknown key or a bad int is rejected."""
|
||||
clean: dict = {}
|
||||
for key, val in updates.items():
|
||||
defn = _BY_KEY.get(key)
|
||||
if defn is None:
|
||||
return {}, f"unknown setting: {key}"
|
||||
if defn.type == "int":
|
||||
try:
|
||||
clean[key] = int(val)
|
||||
except (ValueError, TypeError):
|
||||
return {}, f"{defn.label} must be a whole number"
|
||||
elif defn.type == "bool":
|
||||
clean[key] = _coerce_bool(val)
|
||||
else:
|
||||
clean[key] = str(val)
|
||||
return clean, None
|
||||
|
||||
|
||||
async def set_settings(db, updates: dict) -> None:
|
||||
for key, value in updates.items():
|
||||
await _upsert(db, key, value)
|
||||
|
||||
|
||||
async def load_or_create_secret_key(db) -> str:
|
||||
"""Return the persisted cookie-signing secret, generating + storing one on first
|
||||
run. Keeps sessions valid across restarts with no env var or volume required."""
|
||||
row = await db.get(Setting, SECRET_KEY_SETTING)
|
||||
if row is not None:
|
||||
try:
|
||||
val = json.loads(row.value)
|
||||
if isinstance(val, str) and val:
|
||||
return val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
key = secrets.token_urlsafe(48)
|
||||
await _upsert(db, SECRET_KEY_SETTING, key)
|
||||
await db.commit()
|
||||
return key
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from quart import Blueprint, current_app, jsonify, request
|
||||
|
||||
from .auth import require_admin
|
||||
from .db import session_scope
|
||||
from .settings import get_admin_settings, set_settings, validate_updates
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_admin
|
||||
async def list_settings():
|
||||
async with session_scope() as db:
|
||||
return jsonify({"settings": await get_admin_settings(db)})
|
||||
|
||||
|
||||
@bp.patch("")
|
||||
@require_admin
|
||||
async def update_settings():
|
||||
raw = await request.get_json(silent=True)
|
||||
data = raw if isinstance(raw, dict) else {}
|
||||
# Accept either {settings: {...}} or a bare {key: value} object.
|
||||
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
|
||||
if not isinstance(updates, dict):
|
||||
return jsonify({"error": "expected an object of settings"}), 400
|
||||
|
||||
clean, error = validate_updates(updates)
|
||||
if error is not None:
|
||||
return jsonify({"error": error}), 400
|
||||
|
||||
async with session_scope() as db:
|
||||
await set_settings(db, clean)
|
||||
await db.commit()
|
||||
result = await get_admin_settings(db)
|
||||
|
||||
# Apply the live-tunable knob without a restart (rule 25).
|
||||
if "session_ttl_days" in clean:
|
||||
current_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=int(clean["session_ttl_days"]))
|
||||
|
||||
return jsonify({"settings": result})
|
||||
@@ -0,0 +1,21 @@
|
||||
import pytest
|
||||
|
||||
from thoughtsync.app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
async def test_settings_requires_auth(app):
|
||||
# require_admin returns 401 before any DB access when unauthenticated.
|
||||
client = app.test_client()
|
||||
resp = await client.get("/api/settings")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_settings_patch_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.patch("/api/settings", json={"site_name": "x"})
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,25 @@
|
||||
from thoughtsync.settings import REGISTRY, validate_updates
|
||||
|
||||
|
||||
def test_registry_has_expected_keys():
|
||||
keys = {d.key for d in REGISTRY}
|
||||
assert {"site_name", "allow_registration", "session_ttl_days"} <= keys
|
||||
|
||||
|
||||
def test_validate_rejects_unknown_key():
|
||||
clean, error = validate_updates({"nope": 1})
|
||||
assert error is not None
|
||||
assert clean == {}
|
||||
|
||||
|
||||
def test_validate_coerces_bool_and_int():
|
||||
clean, error = validate_updates({"allow_registration": "true", "session_ttl_days": "45"})
|
||||
assert error is None
|
||||
assert clean["allow_registration"] is True
|
||||
assert clean["session_ttl_days"] == 45
|
||||
|
||||
|
||||
def test_validate_rejects_bad_int():
|
||||
clean, error = validate_updates({"session_ttl_days": "not-a-number"})
|
||||
assert error is not None
|
||||
assert clean == {}
|
||||
Reference in New Issue
Block a user