Admin panel on its own port, and a registration gate
Three strands that only make sense together, so they land together. Registration gate (migration 0016). POST /auth/register was open to anyone who could reach the port. Now the first account on an empty instance is always allowed, becomes the administrator, and closes registration behind itself; after that it takes an admin reopening it. The decision is made under a row lock on app_settings so two simultaneous first signups cannot both see an empty table and both claim administrator. Existing installs keep their oldest account as admin and start closed. Instance default quota (migration 0017). users.quota_bytes is snapshotted at creation from app_settings.default_quota_bytes, so changing the default affects later accounts only and never resizes a mailbox that exists. The panel is a separate app, not a page in the mail client. admin.html is its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the vendor chunk), with its own administrator sign-in. One nginx now serves two listeners: :80 is the mail client and 404s both /admin.html and /api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not process isolation — CurrentAdmin still guards every admin route — so ADMIN_PORT must be restricted to the LAN, which the runbook now covers for both DSM and QNAP. Also QNAP: deploy/webmail.sh finds Container Station's docker under /share/*/.qpkg/container-station/bin, which is off an SSH session's PATH, before anything shells out to it. docs/nas-install.md gains a QuTS hero runbook (§2.6), splits the DSM firewall section into container-to-container (§8.1) and the egress case that makes account-add time out while autodiscovery appears to work (§8.2), and scopes both to Synology. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -13,6 +13,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
"""
|
||||
from app.api.accounts import router as accounts_router
|
||||
from app.api.actions import router as actions_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.avatar import router as avatar_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
@@ -40,6 +41,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
saved_searches_router,
|
||||
avatar_router,
|
||||
plugins_router,
|
||||
admin_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Instance administration (SPEC §6.1).
|
||||
|
||||
The backend seam for the admin panel. Today it carries only the registration
|
||||
gate — the first account on a fresh install becomes the administrator and closes
|
||||
signup behind itself, and this is how that gets reopened. Further instance-wide
|
||||
options land here as the panel grows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentAdmin, require_csrf
|
||||
from app.identity.schemas import AdminSettingsIn, AdminSettingsOut
|
||||
from app.identity.service import app_settings
|
||||
from app.models import AppSettings
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _out(row: AppSettings) -> AdminSettingsOut:
|
||||
return AdminSettingsOut(
|
||||
registration_open=row.registration_open,
|
||||
default_quota_bytes=row.default_quota_bytes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=AdminSettingsOut)
|
||||
async def read_settings(admin: CurrentAdmin, db: DbSession) -> AdminSettingsOut:
|
||||
return _out(await app_settings(db))
|
||||
|
||||
|
||||
@router.put("/settings", response_model=AdminSettingsOut, dependencies=[Depends(require_csrf)])
|
||||
async def write_settings(
|
||||
body: AdminSettingsIn, admin: CurrentAdmin, db: DbSession
|
||||
) -> AdminSettingsOut:
|
||||
"""Merge the supplied fields into the settings row.
|
||||
|
||||
Changing ``default_quota_bytes`` affects users created afterwards only —
|
||||
nothing here rewrites an existing ``users.quota_bytes``.
|
||||
"""
|
||||
row = await app_settings(db, for_update=True)
|
||||
if body.registration_open is not None:
|
||||
row.registration_open = body.registration_open
|
||||
if body.default_quota_bytes is not None:
|
||||
row.default_quota_bytes = body.default_quota_bytes
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _out(row)
|
||||
@@ -46,5 +46,13 @@ async def require_csrf(request: Request) -> None:
|
||||
raise_api_error(403, "csrf_failed", "missing or invalid CSRF token")
|
||||
|
||||
|
||||
async def current_admin(user: Annotated[User, Depends(current_user)]) -> User:
|
||||
"""Load the authenticated user and require the administrator flag."""
|
||||
if not user.is_admin:
|
||||
raise_api_error(403, "not_admin", "administrator privileges required")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(current_user)]
|
||||
CurrentAdmin = Annotated[User, Depends(current_admin)]
|
||||
CurrentSession = Annotated[Session, Depends(current_session)]
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.identity.schemas import (
|
||||
PasswordResetIn,
|
||||
PasswordResetRequestIn,
|
||||
RegisterRequest,
|
||||
RegistrationStatusOut,
|
||||
SessionOut,
|
||||
TotpDisableRequest,
|
||||
TotpEnableRequest,
|
||||
@@ -41,6 +42,7 @@ from app.identity.service import (
|
||||
TotpRequired,
|
||||
authenticate,
|
||||
register_user,
|
||||
registration_status,
|
||||
resolve_handle,
|
||||
)
|
||||
from app.identity.sessions import (
|
||||
@@ -77,9 +79,20 @@ def _user_out(user: User) -> UserOut:
|
||||
recovery_email=user.recovery_email,
|
||||
settings=user.settings,
|
||||
totp_enabled=user.totp_secret is not None,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/registration", response_model=RegistrationStatusOut)
|
||||
async def registration(db: DbSession) -> RegistrationStatusOut:
|
||||
"""Whether signup is open. Unauthenticated: the signup page needs it to render."""
|
||||
status = await registration_status(db)
|
||||
return RegistrationStatusOut(open=status.open, bootstrap=status.bootstrap)
|
||||
|
||||
|
||||
_REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut)
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, response: Response, db: DbSession
|
||||
@@ -92,8 +105,7 @@ async def register(
|
||||
recovery_email=body.recovery_email,
|
||||
)
|
||||
except RegistrationError as exc:
|
||||
status = 409 if exc.code == "handle_taken" else 400
|
||||
raise_api_error(status, exc.code, exc.message)
|
||||
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
|
||||
await db.commit()
|
||||
await _establish_session(request, response, user)
|
||||
return _user_out(user)
|
||||
|
||||
@@ -55,6 +55,28 @@ class UserOut(BaseModel):
|
||||
recovery_email: str | None
|
||||
settings: dict[str, object]
|
||||
totp_enabled: bool = False
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class RegistrationStatusOut(BaseModel):
|
||||
"""Public, unauthenticated: what the signup page should show."""
|
||||
|
||||
open: bool
|
||||
# True when the instance has no users yet, so signup creates the administrator.
|
||||
bootstrap: bool
|
||||
|
||||
|
||||
class AdminSettingsOut(BaseModel):
|
||||
registration_open: bool
|
||||
default_quota_bytes: int
|
||||
|
||||
|
||||
class AdminSettingsIn(BaseModel):
|
||||
"""Merge semantics: omitted fields are left as they are."""
|
||||
|
||||
registration_open: bool | None = None
|
||||
# Applies to users created after the change; existing mailboxes keep theirs.
|
||||
default_quota_bytes: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
|
||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
|
||||
from app.identity.passwords import hash_password, verify_password
|
||||
from app.models import User
|
||||
from app.models import AppSettings, User
|
||||
from app.redis_client import get_redis
|
||||
|
||||
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
|
||||
@@ -28,6 +29,15 @@ class RegistrationError(Exception):
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationStatus:
|
||||
"""What the signup page may offer right now."""
|
||||
|
||||
open: bool
|
||||
# True when the instance has no users: the next signup creates the administrator.
|
||||
bootstrap: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimited(Exception):
|
||||
"""Raised when the login rate limit is exceeded."""
|
||||
@@ -51,19 +61,64 @@ def resolve_handle(handle_input: str) -> str:
|
||||
return f"{normalize_localpart(text)}@{get_settings().app_domain}"
|
||||
|
||||
|
||||
async def app_settings(db: AsyncSession, *, for_update: bool = False) -> AppSettings:
|
||||
"""Load the singleton settings row, creating it if an old database lacks one.
|
||||
|
||||
``for_update`` takes a row lock, which is what serialises concurrent signups.
|
||||
A row conjured here defaults to *closed*: the safe answer when we cannot tell
|
||||
what the operator intended.
|
||||
"""
|
||||
stmt = select(AppSettings).where(AppSettings.id == 1)
|
||||
row = await db.scalar(stmt.with_for_update() if for_update else stmt)
|
||||
if row is None:
|
||||
await db.execute(
|
||||
pg_insert(AppSettings).values(id=1, registration_open=False).on_conflict_do_nothing()
|
||||
)
|
||||
row = await db.scalar(stmt.with_for_update() if for_update else stmt)
|
||||
assert row is not None # just inserted in this transaction
|
||||
return row
|
||||
|
||||
|
||||
async def _has_users(db: AsyncSession) -> bool:
|
||||
return await db.scalar(select(User.id).limit(1)) is not None
|
||||
|
||||
|
||||
async def registration_status(db: AsyncSession) -> RegistrationStatus:
|
||||
"""Whether signup is currently accepted, and whether it would bootstrap an admin."""
|
||||
bootstrap = not await _has_users(db)
|
||||
settings = await app_settings(db)
|
||||
return RegistrationStatus(open=bootstrap or settings.registration_open, bootstrap=bootstrap)
|
||||
|
||||
|
||||
async def register_user(
|
||||
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
|
||||
) -> User:
|
||||
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness."""
|
||||
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.
|
||||
|
||||
The first user on an empty instance is always allowed and becomes the
|
||||
administrator, which closes registration behind them; after that it takes an
|
||||
admin reopening it. The settings row is locked across the whole decision so
|
||||
two simultaneous first signups cannot both see an empty table and both claim
|
||||
administrator.
|
||||
"""
|
||||
try:
|
||||
handle = full_handle(localpart)
|
||||
except InvalidHandle as exc:
|
||||
raise RegistrationError(exc.code, exc.message) from exc
|
||||
|
||||
settings = await app_settings(db, for_update=True)
|
||||
bootstrap = not await _has_users(db)
|
||||
if not bootstrap and not settings.registration_open:
|
||||
raise RegistrationError("registration_closed", "registration is closed on this server")
|
||||
|
||||
user = User(
|
||||
handle=handle,
|
||||
password_hash=hash_password(password),
|
||||
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
|
||||
is_admin=bootstrap,
|
||||
# Snapshotted at creation: later changes to the instance default leave
|
||||
# this user's quota alone.
|
||||
quota_bytes=settings.default_quota_bytes,
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
@@ -71,6 +126,8 @@ async def register_user(
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise RegistrationError("handle_taken", "that handle is already registered") from exc
|
||||
if bootstrap:
|
||||
settings.registration_open = False
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -45,6 +46,32 @@ def _now() -> Mapped[dt.datetime]:
|
||||
return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()"))
|
||||
|
||||
|
||||
class AppSettings(Base):
|
||||
"""Instance-wide settings, exactly one row (id = 1).
|
||||
|
||||
Distinct from :func:`app.config.get_settings`, which is deployment environment
|
||||
read at boot; these are options an administrator changes at runtime from the
|
||||
admin panel. Locking this row serialises the registration decision below.
|
||||
"""
|
||||
|
||||
__tablename__ = "app_settings"
|
||||
__table_args__ = (
|
||||
CheckConstraint("id = 1", name="ck_app_settings_singleton"),
|
||||
CheckConstraint("default_quota_bytes > 0", name="ck_app_settings_quota_positive"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
|
||||
# Whether POST /auth/register accepts new users. Closed automatically once the
|
||||
# first account (the administrator) is created; an admin reopens it later.
|
||||
registration_open: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||
# Quota handed to the NEXT user created. Existing users keep whatever
|
||||
# users.quota_bytes already says — changing this never resizes a mailbox.
|
||||
default_quota_bytes: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, server_default=text("5368709120")
|
||||
)
|
||||
updated_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -61,6 +88,9 @@ class User(Base):
|
||||
quota_bytes: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, server_default=text("5368709120")
|
||||
)
|
||||
# The first account created on a fresh install bootstraps as administrator;
|
||||
# the admin panel can grant it to others later.
|
||||
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||
created_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""bootstrap administrator + registration gate
|
||||
|
||||
The first account created on a fresh install becomes the administrator and
|
||||
registration closes behind it, so an exposed port cannot be claimed by a
|
||||
stranger. Existing installs keep their oldest account as administrator and start
|
||||
closed; an admin reopens registration from the admin panel.
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0016"
|
||||
down_revision: str | None = "0015"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE users ADD COLUMN is_admin boolean NOT NULL DEFAULT false")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE app_settings (
|
||||
id integer PRIMARY KEY DEFAULT 1,
|
||||
registration_open boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ck_app_settings_singleton CHECK (id = 1)
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Existing deployment: the oldest account is the one that set the instance up,
|
||||
# so it becomes the administrator. Ties on created_at break by id.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users SET is_admin = true
|
||||
WHERE id = (SELECT id FROM users ORDER BY created_at, id LIMIT 1)
|
||||
"""
|
||||
)
|
||||
# Fresh install (no users yet) starts open so the first signup can happen;
|
||||
# anything else starts closed.
|
||||
op.execute(
|
||||
"INSERT INTO app_settings (id, registration_open) "
|
||||
"VALUES (1, NOT EXISTS (SELECT 1 FROM users))"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS app_settings")
|
||||
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS is_admin")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""instance-wide default quota for new users
|
||||
|
||||
The per-user ``users.quota_bytes`` stays authoritative for accounts that already
|
||||
exist — changing the default must never resize anyone's mailbox retroactively.
|
||||
This is only the value handed to the next user created.
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0017"
|
||||
down_revision: str | None = "0016"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# 5 GiB — the same figure users.quota_bytes has defaulted to since 0001.
|
||||
_DEFAULT = 5 * 1024**3
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN default_quota_bytes bigint NOT NULL "
|
||||
f"DEFAULT {_DEFAULT}"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE app_settings ADD CONSTRAINT ck_app_settings_quota_positive "
|
||||
"CHECK (default_quota_bytes > 0)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE app_settings DROP CONSTRAINT IF EXISTS ck_app_settings_quota_positive")
|
||||
op.execute("ALTER TABLE app_settings DROP COLUMN IF EXISTS default_quota_bytes")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Registration policy: first user bootstraps as administrator, then signup closes.
|
||||
|
||||
The bootstrap cases run against the rollback-per-test ``db_session`` fixture, so
|
||||
they can empty the users table to simulate a fresh install without touching the
|
||||
dev database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.service import (
|
||||
RegistrationError,
|
||||
app_settings,
|
||||
register_user,
|
||||
registration_status,
|
||||
)
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import AppSettings, User
|
||||
|
||||
CSRF = "csrf-token"
|
||||
PASSWORD = "hunter2pass"
|
||||
|
||||
|
||||
def _localpart() -> str:
|
||||
return f"reg{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
# --- service level: the bootstrap decision ----------------------------------
|
||||
|
||||
|
||||
async def test_first_user_becomes_admin_and_closes_registration(db_session: AsyncSession) -> None:
|
||||
"""On an empty instance the first signup is allowed, is admin, and shuts the door."""
|
||||
await db_session.execute(delete(User))
|
||||
settings = await app_settings(db_session)
|
||||
settings.registration_open = False # even closed, an empty instance must bootstrap
|
||||
await db_session.flush()
|
||||
|
||||
status = await registration_status(db_session)
|
||||
assert (status.open, status.bootstrap) == (True, True)
|
||||
|
||||
first = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert first.is_admin is True
|
||||
|
||||
settings = await app_settings(db_session)
|
||||
assert settings.registration_open is False
|
||||
status = await registration_status(db_session)
|
||||
assert (status.open, status.bootstrap) == (False, False)
|
||||
|
||||
with pytest.raises(RegistrationError) as exc:
|
||||
await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert exc.value.code == "registration_closed"
|
||||
|
||||
|
||||
async def test_reopened_registration_admits_plain_users(db_session: AsyncSession) -> None:
|
||||
"""A second account created while open is an ordinary user, not an admin."""
|
||||
await db_session.execute(delete(User))
|
||||
await register_user(db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None)
|
||||
|
||||
settings = await app_settings(db_session)
|
||||
settings.registration_open = True
|
||||
await db_session.flush()
|
||||
|
||||
second = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert second.is_admin is False
|
||||
# Reopening is a deliberate admin act; it stays open until they close it.
|
||||
assert (await app_settings(db_session)).registration_open is True
|
||||
|
||||
|
||||
async def test_missing_settings_row_defaults_to_closed(db_session: AsyncSession) -> None:
|
||||
"""A database predating the settings row must not fall open."""
|
||||
await db_session.execute(delete(AppSettings))
|
||||
assert (await app_settings(db_session)).registration_open is False
|
||||
|
||||
|
||||
# --- API level ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api() -> AsyncIterator[tuple[httpx.AsyncClient, AsyncSession, uuid.UUID]]:
|
||||
"""A logged-in non-admin user plus a restored-on-exit app_settings row."""
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
before = await app_settings(session)
|
||||
was_open, was_quota = before.registration_open, before.default_quota_bytes
|
||||
user = User(handle=f"{_localpart()}@test.local", password_hash=hash_password(PASSWORD))
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield client, session, user.id
|
||||
finally:
|
||||
await client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.execute(
|
||||
update(AppSettings).values(registration_open=was_open, default_quota_bytes=was_quota)
|
||||
)
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _set_open(session: AsyncSession, value: bool) -> None:
|
||||
await session.execute(update(AppSettings).values(registration_open=value))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_register_is_refused_while_closed(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, _ = api
|
||||
await _set_open(session, False)
|
||||
|
||||
status = await client.get("/api/auth/registration")
|
||||
assert status.status_code == 200
|
||||
assert status.json() == {"open": False, "bootstrap": False}
|
||||
|
||||
refused = await client.post(
|
||||
"/api/auth/register", json={"localpart": _localpart(), "password": PASSWORD}
|
||||
)
|
||||
assert refused.status_code == 403
|
||||
assert refused.json()["error"]["code"] == "registration_closed"
|
||||
|
||||
|
||||
async def test_admin_settings_endpoint_requires_admin(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, user_id = api
|
||||
denied = await client.get("/api/admin/settings")
|
||||
assert denied.status_code == 403
|
||||
assert denied.json()["error"]["code"] == "not_admin"
|
||||
|
||||
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
|
||||
await session.commit()
|
||||
|
||||
assert (await client.get("/api/admin/settings")).status_code == 200
|
||||
opened = await client.put(
|
||||
"/api/admin/settings", json={"registration_open": True}, headers={"X-CSRF": CSRF}
|
||||
)
|
||||
assert opened.status_code == 200
|
||||
assert opened.json()["registration_open"] is True
|
||||
assert (await client.get("/api/auth/registration")).json()["open"] is True
|
||||
|
||||
|
||||
async def test_me_exposes_admin_flag(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, user_id = api
|
||||
assert (await client.get("/api/auth/me")).json()["is_admin"] is False
|
||||
|
||||
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
|
||||
await session.commit()
|
||||
assert (await client.get("/api/auth/me")).json()["is_admin"] is True
|
||||
|
||||
|
||||
async def test_settings_row_is_a_singleton(db_session: AsyncSession) -> None:
|
||||
"""The CHECK constraint is what stops a second row drifting into existence."""
|
||||
rows = (await db_session.execute(select(AppSettings.id))).scalars().all()
|
||||
assert rows == [1]
|
||||
|
||||
|
||||
# --- default quota -----------------------------------------------------------
|
||||
|
||||
_GIB = 1024**3
|
||||
|
||||
|
||||
async def test_default_quota_applies_only_to_later_users(db_session: AsyncSession) -> None:
|
||||
"""Raising the default must not resize a mailbox that already exists."""
|
||||
await db_session.execute(delete(User))
|
||||
settings = await app_settings(db_session)
|
||||
settings.default_quota_bytes = 5 * _GIB
|
||||
await db_session.flush()
|
||||
|
||||
first = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert first.quota_bytes == 5 * _GIB
|
||||
|
||||
settings = await app_settings(db_session)
|
||||
settings.default_quota_bytes = 20 * _GIB
|
||||
settings.registration_open = True
|
||||
await db_session.flush()
|
||||
|
||||
second = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert second.quota_bytes == 20 * _GIB
|
||||
# The whole point: the earlier account is untouched.
|
||||
await db_session.refresh(first)
|
||||
assert first.quota_bytes == 5 * _GIB
|
||||
|
||||
|
||||
async def test_admin_can_set_default_quota(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, user_id = api
|
||||
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
|
||||
await session.commit()
|
||||
# Establish the value the merge must preserve rather than assuming the
|
||||
# ambient one — the dev database is shared and its state moves around.
|
||||
await _set_open(session, True)
|
||||
|
||||
saved = await client.put(
|
||||
"/api/admin/settings",
|
||||
json={"default_quota_bytes": 10 * _GIB},
|
||||
headers={"X-CSRF": CSRF},
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["default_quota_bytes"] == 10 * _GIB
|
||||
# A merge, not a replace: the field we left out kept its value.
|
||||
assert saved.json()["registration_open"] is True
|
||||
|
||||
rejected = await client.put(
|
||||
"/api/admin/settings", json={"default_quota_bytes": 0}, headers={"X-CSRF": CSRF}
|
||||
)
|
||||
assert rejected.status_code == 422
|
||||
Reference in New Issue
Block a user