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 用户
|
- 单个 admin 用户
|
||||||
- server-side session
|
- server-side session
|
||||||
|
- runtime config 持久化
|
||||||
|
|
||||||
这部分现在也使用 Alembic 管理:
|
这部分现在也使用 Alembic 管理:
|
||||||
|
|
||||||
@@ -169,8 +170,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
- 认证模型:`username/password`
|
- 认证模型:`username/password`
|
||||||
- 会话模型:server-side session + cookie
|
- 会话模型:server-side session + cookie
|
||||||
- 当前受保护页面:`/admin`
|
- 当前主要受保护页面:`/config`
|
||||||
- 当前公开页面:`/`、`/login`
|
- 当前公开页面:`/login`
|
||||||
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
|
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
|
||||||
|
|
||||||
安全实现的当前边界:
|
安全实现的当前边界:
|
||||||
@@ -193,6 +194,31 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
首次登录后会被要求立即修改密码。这个 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
|
```bash
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import engine_from_config, pool
|
|||||||
|
|
||||||
from app.auth_db import AuthBase
|
from app.auth_db import AuthBase
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.models.config import AppConfigEntry # noqa: F401
|
||||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||||
|
|
||||||
config = context.config
|
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,
|
revoke_session,
|
||||||
validate_csrf_token,
|
validate_csrf_token,
|
||||||
)
|
)
|
||||||
|
from app.services.config_page import build_config_sections
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
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),
|
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
if current_auth is not None:
|
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()
|
csrf_token = issue_login_csrf_token()
|
||||||
response = templates.TemplateResponse(
|
response = templates.TemplateResponse(
|
||||||
@@ -79,7 +80,7 @@ def login_submit(
|
|||||||
)
|
)
|
||||||
|
|
||||||
auth_session, raw_token = create_session(session, user=user, settings=settings)
|
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.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=settings.auth_session_cookie_name,
|
key=settings.auth_session_cookie_name,
|
||||||
@@ -94,7 +95,7 @@ def login_submit(
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/change-password", response_class=HTMLResponse)
|
@router.post("/config/change-password", response_class=HTMLResponse)
|
||||||
def change_password_submit(
|
def change_password_submit(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_password: str = Form(),
|
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):
|
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")
|
logger.warning("Rejected password change attempt due to CSRF validation failure")
|
||||||
return _render_admin_page(
|
return _render_config_page(
|
||||||
request,
|
request,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
auth_db_session=session,
|
||||||
current_auth=current_auth,
|
current_auth=current_auth,
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
password_change_error="invalid password change request",
|
password_change_error="invalid password change request",
|
||||||
@@ -132,16 +134,17 @@ def change_password_submit(
|
|||||||
current_auth.user.username,
|
current_auth.user.username,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return _render_admin_page(
|
return _render_config_page(
|
||||||
request,
|
request,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
auth_db_session=session,
|
||||||
current_auth=current_auth,
|
current_auth=current_auth,
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
password_change_error="password change failed",
|
password_change_error="password change failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Password updated for user '%s'", current_auth.user.username)
|
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")
|
@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,
|
request: Request,
|
||||||
*,
|
*,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
auth_db_session: Session,
|
||||||
current_auth: AuthenticatedSession,
|
current_auth: AuthenticatedSession,
|
||||||
status_code: int,
|
status_code: int,
|
||||||
password_change_error: str | None,
|
password_change_error: str | None,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"admin.html",
|
"config.html",
|
||||||
{
|
{
|
||||||
"app_name": settings.app_name,
|
"app_name": settings.app_name,
|
||||||
"app_env": settings.app_env,
|
"app_env": settings.app_env,
|
||||||
@@ -218,6 +222,9 @@ def _render_admin_page(
|
|||||||
"csrf_token": current_auth.session.csrf_token,
|
"csrf_token": current_auth.session.csrf_token,
|
||||||
"force_password_change": current_auth.user.force_password_change,
|
"force_password_change": current_auth.user.force_password_change,
|
||||||
"password_change_error": password_change_error,
|
"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,
|
status_code=status_code,
|
||||||
)
|
)
|
||||||
|
|||||||
+87
-11
@@ -1,30 +1,45 @@
|
|||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, status
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings, get_settings
|
||||||
from app.dependencies import get_app_settings, get_current_auth_session
|
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
||||||
from app.services.auth import AuthenticatedSession
|
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"))
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||||
router = APIRouter(tags=["pages"])
|
router = APIRouter(tags=["pages"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse:
|
def home(
|
||||||
context = {
|
request: Request,
|
||||||
"app_name": settings.app_name,
|
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||||
"app_env": settings.app_env,
|
) -> RedirectResponse:
|
||||||
"notion_status": "Legacy scope, removed from the Python rewrite target.",
|
if current_auth is None:
|
||||||
}
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
return templates.TemplateResponse(request, "home.html", context)
|
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin", response_class=HTMLResponse)
|
@router.get("/admin", response_class=HTMLResponse)
|
||||||
def admin_page(
|
def admin_redirect(
|
||||||
request: Request,
|
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),
|
settings: Settings = Depends(get_app_settings),
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -38,5 +53,66 @@ def admin_page(
|
|||||||
"csrf_token": current_auth.session.csrf_token,
|
"csrf_token": current_auth.session.csrf_token,
|
||||||
"force_password_change": current_auth.user.force_password_change,
|
"force_password_change": current_auth.user.force_password_change,
|
||||||
"password_change_error": None,
|
"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.integrations.homeassistant import HomeAssistantClient
|
||||||
from app.poo_db import get_poo_db_session
|
from app.poo_db import get_poo_db_session
|
||||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||||
|
from app.services.config_page import build_runtime_settings
|
||||||
|
|
||||||
def get_app_settings() -> Settings:
|
|
||||||
return get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_db() -> Generator[Session, None, None]:
|
def get_auth_db() -> Generator[Session, None, None]:
|
||||||
yield from get_auth_db_session()
|
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]:
|
def get_db() -> Generator[Session, None, None]:
|
||||||
yield from get_db_session()
|
yield from get_db_session()
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ def get_poo_db() -> Generator[Session, None, None]:
|
|||||||
yield from get_poo_db_session()
|
yield from get_poo_db_session()
|
||||||
|
|
||||||
|
|
||||||
def get_homeassistant_client() -> HomeAssistantClient:
|
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
|
||||||
return HomeAssistantClient(get_settings())
|
return HomeAssistantClient(settings)
|
||||||
|
|
||||||
|
|
||||||
def get_current_auth_session(
|
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.api.routes.poo import router as poo_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
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.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
||||||
@@ -25,6 +26,7 @@ def ensure_auth_db_ready() -> None:
|
|||||||
try:
|
try:
|
||||||
validate_app_runtime_db(get_settings().app_database_url)
|
validate_app_runtime_db(get_settings().app_database_url)
|
||||||
initialize_auth_schema(session, get_settings())
|
initialize_auth_schema(session, get_settings())
|
||||||
|
seed_missing_config_from_bootstrap(session, get_settings())
|
||||||
except AppDatabaseAdoptionError as exc:
|
except AppDatabaseAdoptionError as exc:
|
||||||
raise RuntimeError(str(exc)) from exc
|
raise RuntimeError(str(exc)) from exc
|
||||||
except AuthBootstrapError as exc:
|
except AuthBootstrapError as exc:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""SQLAlchemy models package."""
|
"""SQLAlchemy models package."""
|
||||||
|
|
||||||
from app.models.auth import AuthSession, AuthUser
|
from app.models.auth import AuthSession, AuthUser
|
||||||
|
from app.models.config import AppConfigEntry
|
||||||
from app.models.location import Location
|
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;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-column {
|
||||||
|
grid-template-columns: minmax(180px, 320px);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.meta div {
|
.meta div {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -136,6 +141,47 @@ button:hover {
|
|||||||
color: #8b2a2a;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.shell {
|
.shell {
|
||||||
margin: 24px auto;
|
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>
|
<p class="eyebrow">Authentication</p>
|
||||||
<h1>登录</h1>
|
<h1>登录</h1>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。
|
登录成功后会进入受保护的 config 页面。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if error_message %}
|
{% if error_message %}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
- Pydantic schemas
|
- Pydantic schemas
|
||||||
- `services/`
|
- `services/`
|
||||||
- 业务服务层
|
- 业务服务层
|
||||||
|
- 当前已迁入 config page 的 DB 持久化逻辑
|
||||||
- `integrations/`
|
- `integrations/`
|
||||||
- 外部系统适配层
|
- 外部系统适配层
|
||||||
- 当前已迁入 Home Assistant outbound adapter
|
- 当前已迁入 Home Assistant outbound adapter
|
||||||
|
|||||||
+13
-3
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
- `auth_users`
|
- `auth_users`
|
||||||
- `auth_sessions`
|
- `auth_sessions`
|
||||||
|
- `app_config`
|
||||||
|
|
||||||
当前没有把 auth 数据和 `location` / `poo` DB 混放。
|
当前没有把 auth 数据和 `location` / `poo` DB 混放。
|
||||||
|
|
||||||
@@ -44,6 +45,14 @@
|
|||||||
|
|
||||||
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
|
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
|
||||||
|
|
||||||
|
`app_config` 现在承接运行时配置持久化。
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `.env` 负责 bootstrap / fallback
|
||||||
|
- `app_config` 表负责运行时配置覆盖
|
||||||
|
- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config`
|
||||||
|
|
||||||
## 首次启动与 bootstrap
|
## 首次启动与 bootstrap
|
||||||
|
|
||||||
如果 auth DB 中还没有任何用户,应用启动时会要求:
|
如果 auth DB 中还没有任何用户,应用启动时会要求:
|
||||||
@@ -89,8 +98,9 @@
|
|||||||
|
|
||||||
当前这轮只保护了页面入口:
|
当前这轮只保护了页面入口:
|
||||||
|
|
||||||
- `GET /admin`
|
- `GET /config`
|
||||||
- `POST /admin/change-password`
|
- `POST /config`
|
||||||
|
- `POST /config/change-password`
|
||||||
- `POST /logout`
|
- `POST /logout`
|
||||||
|
|
||||||
相关流程:
|
相关流程:
|
||||||
@@ -98,7 +108,7 @@
|
|||||||
- `GET /login`
|
- `GET /login`
|
||||||
- `POST /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": {
|
"/logout": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -152,8 +193,48 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"pages"
|
"pages"
|
||||||
],
|
],
|
||||||
"summary": "Admin Page",
|
"summary": "Admin Redirect",
|
||||||
"operationId": "admin_page_admin_get",
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -247,6 +328,34 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"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": {
|
"Body_login_submit_login_post": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"username": {
|
"username": {
|
||||||
|
|||||||
+73
-2
@@ -55,6 +55,31 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$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:
|
/logout:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -96,8 +121,33 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- pages
|
- pages
|
||||||
summary: Admin Page
|
summary: Admin Redirect
|
||||||
operationId: admin_page_admin_get
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successful Response
|
description: Successful Response
|
||||||
@@ -155,6 +205,27 @@ paths:
|
|||||||
schema: {}
|
schema: {}
|
||||||
components:
|
components:
|
||||||
schemas:
|
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:
|
Body_login_submit_login_post:
|
||||||
properties:
|
properties:
|
||||||
username:
|
username:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
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):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
@@ -102,13 +102,10 @@ def adopt_or_initialize_app_db(database_url: str) -> str:
|
|||||||
if database_path.exists():
|
if database_path.exists():
|
||||||
if _alembic_version_table_exists(database_path):
|
if _alembic_version_table_exists(database_path):
|
||||||
current_revision = _fetch_alembic_revision(database_path)
|
current_revision = _fetch_alembic_revision(database_path)
|
||||||
if current_revision != APP_BASELINE_REVISION:
|
if current_revision == APP_BASELINE_REVISION:
|
||||||
raise AppDatabaseAdoptionError(
|
return "already_managed"
|
||||||
"App DB is already Alembic-managed but revision does not match "
|
command.upgrade(alembic_config, "head")
|
||||||
f"the expected baseline: expected {APP_BASELINE_REVISION}, "
|
return "upgraded"
|
||||||
f"got {current_revision}"
|
|
||||||
)
|
|
||||||
return "already_managed"
|
|
||||||
|
|
||||||
existing_tables = _list_user_tables(database_path)
|
existing_tables = _list_user_tables(database_path)
|
||||||
if existing_tables:
|
if existing_tables:
|
||||||
@@ -127,6 +124,8 @@ def main() -> None:
|
|||||||
result = adopt_or_initialize_app_db(settings.app_database_url)
|
result = adopt_or_initialize_app_db(settings.app_database_url)
|
||||||
if result == "initialized":
|
if result == "initialized":
|
||||||
print("Initialized a new app DB via Alembic upgrade head.")
|
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:
|
else:
|
||||||
print("App DB is already Alembic-managed at the expected baseline revision.")
|
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:
|
def test_app_starts(client: TestClient) -> None:
|
||||||
response = client.get("/")
|
response = client.get("/", follow_redirects=False)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
def test_status_endpoint(client: TestClient) -> None:
|
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_%'"
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
assert {"auth_users", "auth_sessions", "alembic_version"} <= tables
|
assert {"auth_users", "auth_sessions", "app_config", "alembic_version"} <= tables
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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(
|
def test_app_start_fails_when_location_db_missing(
|
||||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+94
-14
@@ -1,7 +1,11 @@
|
|||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
def _extract_csrf_token(html: str) -> str:
|
def _extract_csrf_token(html: str) -> str:
|
||||||
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
||||||
@@ -9,8 +13,16 @@ def _extract_csrf_token(html: str) -> str:
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
def test_unauthenticated_admin_redirects_to_login(client: TestClient) -> None:
|
def _stringify_for_form(value) -> str:
|
||||||
response = client.get("/admin", follow_redirects=False)
|
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.status_code == 303
|
||||||
assert response.headers["location"] == "/login"
|
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.status_code == 303
|
||||||
assert response.headers["location"] == "/admin"
|
assert response.headers["location"] == "/config"
|
||||||
set_cookie_header = response.headers["set-cookie"].lower()
|
set_cookie_header = response.headers["set-cookie"].lower()
|
||||||
assert "home_automation_session=" in set_cookie_header
|
assert "home_automation_session=" in set_cookie_header
|
||||||
assert "httponly" in set_cookie_header
|
assert "httponly" in set_cookie_header
|
||||||
assert "samesite=lax" in set_cookie_header
|
assert "samesite=lax" in set_cookie_header
|
||||||
|
|
||||||
admin_response = client.get("/admin")
|
config_response = client.get("/config")
|
||||||
assert admin_response.status_code == 200
|
assert config_response.status_code == 200
|
||||||
assert "首次登录后需要先修改密码" in admin_response.text
|
assert "首次登录后需要先修改密码" in config_response.text
|
||||||
assert "Current Password" in admin_response.text
|
assert "Current Password" in config_response.text
|
||||||
assert "New Password" in admin_response.text
|
assert "New Password" in config_response.text
|
||||||
assert "当前用户" not in admin_response.text
|
assert "Save Config" in config_response.text
|
||||||
|
assert "当前用户" in config_response.text
|
||||||
|
|
||||||
|
|
||||||
def test_login_failure_returns_generic_error(client: TestClient) -> None:
|
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")
|
config_page = client.get("/config")
|
||||||
logout_csrf_token = _extract_csrf_token(admin_page.text)
|
logout_csrf_token = _extract_csrf_token(config_page.text)
|
||||||
|
|
||||||
logout_response = client.post(
|
logout_response = client.post(
|
||||||
"/logout",
|
"/logout",
|
||||||
@@ -88,9 +101,9 @@ def test_logout_revokes_session(client: TestClient) -> None:
|
|||||||
assert logout_response.status_code == 303
|
assert logout_response.status_code == 303
|
||||||
assert logout_response.headers["location"] == "/login"
|
assert logout_response.headers["location"] == "/login"
|
||||||
|
|
||||||
admin_after_logout = client.get("/admin", follow_redirects=False)
|
config_after_logout = client.get("/config", follow_redirects=False)
|
||||||
assert admin_after_logout.status_code == 303
|
assert config_after_logout.status_code == 303
|
||||||
assert admin_after_logout.headers["location"] == "/login"
|
assert config_after_logout.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
def test_login_rejects_invalid_csrf(client: TestClient) -> None:
|
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 response.status_code == 400
|
||||||
assert "invalid login request" in response.text
|
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