Persist runtime config in app db and seed from env
This commit is contained in:
@@ -58,6 +58,7 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco
|
||||
|
||||
- 单个 admin 用户
|
||||
- server-side session
|
||||
- runtime config 持久化
|
||||
|
||||
这部分现在也使用 Alembic 管理:
|
||||
|
||||
@@ -169,8 +170,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
- 认证模型:`username/password`
|
||||
- 会话模型:server-side session + cookie
|
||||
- 当前受保护页面:`/admin`
|
||||
- 当前公开页面:`/`、`/login`
|
||||
- 当前主要受保护页面:`/config`
|
||||
- 当前公开页面:`/login`
|
||||
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
|
||||
|
||||
安全实现的当前边界:
|
||||
@@ -193,6 +194,31 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
||||
|
||||
当前前端已经收敛为两条主路径:
|
||||
|
||||
- `/login`
|
||||
- `/config`
|
||||
|
||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`。
|
||||
|
||||
## Config 持久化
|
||||
|
||||
当前 config 页面已经不再把修改写回 `.env`。
|
||||
|
||||
当前原则是:
|
||||
|
||||
- `.env` 只负责 bootstrap / fallback
|
||||
- app 启动先从 `.env` 读取数据库地址等基础配置
|
||||
- 请求期读取配置时,优先使用 app DB 中的 `app_config` 表
|
||||
- 如果数据库里没有对应值,再 fallback 到 `.env`
|
||||
|
||||
这意味着:
|
||||
|
||||
- location / poo / app DB 地址仍然属于 bootstrap 范畴
|
||||
- 运行时可编辑配置主要通过 `app_config` 表持久化
|
||||
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
|
||||
- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.config import get_settings
|
||||
from app.models.config import AppConfigEntry # noqa: F401
|
||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""app config table
|
||||
|
||||
Revision ID: 20260420_04_app_config_table
|
||||
Revises: 20260420_03_app_auth_baseline
|
||||
Create Date: 2026-04-20 00:00:01.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260420_04_app_config_table"
|
||||
down_revision: Union[str, None] = "20260420_03_app_auth_baseline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"app_config",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("key", sa.String(length=255), nullable=False),
|
||||
sa.Column("value", sa.String(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_app_config_key"), "app_config", ["key"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_app_config_key"), table_name="app_config")
|
||||
op.drop_table("app_config")
|
||||
+15
-8
@@ -18,6 +18,7 @@ from app.services.auth import (
|
||||
revoke_session,
|
||||
validate_csrf_token,
|
||||
)
|
||||
from app.services.config_page import build_config_sections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
@@ -33,7 +34,7 @@ def login_page(
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is not None:
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
csrf_token = issue_login_csrf_token()
|
||||
response = templates.TemplateResponse(
|
||||
@@ -79,7 +80,7 @@ def login_submit(
|
||||
)
|
||||
|
||||
auth_session, raw_token = create_session(session, user=user, settings=settings)
|
||||
response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
|
||||
response.set_cookie(
|
||||
key=settings.auth_session_cookie_name,
|
||||
@@ -94,7 +95,7 @@ def login_submit(
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/admin/change-password", response_class=HTMLResponse)
|
||||
@router.post("/config/change-password", response_class=HTMLResponse)
|
||||
def change_password_submit(
|
||||
request: Request,
|
||||
current_password: str = Form(),
|
||||
@@ -110,9 +111,10 @@ def change_password_submit(
|
||||
|
||||
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
|
||||
logger.warning("Rejected password change attempt due to CSRF validation failure")
|
||||
return _render_admin_page(
|
||||
return _render_config_page(
|
||||
request,
|
||||
settings=settings,
|
||||
auth_db_session=session,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="invalid password change request",
|
||||
@@ -132,16 +134,17 @@ def change_password_submit(
|
||||
current_auth.user.username,
|
||||
exc,
|
||||
)
|
||||
return _render_admin_page(
|
||||
return _render_config_page(
|
||||
request,
|
||||
settings=settings,
|
||||
auth_db_session=session,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="password change failed",
|
||||
)
|
||||
|
||||
logger.info("Password updated for user '%s'", current_auth.user.username)
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@@ -200,17 +203,18 @@ def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token:
|
||||
)
|
||||
|
||||
|
||||
def _render_admin_page(
|
||||
def _render_config_page(
|
||||
request: Request,
|
||||
*,
|
||||
settings: Settings,
|
||||
auth_db_session: Session,
|
||||
current_auth: AuthenticatedSession,
|
||||
status_code: int,
|
||||
password_change_error: str | None,
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
"config.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
@@ -218,6 +222,9 @@ def _render_admin_page(
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": password_change_error,
|
||||
"config_error": None,
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
+87
-11
@@ -1,30 +1,45 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_current_auth_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
router = APIRouter(tags=["pages"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse:
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"notion_status": "Legacy scope, removed from the Python rewrite target.",
|
||||
}
|
||||
return templates.TemplateResponse(request, "home.html", context)
|
||||
def home(
|
||||
request: Request,
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(
|
||||
def admin_redirect(
|
||||
request: Request,
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/config", response_class=HTMLResponse)
|
||||
def config_page(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
@@ -38,5 +53,66 @@ def admin_page(
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": None,
|
||||
"config_saved": request.query_params.get("saved") == "1",
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
}
|
||||
return templates.TemplateResponse(request, "admin.html", context)
|
||||
return templates.TemplateResponse(request, "config.html", context)
|
||||
|
||||
|
||||
@router.post("/config", response_class=HTMLResponse)
|
||||
async def config_submit(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
form = await request.form()
|
||||
csrf_token = form.get("csrf_token")
|
||||
if csrf_token != current_auth.session.csrf_token:
|
||||
logger.warning("Rejected config update due to CSRF validation failure")
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": "invalid config update request",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"config.html",
|
||||
context,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
save_config_updates(auth_db_session, dict(form), settings)
|
||||
except ConfigSaveError:
|
||||
logger.warning("Rejected config update due to invalid submitted values")
|
||||
refreshed_settings = build_runtime_settings(auth_db_session, get_settings())
|
||||
context = {
|
||||
"app_name": refreshed_settings.app_name,
|
||||
"app_env": refreshed_settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": "invalid config submission",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"config.html",
|
||||
context,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
+7
-6
@@ -9,16 +9,17 @@ from app.db import get_db_session
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.poo_db import get_poo_db_session
|
||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||
|
||||
|
||||
def get_app_settings() -> Settings:
|
||||
return get_settings()
|
||||
from app.services.config_page import build_runtime_settings
|
||||
|
||||
|
||||
def get_auth_db() -> Generator[Session, None, None]:
|
||||
yield from get_auth_db_session()
|
||||
|
||||
|
||||
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
|
||||
return build_runtime_settings(session, get_settings())
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
|
||||
@@ -27,8 +28,8 @@ def get_poo_db() -> Generator[Session, None, None]:
|
||||
yield from get_poo_db_session()
|
||||
|
||||
|
||||
def get_homeassistant_client() -> HomeAssistantClient:
|
||||
return HomeAssistantClient(get_settings())
|
||||
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
|
||||
return HomeAssistantClient(settings)
|
||||
|
||||
|
||||
def get_current_auth_session(
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from app.services.config_page import seed_missing_config_from_bootstrap
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
||||
@@ -25,6 +26,7 @@ def ensure_auth_db_ready() -> None:
|
||||
try:
|
||||
validate_app_runtime_db(get_settings().app_database_url)
|
||||
initialize_auth_schema(session, get_settings())
|
||||
seed_missing_config_from_bootstrap(session, get_settings())
|
||||
except AppDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
except AuthBootstrapError as exc:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""SQLAlchemy models package."""
|
||||
|
||||
from app.models.auth import AuthSession, AuthUser
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.location import Location
|
||||
|
||||
__all__ = ["AuthSession", "AuthUser", "Location"]
|
||||
__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
|
||||
|
||||
class AppConfigEntry(AuthBase):
|
||||
__tablename__ = "app_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
value: Mapped[str] = mapped_column(String, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.config import AppConfigEntry
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigField:
|
||||
section: str
|
||||
env_name: str
|
||||
setting_attr: str
|
||||
label: str
|
||||
secret: bool = False
|
||||
input_type: str = "text"
|
||||
|
||||
|
||||
CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
ConfigField("System", "APP_NAME", "app_name", "App Name"),
|
||||
ConfigField("System", "APP_ENV", "app_env", "App Env"),
|
||||
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
|
||||
ConfigField("System", "APP_HOST", "app_host", "App Host"),
|
||||
ConfigField("System", "APP_PORT", "app_port", "App Port"),
|
||||
ConfigField(
|
||||
"Authentication",
|
||||
"AUTH_SESSION_COOKIE_NAME",
|
||||
"auth_session_cookie_name",
|
||||
"Session Cookie Name",
|
||||
),
|
||||
ConfigField("Authentication", "AUTH_SESSION_TTL_HOURS", "auth_session_ttl_hours", "Session TTL Hours"),
|
||||
ConfigField(
|
||||
"Authentication",
|
||||
"AUTH_COOKIE_SECURE_OVERRIDE",
|
||||
"auth_cookie_secure_override",
|
||||
"Cookie Secure Override",
|
||||
),
|
||||
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
|
||||
ConfigField(
|
||||
"Poo",
|
||||
"POO_SENSOR_ENTITY_NAME",
|
||||
"poo_sensor_entity_name",
|
||||
"Poo Sensor Entity Name",
|
||||
),
|
||||
ConfigField(
|
||||
"Poo",
|
||||
"POO_SENSOR_FRIENDLY_NAME",
|
||||
"poo_sensor_friendly_name",
|
||||
"Poo Sensor Friendly Name",
|
||||
),
|
||||
ConfigField("TickTick", "TICKTICK_CLIENT_ID", "ticktick_client_id", "TickTick Client ID"),
|
||||
ConfigField(
|
||||
"TickTick",
|
||||
"TICKTICK_CLIENT_SECRET",
|
||||
"ticktick_client_secret",
|
||||
"TickTick Client Secret",
|
||||
secret=True,
|
||||
),
|
||||
ConfigField(
|
||||
"TickTick",
|
||||
"TICKTICK_REDIRECT_URI",
|
||||
"ticktick_redirect_uri",
|
||||
"TickTick Redirect URI",
|
||||
),
|
||||
ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_BASE_URL",
|
||||
"home_assistant_base_url",
|
||||
"Home Assistant Base URL",
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_AUTH_TOKEN",
|
||||
"home_assistant_auth_token",
|
||||
"Home Assistant Auth Token",
|
||||
secret=True,
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_TIMEOUT_SECONDS",
|
||||
"home_assistant_timeout_seconds",
|
||||
"Home Assistant Timeout Seconds",
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_ACTION_TASK_PROJECT_ID",
|
||||
"home_assistant_action_task_project_id",
|
||||
"Home Assistant Action Task Project ID",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ConfigSaveError(ValueError):
|
||||
"""Raised when the submitted config payload is invalid."""
|
||||
|
||||
|
||||
def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None:
|
||||
current_values = _read_config_values(session)
|
||||
missing_values: dict[str, str] = {}
|
||||
|
||||
for field in CONFIG_FIELDS:
|
||||
if field.env_name in current_values:
|
||||
continue
|
||||
missing_values[field.env_name] = _stringify(getattr(bootstrap_settings, field.setting_attr))
|
||||
|
||||
if not missing_values:
|
||||
return
|
||||
|
||||
_persist_config_values(session, {**current_values, **missing_values})
|
||||
|
||||
|
||||
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
|
||||
overrides = _read_config_values(session)
|
||||
if not overrides:
|
||||
return bootstrap_settings
|
||||
|
||||
payload = _settings_payload(bootstrap_settings)
|
||||
for field in CONFIG_FIELDS:
|
||||
if field.env_name in overrides:
|
||||
payload[field.setting_attr] = overrides[field.env_name]
|
||||
|
||||
return Settings(_env_file=None, **payload)
|
||||
|
||||
|
||||
def build_config_sections(session: Session, bootstrap_settings: Settings) -> list[dict[str, Any]]:
|
||||
runtime_settings = build_runtime_settings(session, bootstrap_settings)
|
||||
persisted_values = _read_config_values(session)
|
||||
sections: list[dict[str, Any]] = []
|
||||
current_section: dict[str, Any] | None = None
|
||||
|
||||
for field in CONFIG_FIELDS:
|
||||
if current_section is None or current_section["name"] != field.section:
|
||||
current_section = {"name": field.section, "fields": []}
|
||||
sections.append(current_section)
|
||||
|
||||
current_section["fields"].append(
|
||||
{
|
||||
"env_name": field.env_name,
|
||||
"label": field.label,
|
||||
"value": "" if field.secret else _stringify(getattr(runtime_settings, field.setting_attr)),
|
||||
"secret": field.secret,
|
||||
"input_type": "password" if field.secret else field.input_type,
|
||||
"configured": field.env_name in persisted_values
|
||||
or bool(_stringify(getattr(bootstrap_settings, field.setting_attr))),
|
||||
}
|
||||
)
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_settings: Settings) -> None:
|
||||
current_values = _read_config_values(session)
|
||||
merged_values = dict(current_values)
|
||||
|
||||
for field in CONFIG_FIELDS:
|
||||
submitted_value = form_data.get(field.env_name, "")
|
||||
if field.secret:
|
||||
if submitted_value:
|
||||
merged_values[field.env_name] = submitted_value
|
||||
else:
|
||||
merged_values[field.env_name] = submitted_value
|
||||
|
||||
_validate_config_values(merged_values, bootstrap_settings)
|
||||
_persist_config_values(session, merged_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
|
||||
def _read_config_values(session: Session) -> dict[str, str]:
|
||||
rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all()
|
||||
return {row.key: row.value for row in rows}
|
||||
|
||||
|
||||
def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None:
|
||||
payload = _settings_payload(bootstrap_settings)
|
||||
for field in CONFIG_FIELDS:
|
||||
if field.env_name in config_values:
|
||||
payload[field.setting_attr] = config_values[field.env_name]
|
||||
|
||||
try:
|
||||
Settings(_env_file=None, **payload)
|
||||
except Exception as exc:
|
||||
raise ConfigSaveError("invalid config submission") from exc
|
||||
|
||||
|
||||
def _persist_config_values(session: Session, config_values: dict[str, str]) -> None:
|
||||
existing_entries = {
|
||||
row.key: row
|
||||
for row in session.execute(select(AppConfigEntry)).scalars().all()
|
||||
}
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for env_name, value in config_values.items():
|
||||
entry = existing_entries.get(env_name)
|
||||
if entry is None:
|
||||
session.add(AppConfigEntry(key=env_name, value=value, updated_at=now))
|
||||
else:
|
||||
entry.value = value
|
||||
entry.updated_at = now
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return str(value).lower()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
return {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"app_debug": settings.app_debug,
|
||||
"app_host": settings.app_host,
|
||||
"app_port": settings.app_port,
|
||||
"app_database_url": settings.app_database_url,
|
||||
"location_database_url": settings.location_database_url,
|
||||
"poo_database_url": settings.poo_database_url,
|
||||
"ticktick_client_id": settings.ticktick_client_id,
|
||||
"ticktick_client_secret": settings.ticktick_client_secret,
|
||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
||||
"ticktick_token": settings.ticktick_token,
|
||||
"home_assistant_base_url": settings.home_assistant_base_url,
|
||||
"home_assistant_auth_token": settings.home_assistant_auth_token,
|
||||
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
|
||||
"home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
|
||||
"poo_webhook_id": settings.poo_webhook_id,
|
||||
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
|
||||
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
|
||||
"auth_bootstrap_username": settings.auth_bootstrap_username,
|
||||
"auth_bootstrap_password": settings.auth_bootstrap_password,
|
||||
"auth_session_cookie_name": settings.auth_session_cookie_name,
|
||||
"auth_session_ttl_hours": settings.auth_session_ttl_hours,
|
||||
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
|
||||
}
|
||||
@@ -61,6 +61,11 @@ h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.single-column {
|
||||
grid-template-columns: minmax(180px, 320px);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.meta div {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
@@ -136,6 +141,47 @@ button:hover {
|
||||
color: #8b2a2a;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(45, 106, 79, 0.08);
|
||||
border: 1px solid rgba(45, 106, 79, 0.14);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.config-block + .config-block {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.config-block h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(31, 41, 51, 0.08);
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.config-section legend {
|
||||
padding: 0 8px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.config-form label small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
margin: 24px auto;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Protected Area</p>
|
||||
<h1>Admin</h1>
|
||||
{% if force_password_change %}
|
||||
<p class="lead">
|
||||
首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。
|
||||
</p>
|
||||
|
||||
{% if password_change_error %}
|
||||
<div class="alert">{{ password_change_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="post" action="/admin/change-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Current Password</span>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>New Password</span>
|
||||
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">修改密码</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="lead">
|
||||
你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。
|
||||
</p>
|
||||
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
<dd>{{ current_username }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行环境</dt>
|
||||
<dd>{{ app_env }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>下一步</dt>
|
||||
<dd>在这里接入配置页面与更细的受保护操作。</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% endif %}
|
||||
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">登出</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Config · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>Config</h1>
|
||||
|
||||
{% if force_password_change %}
|
||||
<div class="alert">
|
||||
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if password_change_error %}
|
||||
<div class="alert">{{ password_change_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config_error %}
|
||||
<div class="alert">{{ config_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config_saved %}
|
||||
<div class="notice">config saved to .env. Some changes may require an app restart.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="meta single-column">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
<dd>admin</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="config-block">
|
||||
<h2>Change Password</h2>
|
||||
<form class="auth-form" method="post" action="/config/change-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Current Password</span>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>New Password</span>
|
||||
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">修改密码</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="config-block">
|
||||
<h2>Config</h2>
|
||||
<form class="config-form" method="post" action="/config">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
{% for section in config_sections %}
|
||||
<fieldset class="config-section">
|
||||
<legend>{{ section.name }}</legend>
|
||||
{% for field in section.fields %}
|
||||
<label>
|
||||
<span>{{ field.label }}</span>
|
||||
{% if field.secret %}
|
||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
|
||||
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
|
||||
{% else %}
|
||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit">Save Config</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">登出</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@
|
||||
<p class="eyebrow">Authentication</p>
|
||||
<h1>登录</h1>
|
||||
<p class="lead">
|
||||
这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。
|
||||
登录成功后会进入受保护的 config 页面。
|
||||
</p>
|
||||
|
||||
{% if error_message %}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
- Pydantic schemas
|
||||
- `services/`
|
||||
- 业务服务层
|
||||
- 当前已迁入 config page 的 DB 持久化逻辑
|
||||
- `integrations/`
|
||||
- 外部系统适配层
|
||||
- 当前已迁入 Home Assistant outbound adapter
|
||||
|
||||
+13
-3
@@ -34,6 +34,7 @@
|
||||
|
||||
- `auth_users`
|
||||
- `auth_sessions`
|
||||
- `app_config`
|
||||
|
||||
当前没有把 auth 数据和 `location` / `poo` DB 混放。
|
||||
|
||||
@@ -44,6 +45,14 @@
|
||||
|
||||
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
|
||||
|
||||
`app_config` 现在承接运行时配置持久化。
|
||||
|
||||
其中:
|
||||
|
||||
- `.env` 负责 bootstrap / fallback
|
||||
- `app_config` 表负责运行时配置覆盖
|
||||
- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config`
|
||||
|
||||
## 首次启动与 bootstrap
|
||||
|
||||
如果 auth DB 中还没有任何用户,应用启动时会要求:
|
||||
@@ -89,8 +98,9 @@
|
||||
|
||||
当前这轮只保护了页面入口:
|
||||
|
||||
- `GET /admin`
|
||||
- `POST /admin/change-password`
|
||||
- `GET /config`
|
||||
- `POST /config`
|
||||
- `POST /config/change-password`
|
||||
- `POST /logout`
|
||||
|
||||
相关流程:
|
||||
@@ -98,7 +108,7 @@
|
||||
- `GET /login`
|
||||
- `POST /login`
|
||||
|
||||
未登录访问 `/admin` 时会被重定向到 `/login`。
|
||||
未登录访问 `/config` 时会被重定向到 `/login`。
|
||||
|
||||
## 下一步不在本轮内
|
||||
|
||||
|
||||
+111
-2
@@ -87,6 +87,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/change-password": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Change Password Submit",
|
||||
"operationId": "change_password_submit_config_change_password_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/x-www-form-urlencoded": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_change_password_submit_config_change_password_post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -152,8 +193,48 @@
|
||||
"tags": [
|
||||
"pages"
|
||||
],
|
||||
"summary": "Admin Page",
|
||||
"operationId": "admin_page_admin_get",
|
||||
"summary": "Admin Redirect",
|
||||
"operationId": "admin_redirect_admin_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"pages"
|
||||
],
|
||||
"summary": "Config Page",
|
||||
"operationId": "config_page_config_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"pages"
|
||||
],
|
||||
"summary": "Config Submit",
|
||||
"operationId": "config_submit_config_post",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -247,6 +328,34 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Body_change_password_submit_config_change_password_post": {
|
||||
"properties": {
|
||||
"current_password": {
|
||||
"type": "string",
|
||||
"title": "Current Password"
|
||||
},
|
||||
"new_password": {
|
||||
"type": "string",
|
||||
"title": "New Password"
|
||||
},
|
||||
"confirm_password": {
|
||||
"type": "string",
|
||||
"title": "Confirm Password"
|
||||
},
|
||||
"csrf_token": {
|
||||
"type": "string",
|
||||
"title": "Csrf Token"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"current_password",
|
||||
"new_password",
|
||||
"confirm_password",
|
||||
"csrf_token"
|
||||
],
|
||||
"title": "Body_change_password_submit_config_change_password_post"
|
||||
},
|
||||
"Body_login_submit_login_post": {
|
||||
"properties": {
|
||||
"username": {
|
||||
|
||||
+73
-2
@@ -55,6 +55,31 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/config/change-password:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: Change Password Submit
|
||||
operationId: change_password_submit_config_change_password_post
|
||||
requestBody:
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Body_change_password_submit_config_change_password_post'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/logout:
|
||||
post:
|
||||
tags:
|
||||
@@ -96,8 +121,33 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- pages
|
||||
summary: Admin Page
|
||||
operationId: admin_page_admin_get
|
||||
summary: Admin Redirect
|
||||
operationId: admin_redirect_admin_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
/config:
|
||||
get:
|
||||
tags:
|
||||
- pages
|
||||
summary: Config Page
|
||||
operationId: config_page_config_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
post:
|
||||
tags:
|
||||
- pages
|
||||
summary: Config Submit
|
||||
operationId: config_submit_config_post
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -155,6 +205,27 @@ paths:
|
||||
schema: {}
|
||||
components:
|
||||
schemas:
|
||||
Body_change_password_submit_config_change_password_post:
|
||||
properties:
|
||||
current_password:
|
||||
type: string
|
||||
title: Current Password
|
||||
new_password:
|
||||
type: string
|
||||
title: New Password
|
||||
confirm_password:
|
||||
type: string
|
||||
title: Confirm Password
|
||||
csrf_token:
|
||||
type: string
|
||||
title: Csrf Token
|
||||
type: object
|
||||
required:
|
||||
- current_password
|
||||
- new_password
|
||||
- confirm_password
|
||||
- csrf_token
|
||||
title: Body_change_password_submit_config_change_password_post
|
||||
Body_login_submit_login_post:
|
||||
properties:
|
||||
username:
|
||||
|
||||
@@ -13,7 +13,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
APP_BASELINE_REVISION = "20260420_03_app_auth_baseline"
|
||||
APP_BASELINE_REVISION = "20260420_04_app_config_table"
|
||||
|
||||
|
||||
class AppDatabaseAdoptionError(RuntimeError):
|
||||
@@ -102,13 +102,10 @@ def adopt_or_initialize_app_db(database_url: str) -> str:
|
||||
if database_path.exists():
|
||||
if _alembic_version_table_exists(database_path):
|
||||
current_revision = _fetch_alembic_revision(database_path)
|
||||
if current_revision != APP_BASELINE_REVISION:
|
||||
raise AppDatabaseAdoptionError(
|
||||
"App DB is already Alembic-managed but revision does not match "
|
||||
f"the expected baseline: expected {APP_BASELINE_REVISION}, "
|
||||
f"got {current_revision}"
|
||||
)
|
||||
return "already_managed"
|
||||
if current_revision == APP_BASELINE_REVISION:
|
||||
return "already_managed"
|
||||
command.upgrade(alembic_config, "head")
|
||||
return "upgraded"
|
||||
|
||||
existing_tables = _list_user_tables(database_path)
|
||||
if existing_tables:
|
||||
@@ -127,6 +124,8 @@ def main() -> None:
|
||||
result = adopt_or_initialize_app_db(settings.app_database_url)
|
||||
if result == "initialized":
|
||||
print("Initialized a new app DB via Alembic upgrade head.")
|
||||
elif result == "upgraded":
|
||||
print("Upgraded existing app DB to the expected Alembic head revision.")
|
||||
else:
|
||||
print("App DB is already Alembic-managed at the expected baseline revision.")
|
||||
|
||||
|
||||
+49
-3
@@ -25,8 +25,9 @@ def _prepare_app_db(tmp_path) -> str:
|
||||
|
||||
|
||||
def test_app_starts(client: TestClient) -> None:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
response = client.get("/", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_status_endpoint(client: TestClient) -> None:
|
||||
@@ -73,11 +74,56 @@ def test_app_db_adoption_initializes_new_database(tmp_path) -> None:
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
}
|
||||
assert {"auth_users", "auth_sessions", "alembic_version"} <= tables
|
||||
assert {"auth_users", "auth_sessions", "app_config", "alembic_version"} <= tables
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_values(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
app_database_url = _prepare_app_db(tmp_path)
|
||||
location_database_path = tmp_path / "location_ready.db"
|
||||
poo_database_path = tmp_path / "poo_ready.db"
|
||||
command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head")
|
||||
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
|
||||
|
||||
app_database_path = tmp_path / "app_ready.db"
|
||||
conn = sqlite3.connect(app_database_path)
|
||||
conn.execute(
|
||||
"INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
("APP_NAME", "Database Owned Name"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("APP_NAME", "Bootstrap Name")
|
||||
monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://bootstrap-ha.local:8123")
|
||||
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}")
|
||||
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
app = create_app()
|
||||
anyio.run(_run_lifespan, app)
|
||||
|
||||
conn = sqlite3.connect(app_database_path)
|
||||
try:
|
||||
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert rows["APP_NAME"] == "Database Owned Name"
|
||||
assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123"
|
||||
assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session"
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
|
||||
def test_app_start_fails_when_location_db_missing(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
+94
-14
@@ -1,7 +1,11 @@
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
||||
@@ -9,8 +13,16 @@ def _extract_csrf_token(html: str) -> str:
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_unauthenticated_admin_redirects_to_login(client: TestClient) -> None:
|
||||
response = client.get("/admin", follow_redirects=False)
|
||||
def _stringify_for_form(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return str(value).lower()
|
||||
return str(value)
|
||||
|
||||
|
||||
def test_unauthenticated_config_redirects_to_login(client: TestClient) -> None:
|
||||
response = client.get("/config", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
@@ -31,18 +43,19 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/admin"
|
||||
assert response.headers["location"] == "/config"
|
||||
set_cookie_header = response.headers["set-cookie"].lower()
|
||||
assert "home_automation_session=" in set_cookie_header
|
||||
assert "httponly" in set_cookie_header
|
||||
assert "samesite=lax" in set_cookie_header
|
||||
|
||||
admin_response = client.get("/admin")
|
||||
assert admin_response.status_code == 200
|
||||
assert "首次登录后需要先修改密码" in admin_response.text
|
||||
assert "Current Password" in admin_response.text
|
||||
assert "New Password" in admin_response.text
|
||||
assert "当前用户" not in admin_response.text
|
||||
config_response = client.get("/config")
|
||||
assert config_response.status_code == 200
|
||||
assert "首次登录后需要先修改密码" in config_response.text
|
||||
assert "Current Password" in config_response.text
|
||||
assert "New Password" in config_response.text
|
||||
assert "Save Config" in config_response.text
|
||||
assert "当前用户" in config_response.text
|
||||
|
||||
|
||||
def test_login_failure_returns_generic_error(client: TestClient) -> None:
|
||||
@@ -76,8 +89,8 @@ def test_logout_revokes_session(client: TestClient) -> None:
|
||||
},
|
||||
)
|
||||
|
||||
admin_page = client.get("/admin")
|
||||
logout_csrf_token = _extract_csrf_token(admin_page.text)
|
||||
config_page = client.get("/config")
|
||||
logout_csrf_token = _extract_csrf_token(config_page.text)
|
||||
|
||||
logout_response = client.post(
|
||||
"/logout",
|
||||
@@ -88,9 +101,9 @@ def test_logout_revokes_session(client: TestClient) -> None:
|
||||
assert logout_response.status_code == 303
|
||||
assert logout_response.headers["location"] == "/login"
|
||||
|
||||
admin_after_logout = client.get("/admin", follow_redirects=False)
|
||||
assert admin_after_logout.status_code == 303
|
||||
assert admin_after_logout.headers["location"] == "/login"
|
||||
config_after_logout = client.get("/config", follow_redirects=False)
|
||||
assert config_after_logout.status_code == 303
|
||||
assert config_after_logout.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_login_rejects_invalid_csrf(client: TestClient) -> None:
|
||||
@@ -107,3 +120,70 @@ def test_login_rejects_invalid_csrf(client: TestClient) -> None:
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid login request" in response.text
|
||||
|
||||
|
||||
def test_legacy_admin_route_redirects_to_config_when_authenticated(client: TestClient) -> None:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/admin", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config"
|
||||
|
||||
|
||||
def test_config_page_update_persists_to_database(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
config_page = client.get("/config")
|
||||
config_csrf_token = _extract_csrf_token(config_page.text)
|
||||
settings = get_settings()
|
||||
|
||||
form_data = {"csrf_token": config_csrf_token}
|
||||
from app.services.config_page import CONFIG_FIELDS
|
||||
|
||||
for field in CONFIG_FIELDS:
|
||||
if field.secret:
|
||||
form_data[field.env_name] = ""
|
||||
else:
|
||||
form_data[field.env_name] = _stringify_for_form(getattr(settings, field.setting_attr))
|
||||
|
||||
form_data["APP_NAME"] = "Updated Home Automation"
|
||||
form_data["HOME_ASSISTANT_AUTH_TOKEN"] = "new-token"
|
||||
|
||||
response = client.post("/config", data=form_data, follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config?saved=1"
|
||||
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert rows["APP_NAME"] == "Updated Home Automation"
|
||||
assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token"
|
||||
assert "AUTH_BOOTSTRAP_USERNAME" not in rows
|
||||
|
||||
Reference in New Issue
Block a user