diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py deleted file mode 100644 index d80846f..0000000 --- a/app/api/routes/auth.py +++ /dev/null @@ -1,234 +0,0 @@ -import logging -from pathlib import Path - -from fastapi import APIRouter, Depends, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse, Response -from fastapi.templating import Jinja2Templates -from sqlalchemy.orm import Session - -from app.config import Settings -from app.dependencies import get_app_settings, get_db, get_current_auth_session -from app.services.auth import ( - AuthenticatedSession, - authenticate_user, - change_password, - create_session, - AuthPasswordChangeError, - issue_login_csrf_token, - revoke_session, - validate_csrf_token, -) -from app.services.config_page import build_config_sections, is_ticktick_oauth_ready - -logger = logging.getLogger(__name__) -templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) -router = APIRouter(tags=["auth"]) - -LOGIN_CSRF_COOKIE_NAME = "login_csrf" - - -@router.get("/login", response_class=HTMLResponse) -def login_page( - request: Request, - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> Response: - if current_auth is not None: - return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) - - csrf_token = issue_login_csrf_token() - response = templates.TemplateResponse( - request, - "login.html", - { - "app_name": settings.app_name, - "app_env": settings.app_env, - "csrf_token": csrf_token, - "error_message": None, - }, - ) - _set_login_csrf_cookie(response, settings=settings, token=csrf_token) - return response - - -@router.post("/login", response_class=HTMLResponse) -def login_submit( - request: Request, - username: str = Form(), - password: str = Form(), - csrf_token: str = Form(), - session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), -) -> Response: - cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME) - if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token): - logger.warning("Rejected login attempt due to CSRF validation failure") - return _render_login_error( - request, - settings=settings, - status_code=status.HTTP_400_BAD_REQUEST, - error_message="invalid login request", - ) - - user = authenticate_user(session, username=username, password=password) - if user is None: - return _render_login_error( - request, - settings=settings, - status_code=status.HTTP_401_UNAUTHORIZED, - error_message="invalid username or password", - ) - - auth_session, raw_token = create_session(session, user=user, settings=settings) - 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, - value=raw_token, - max_age=settings.auth_session_ttl_hours * 3600, - httponly=True, - secure=settings.auth_cookie_secure, - samesite="lax", - path="/", - ) - logger.info("Created authenticated session for user '%s'", user.username) - return response - - -@router.post("/config/change-password", response_class=HTMLResponse) -def change_password_submit( - request: Request, - current_password: str = Form(), - new_password: str = Form(), - confirm_password: str = Form(), - csrf_token: str = Form(), - session: Session = Depends(get_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) - - 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_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", - ) - - try: - change_password( - session, - user=current_auth.user, - current_password=current_password, - new_password=new_password, - confirm_password=confirm_password, - ) - except AuthPasswordChangeError as exc: - logger.info( - "Rejected password change for user '%s': %s", - current_auth.user.username, - exc, - ) - 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="/config", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post("/logout") -def logout( - request: Request, - csrf_token: str = Form(), - session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> RedirectResponse: - if current_auth is not None and validate_csrf_token( - expected=current_auth.session.csrf_token, actual=csrf_token - ): - revoke_session(session, auth_session=current_auth.session) - logger.info("Revoked authenticated session for user '%s'", current_auth.user.username) - else: - logger.warning("Rejected logout request due to missing session or invalid CSRF token") - - response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(settings.auth_session_cookie_name, path="/") - return response - - -def _render_login_error( - request: Request, - *, - settings: Settings, - status_code: int, - error_message: str, -) -> HTMLResponse: - csrf_token = issue_login_csrf_token() - response = templates.TemplateResponse( - request, - "login.html", - { - "app_name": settings.app_name, - "app_env": settings.app_env, - "csrf_token": csrf_token, - "error_message": error_message, - }, - status_code=status_code, - ) - _set_login_csrf_cookie(response, settings=settings, token=csrf_token) - return response - - -def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None: - response.set_cookie( - key=LOGIN_CSRF_COOKIE_NAME, - value=token, - max_age=1800, - httponly=True, - secure=settings.auth_cookie_secure, - samesite="lax", - path="/login", - ) - - -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, - "config.html", - { - "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": password_change_error, - "config_error": None, - "config_saved": False, - "config_sections": build_config_sections(auth_db_session, settings), - "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), - "ticktick_redirect_uri": settings.ticktick_redirect_uri, - "ticktick_oauth_notice": None, - "ticktick_oauth_error": None, - }, - status_code=status_code, - ) diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py deleted file mode 100644 index bbd2594..0000000 --- a/app/api/routes/pages.py +++ /dev/null @@ -1,240 +0,0 @@ -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, get_settings -from app.dependencies import get_app_settings, get_db, get_current_auth_session -from app.services.auth import AuthenticatedSession -from app.services.config_page import ( - ConfigSaveError, - build_config_sections, - is_ticktick_oauth_ready, - save_config_updates, -) -from app.services.email import EmailConfigurationError, EmailDeliveryError, is_smtp_ready, send_smtp_test_email -from sqlalchemy.orm import Session - -templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) -router = APIRouter(tags=["pages"]) -logger = logging.getLogger(__name__) - - -def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]: - if status_value == "success": - return "TickTick authorization completed successfully.", None - if status_value == "invalid-state": - return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again." - if status_value == "invalid-callback": - return None, "TickTick authorization callback was missing required parameters." - if status_value == "failed": - return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." - return None, None - - -def _smtp_test_notice(status_value: str | None) -> tuple[str | None, str | None]: - if status_value == "success": - return "SMTP test email sent successfully.", None - if status_value == "config-error": - return None, "SMTP test failed. Check required SMTP settings before sending a test email." - if status_value == "failed": - return None, "SMTP test failed. Check saved SMTP settings and server reachability." - return None, None - - -def _build_config_context( - *, - auth_db_session: Session, - settings: Settings, - current_auth: AuthenticatedSession, - config_saved: bool, - config_error: str | None, - password_change_error: str | None, - ticktick_oauth_notice: str | None, - ticktick_oauth_error: str | None, - smtp_test_notice: str | None, - smtp_test_error: str | None, -) -> dict[str, object]: - return { - "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": password_change_error, - "config_error": config_error, - "config_saved": config_saved, - "config_sections": build_config_sections(auth_db_session, settings), - "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), - "ticktick_redirect_uri": settings.ticktick_redirect_uri, - "ticktick_oauth_notice": ticktick_oauth_notice, - "ticktick_oauth_error": ticktick_oauth_error, - "smtp_test_ready": is_smtp_ready(settings), - "smtp_test_notice": smtp_test_notice, - "smtp_test_error": smtp_test_error, - } - - -@router.get("/", response_class=HTMLResponse) -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_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_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) - - ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice( - request.query_params.get("ticktick_oauth") - ) - smtp_test_notice, smtp_test_error = _smtp_test_notice(request.query_params.get("smtp_test")) - context = _build_config_context( - auth_db_session=auth_db_session, - settings=settings, - current_auth=current_auth, - config_saved=request.query_params.get("saved") == "1", - config_error=None, - password_change_error=None, - ticktick_oauth_notice=ticktick_oauth_notice, - ticktick_oauth_error=ticktick_oauth_error, - smtp_test_notice=smtp_test_notice, - smtp_test_error=smtp_test_error, - ) - 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_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 = _build_config_context( - auth_db_session=auth_db_session, - settings=settings, - current_auth=current_auth, - config_saved=False, - config_error="invalid config update request", - password_change_error=None, - ticktick_oauth_notice=None, - ticktick_oauth_error=None, - smtp_test_notice=None, - smtp_test_error=None, - ) - 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 = get_settings() - context = _build_config_context( - auth_db_session=auth_db_session, - settings=refreshed_settings, - current_auth=current_auth, - config_saved=False, - config_error="invalid config submission", - password_change_error=None, - ticktick_oauth_notice=None, - ticktick_oauth_error=None, - smtp_test_notice=None, - smtp_test_error=None, - ) - 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) - - -@router.post("/config/smtp/test", response_class=HTMLResponse) -async def smtp_test_submit( - request: Request, - auth_db_session: Session = Depends(get_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 SMTP test due to CSRF validation failure") - context = _build_config_context( - auth_db_session=auth_db_session, - settings=settings, - current_auth=current_auth, - config_saved=False, - config_error=None, - password_change_error=None, - ticktick_oauth_notice=None, - ticktick_oauth_error=None, - smtp_test_notice=None, - smtp_test_error="invalid SMTP test request", - ) - return templates.TemplateResponse( - request, - "config.html", - context, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - try: - send_smtp_test_email(settings) - except EmailConfigurationError as exc: - logger.warning("SMTP test email rejected due to configuration: %s", exc) - return RedirectResponse( - url="/config?smtp_test=config-error", - status_code=status.HTTP_303_SEE_OTHER, - ) - except EmailDeliveryError as exc: - logger.warning("SMTP test email failed: %s", exc) - return RedirectResponse( - url="/config?smtp_test=failed", - status_code=status.HTTP_303_SEE_OTHER, - ) - - return RedirectResponse( - url="/config?smtp_test=success", - status_code=status.HTTP_303_SEE_OTHER, - ) diff --git a/app/main.py b/app/main.py index aa0d921..267fb6e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,10 @@ +import logging +import os from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -11,8 +14,7 @@ from app import models # noqa: F401 from app.api.routes.api.config import router as api_config_router from app.api.routes.api.data import router as api_data_router from app.api.routes.api.session import router as api_session_router -from app.api.routes.auth import router as auth_router -from app.api.routes import pages, status +from app.api.routes import status from app.db import get_session_local from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router @@ -25,6 +27,17 @@ from app.services.config_page import seed_missing_config_from_bootstrap, sync_ap from app.services.public_ip import check_public_ipv4_and_notify from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _get_spa_dist_dir() -> Path: + env_val = os.environ.get("SPA_DIST_DIR") + if env_val: + return Path(env_val) + return _REPO_ROOT / "frontend" / "dist" + def _run_scheduled_public_ip_check() -> None: session_local = get_session_local() @@ -92,8 +105,6 @@ def create_app() -> FastAPI: app.mount("/static", StaticFiles(directory=static_dir), name="static") app.include_router(status.router) - app.include_router(auth_router) - app.include_router(pages.router) app.include_router(api_config_router) app.include_router(api_data_router) app.include_router(api_session_router) @@ -102,6 +113,39 @@ def create_app() -> FastAPI: app.include_router(poo_router) app.include_router(public_ip_router) app.include_router(ticktick_router) + + # SPA hosting: mount frontend/dist if it exists and has index.html. + # If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely + # so that pytest stays green with only the API routes registered. + spa_dist = _get_spa_dist_dir() + spa_index = spa_dist / "index.html" + if spa_dist.is_dir() and spa_index.is_file(): + spa_assets = spa_dist / "assets" + if spa_assets.is_dir(): + app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets") + + # Resolve the dist root once so the containment check is fast and consistent. + _spa_root = spa_dist.resolve() + + @app.get("/{full_path:path}", include_in_schema=False) + async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029 + # Explicit 404 for unmatched /api/* — never return index.html for API paths. + if full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="not found") + # Resolve candidate to an absolute path and verify it stays within the SPA + # dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f") + # bypass Starlette's path parameter handling and allow arbitrary file reads. + candidate = (spa_dist / full_path).resolve() + if candidate.is_file() and candidate.is_relative_to(_spa_root): + return FileResponse(candidate) + # For any path outside the dist root, or for SPA client routes that don't + # correspond to a real file, return index.html so the SPA router handles it. + return FileResponse(spa_index) + else: + logger.warning( + "SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist + ) + return app diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index e5c583f..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - {% block title %}{{ app_name }}{% endblock %} - - - - -
- {% block content %}{% endblock %} -
- - - diff --git a/app/templates/config.html b/app/templates/config.html deleted file mode 100644 index 0fb3f70..0000000 --- a/app/templates/config.html +++ /dev/null @@ -1,139 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Config · {{ app_name }}{% endblock %} - -{% block content %} -
-

Configuration

-

Config

- - {% if force_password_change %} -
- 首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。 -
- {% endif %} - - {% if password_change_error %} -
{{ password_change_error }}
- {% endif %} - - {% if config_error %} -
{{ config_error }}
- {% endif %} - - {% if config_saved %} -
config saved to the app database. Some changes may require an app restart.
- {% endif %} - - {% if ticktick_oauth_error %} -
{{ ticktick_oauth_error }}
- {% endif %} - - {% if ticktick_oauth_notice %} -
{{ ticktick_oauth_notice }}
- {% endif %} - - {% if smtp_test_error %} -
{{ smtp_test_error }}
- {% endif %} - - {% if smtp_test_notice %} -
{{ smtp_test_notice }}
- {% endif %} - -
-
-
当前用户
-
admin
-
-
- -
-

Change Password

-
- - - - - - - - - -
-
- -
-

Config

-
- - - {% for section in config_sections %} -
- {{ section.name }} - {% for field in section.fields %} - - {% endfor %} - - {% if section.name == "TickTick" %} -
-
-

TickTick OAuth

-

Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}

- {% if ticktick_oauth_ready %} -

Use the saved TickTick client settings to start the authorization flow.

- {% else %} -

Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.

- {% endif %} -
- {% if ticktick_oauth_ready %} - Authorize TickTick - {% else %} - Authorize TickTick - {% endif %} -
- {% endif %} - - {% if section.name == "SMTP" %} -
-
-

SMTP Test Email

-

Save the SMTP settings first, then send a simple plaintext test email to the configured recipient.

-
- {% if smtp_test_ready %} - - {% else %} - Send SMTP Test - {% endif %} -
- {% endif %} -
- {% endfor %} - - -
-
- -
- - -
-
-{% endblock %} diff --git a/app/templates/home.html b/app/templates/home.html deleted file mode 100644 index 63ef3aa..0000000 --- a/app/templates/home.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ app_name }}{% endblock %} - -{% block content %} -
-

Python Rewrite Skeleton

-

{{ app_name }}

-

- 这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、 - 测试、模板和容器化基础,不包含业务逻辑迁移。 -

-
-
-
运行环境
-
{{ app_env }}
-
-
-
健康检查
-
/status
-
-
-
OpenAPI
-
/docs
-
-
-
登录
-
/login
-
-
-
Notion
-
{{ notion_status }}
-
-
-
-{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html deleted file mode 100644 index 8dcc2d7..0000000 --- a/app/templates/login.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} - -{% block title %}登录 · {{ app_name }}{% endblock %} - -{% block content %} -
-

Authentication

-

登录

-

- 登录成功后会进入受保护的 config 页面。 -

- - {% if error_message %} -
{{ error_message }}
- {% endif %} - -
- - - - - - - -
-
-{% endblock %} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d227279..32b6c4a 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -21,127 +21,6 @@ export interface paths { patch?: never; trace?: never; }; - "/login": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Login Page */ - get: operations["login_page_login_get"]; - put?: never; - /** Login Submit */ - post: operations["login_submit_login_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/config/change-password": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Change Password Submit */ - post: operations["change_password_submit_config_change_password_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/logout": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Logout */ - post: operations["logout_logout_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Home */ - get: operations["home__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Admin Redirect */ - get: operations["admin_redirect_admin_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/config": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Config Page */ - get: operations["config_page_config_get"]; - put?: never; - /** Config Submit */ - post: operations["config_submit_config_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/config/smtp/test": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Smtp Test Submit */ - post: operations["smtp_test_submit_config_smtp_test_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/config": { parameters: { query?: never; @@ -544,31 +423,6 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { - /** Body_change_password_submit_config_change_password_post */ - Body_change_password_submit_config_change_password_post: { - /** Current Password */ - current_password: string; - /** New Password */ - new_password: string; - /** Confirm Password */ - confirm_password: string; - /** Csrf Token */ - csrf_token: string; - }; - /** Body_login_submit_login_post */ - Body_login_submit_login_post: { - /** Username */ - username: string; - /** Password */ - password: string; - /** Csrf Token */ - csrf_token: string; - }; - /** Body_logout_logout_post */ - Body_logout_logout_post: { - /** Csrf Token */ - csrf_token: string; - }; /** ConfigField */ ConfigField: { /** Env Name */ @@ -831,225 +685,6 @@ export interface operations { }; }; }; - login_page_login_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; - login_submit_login_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/x-www-form-urlencoded": components["schemas"]["Body_login_submit_login_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - change_password_submit_config_change_password_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/x-www-form-urlencoded": components["schemas"]["Body_change_password_submit_config_change_password_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - logout_logout_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/x-www-form-urlencoded": components["schemas"]["Body_logout_logout_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - home__get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; - admin_redirect_admin_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; - config_page_config_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; - config_submit_config_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; - smtp_test_submit_config_smtp_test_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; get_config_api_config_get: { parameters: { query?: never; diff --git a/openapi/openapi.json b/openapi/openapi.json index 43b605c..d3a2fd2 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -27,249 +27,6 @@ } } }, - "/login": { - "get": { - "tags": [ - "auth" - ], - "summary": "Login Page", - "operationId": "login_page_login_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - }, - "post": { - "tags": [ - "auth" - ], - "summary": "Login Submit", - "operationId": "login_submit_login_post", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Body_login_submit_login_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" - } - } - } - } - } - } - }, - "/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": [ - "auth" - ], - "summary": "Logout", - "operationId": "logout_logout_post", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Body_logout_logout_post" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/": { - "get": { - "tags": [ - "pages" - ], - "summary": "Home", - "operationId": "home__get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/admin": { - "get": { - "tags": [ - "pages" - ], - "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", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/config/smtp/test": { - "post": { - "tags": [ - "pages" - ], - "summary": "Smtp Test Submit", - "operationId": "smtp_test_submit_config_smtp_test_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, "/api/config": { "get": { "tags": [ @@ -1176,70 +933,6 @@ }, "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": { - "type": "string", - "title": "Username" - }, - "password": { - "type": "string", - "title": "Password" - }, - "csrf_token": { - "type": "string", - "title": "Csrf Token" - } - }, - "type": "object", - "required": [ - "username", - "password", - "csrf_token" - ], - "title": "Body_login_submit_login_post" - }, - "Body_logout_logout_post": { - "properties": { - "csrf_token": { - "type": "string", - "title": "Csrf Token" - } - }, - "type": "object", - "required": [ - "csrf_token" - ], - "title": "Body_logout_logout_post" - }, "ConfigField": { "properties": { "env_name": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 381b09b..6ce99b3 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -18,156 +18,6 @@ paths: application/json: schema: $ref: '#/components/schemas/StatusResponse' - /login: - get: - tags: - - auth - summary: Login Page - operationId: login_page_login_get - responses: - '200': - description: Successful Response - content: - text/html: - schema: - type: string - post: - tags: - - auth - summary: Login Submit - operationId: login_submit_login_post - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Body_login_submit_login_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' - /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: - - auth - summary: Logout - operationId: logout_logout_post - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Body_logout_logout_post' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /: - get: - tags: - - pages - summary: Home - operationId: home__get - responses: - '200': - description: Successful Response - content: - text/html: - schema: - type: string - /admin: - get: - tags: - - pages - 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 - content: - text/html: - schema: - type: string - /config/smtp/test: - post: - tags: - - pages - summary: Smtp Test Submit - operationId: smtp_test_submit_config_smtp_test_post - responses: - '200': - description: Successful Response - content: - text/html: - schema: - type: string /api/config: get: tags: @@ -812,53 +662,6 @@ 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: - type: string - title: Username - password: - type: string - title: Password - csrf_token: - type: string - title: Csrf Token - type: object - required: - - username - - password - - csrf_token - title: Body_login_submit_login_post - Body_logout_logout_post: - properties: - csrf_token: - type: string - title: Csrf Token - type: object - required: - - csrf_token - title: Body_logout_logout_post ConfigField: properties: env_name: diff --git a/tests/test_api_config.py b/tests/test_api_config.py index 20d4006..57b3c34 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -2,7 +2,6 @@ Tests for M2-T05: POST /api/config/smtp/test.""" from __future__ import annotations -import re import sqlite3 from unittest.mock import patch @@ -17,26 +16,13 @@ from app.services.email import EmailConfigurationError, EmailDeliveryError # Helpers # --------------------------------------------------------------------------- -def _extract_csrf_token(html: str) -> str: - match = re.search(r'name="csrf_token" value="([^"]+)"', html) - assert match is not None, "csrf_token not found in HTML" - return match.group(1) - - def _login(client: TestClient) -> None: - """Log in as admin/test-password using the Jinja login form.""" - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) + """Log in as admin/test-password using the JSON API.""" resp = client.post( - "/login", - data={ - "username": "admin", - "password": "test-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, + "/api/auth/login", + json={"username": "admin", "password": "test-password"}, ) - assert resp.status_code == 303, f"Login failed: {resp.status_code}" + assert resp.status_code == 200, f"Login failed: {resp.status_code}" def _stringify(value) -> str: diff --git a/tests/test_api_session.py b/tests/test_api_session.py index 2b7293d..5d932b1 100644 --- a/tests/test_api_session.py +++ b/tests/test_api_session.py @@ -1,8 +1,6 @@ """Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password.""" from __future__ import annotations -import re - from fastapi.testclient import TestClient @@ -11,24 +9,6 @@ from fastapi.testclient import TestClient # --------------------------------------------------------------------------- -def _extract_csrf_token(html: str) -> str: - match = re.search(r'name="csrf_token" value="([^"]+)"', html) - assert match is not None, "csrf_token not found in HTML" - return match.group(1) - - -def _jinja_login(client: TestClient) -> None: - """Log in via the existing Jinja form so the client has a session cookie.""" - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - resp = client.post( - "/login", - data={"username": "admin", "password": "test-password", "csrf_token": csrf_token}, - follow_redirects=False, - ) - assert resp.status_code == 303, f"Jinja login failed: {resp.status_code}" - - def _api_login(client: TestClient, *, username: str = "admin", password: str = "test-password"): """Log in via POST /api/auth/login and return the response.""" return client.post( @@ -53,7 +33,7 @@ def test_get_session_unauthenticated_returns_401(client: TestClient) -> None: def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> None: - _jinja_login(client) + _api_login(client) response = client.get("/api/session") @@ -68,7 +48,7 @@ def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> def test_get_session_does_not_leak_password(client: TestClient) -> None: - _jinja_login(client) + _api_login(client) response = client.get("/api/session") body_str = str(response.json()) assert "test-password" not in body_str diff --git a/tests/test_app.py b/tests/test_app.py index 79b611d..2d4a002 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -25,9 +25,11 @@ def _prepare_app_db(tmp_path) -> str: def test_app_starts(client: TestClient) -> None: + # With SPA enabled, GET / is served by the catch-all and returns index.html (200). + # Without SPA (e.g. SPA_DIST_DIR points to empty dir), it returns 404. + # Either way the app started successfully — just assert it is not a server error. response = client.get("/", follow_redirects=False) - assert response.status_code == 303 - assert response.headers["location"] == "/login" + assert response.status_code in (200, 404) def test_status_endpoint(client: TestClient) -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py index d354aa2..1902c37 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,265 +1,5 @@ -import re -import sqlite3 +"""Jinja-based auth tests removed in M2-T11 (Jinja routes deleted). -from fastapi.testclient import TestClient - -from app.db import reset_db_caches -from app.config import get_settings -from app.main import create_app - - -def _extract_csrf_token(html: str) -> str: - match = re.search(r'name="csrf_token" value="([^"]+)"', html) - assert match is not None - return match.group(1) - - -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" - - -def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestClient) -> None: - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - - response = client.post( - "/login", - data={ - "username": "admin", - "password": "test-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - - assert response.status_code == 303 - 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 - - 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 - assert "Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth." in config_response.text - assert 'aria-disabled="true">Authorize TickTick<' in config_response.text - - -def test_login_failure_returns_generic_error(client: TestClient) -> None: - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - - response = client.post( - "/login", - data={ - "username": "admin", - "password": "wrong-password", - "csrf_token": csrf_token, - }, - ) - - assert response.status_code == 401 - assert "invalid username or password" in response.text - assert "wrong-password" not in response.text - - -def test_logout_revokes_session(client: TestClient) -> None: - login_page = client.get("/login") - login_csrf_token = _extract_csrf_token(login_page.text) - - client.post( - "/login", - data={ - "username": "admin", - "password": "test-password", - "csrf_token": login_csrf_token, - }, - ) - - config_page = client.get("/config") - logout_csrf_token = _extract_csrf_token(config_page.text) - - logout_response = client.post( - "/logout", - data={"csrf_token": logout_csrf_token}, - follow_redirects=False, - ) - - assert logout_response.status_code == 303 - assert logout_response.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: - client.get("/login") - - response = client.post( - "/login", - data={ - "username": "admin", - "password": "test-password", - "csrf_token": "wrong-csrf", - }, - ) - - 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 - - -def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured( - auth_database, - monkeypatch, -) -> None: - monkeypatch.setenv("APP_ENV", "production") - monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") - monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") - monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - get_settings.cache_clear() - reset_db_caches() - - with TestClient(create_app()) as client: - 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_response = client.get("/config") - - assert config_response.status_code == 200 - assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text - assert "Redirect URI: https://localhost:8000/ticktick/auth/code" in config_response.text - assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text - - -def test_config_page_shows_ticktick_oauth_success_notice(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("/config?ticktick_oauth=success") - - assert response.status_code == 200 - assert "TickTick authorization completed successfully." in response.text - - -def test_config_page_shows_ticktick_oauth_failure_notice(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("/config?ticktick_oauth=failed") - - assert response.status_code == 200 - assert "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." in response.text +Equivalent JSON-API coverage lives in test_api_session.py and test_api_config.py. +This file is intentionally left with no test functions so pytest does not error. +""" diff --git a/tests/test_public_ip.py b/tests/test_public_ip.py index 6f88fdf..408d488 100644 --- a/tests/test_public_ip.py +++ b/tests/test_public_ip.py @@ -1,5 +1,4 @@ from datetime import UTC, datetime -import re import sqlite3 from fastapi.testclient import TestClient @@ -17,25 +16,12 @@ def _make_session(database_url: str) -> Session: return session_local() -def _extract_csrf_token(html: str) -> str: - match = re.search(r'name="csrf_token" value="([^"]+)"', html) - assert match is not None - return match.group(1) - - def _login(client: TestClient) -> None: - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) response = client.post( - "/login", - data={ - "username": "admin", - "password": "test-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, + "/api/auth/login", + json={"username": "admin", "password": "test-password"}, ) - assert response.status_code == 303 + assert response.status_code == 200 def test_public_ip_first_seen_persists_state_and_history(auth_database) -> None: diff --git a/tests/test_smtp.py b/tests/test_smtp.py index 5d06635..e2f3374 100644 --- a/tests/test_smtp.py +++ b/tests/test_smtp.py @@ -1,8 +1,10 @@ -import re -import sqlite3 -import smtplib +"""SMTP service-layer unit tests. -from fastapi.testclient import TestClient +Jinja-based HTTP flow tests (POST /config, POST /config/smtp/test via form) were +removed in M2-T11 when the Jinja routes were deleted. HTTP-level SMTP test +endpoint coverage lives in test_api_config.py. +""" +import smtplib from app.config import Settings from app.services.email import ( @@ -14,27 +16,6 @@ from app.services.email import ( ) -def _extract_csrf_token(html: str) -> str: - match = re.search(r'name="csrf_token" value="([^"]+)"', html) - assert match is not None - return match.group(1) - - -def _login(client: TestClient) -> None: - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - response = client.post( - "/login", - data={ - "username": "admin", - "password": "test-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - assert response.status_code == 303 - - def _smtp_settings(**overrides) -> Settings: payload = { "app_env": "development", @@ -237,159 +218,3 @@ def test_send_public_ip_changed_email_contains_expected_english_content(monkeypa assert "Current IP: 198.51.100.25" in sent["body"] assert "Detected at: 2026-04-29 10:00:00 UTC" in sent["body"] assert "update the trusted IP manually" in sent["body"] - - -def test_config_update_does_not_clear_existing_smtp_password( - client: TestClient, test_database_urls -) -> None: - _login(client) - config_page = client.get("/config") - config_csrf_token = _extract_csrf_token(config_page.text) - - response = client.post( - "/config", - data={ - "csrf_token": config_csrf_token, - "APP_NAME": "SMTP Config Test", - "APP_ENV": "development", - "APP_DEBUG": "true", - "APP_HOSTNAME": "localhost:8000", - "SMTP_ENABLED": "true", - "SMTP_HOST": "smtp.example.com", - "SMTP_PORT": "587", - "SMTP_USERNAME": "smtp-user", - "SMTP_PASSWORD": "persist-me", - "SMTP_FROM_ADDRESS": "sender@example.com", - "SMTP_TO_ADDRESS": "recipient@example.com", - "SMTP_USE_STARTTLS": "true", - "AUTH_SESSION_COOKIE_NAME": "home_automation_session", - "AUTH_SESSION_TTL_HOURS": "12", - "AUTH_COOKIE_SECURE_OVERRIDE": "false", - "POO_WEBHOOK_ID": "", - "POO_SENSOR_ENTITY_NAME": "sensor.test_poo_status", - "POO_SENSOR_FRIENDLY_NAME": "Poo Status", - "TICKTICK_CLIENT_ID": "", - "TICKTICK_CLIENT_SECRET": "", - "TICKTICK_TOKEN": "", - "HOME_ASSISTANT_BASE_URL": "", - "HOME_ASSISTANT_AUTH_TOKEN": "", - "HOME_ASSISTANT_TIMEOUT_SECONDS": "1.0", - "HOME_ASSISTANT_ACTION_TASK_PROJECT_ID": "", - }, - follow_redirects=False, - ) - assert response.status_code == 303 - - config_page = client.get("/config") - config_csrf_token = _extract_csrf_token(config_page.text) - response = client.post( - "/config", - data={ - "csrf_token": config_csrf_token, - "APP_NAME": "SMTP Config Updated", - "APP_ENV": "development", - "APP_DEBUG": "true", - "APP_HOSTNAME": "localhost:8000", - "SMTP_ENABLED": "true", - "SMTP_HOST": "smtp.example.com", - "SMTP_PORT": "587", - "SMTP_USERNAME": "smtp-user", - "SMTP_PASSWORD": "", - "SMTP_FROM_ADDRESS": "sender@example.com", - "SMTP_TO_ADDRESS": "recipient@example.com", - "SMTP_USE_STARTTLS": "true", - "AUTH_SESSION_COOKIE_NAME": "home_automation_session", - "AUTH_SESSION_TTL_HOURS": "12", - "AUTH_COOKIE_SECURE_OVERRIDE": "false", - "POO_WEBHOOK_ID": "", - "POO_SENSOR_ENTITY_NAME": "sensor.test_poo_status", - "POO_SENSOR_FRIENDLY_NAME": "Poo Status", - "TICKTICK_CLIENT_ID": "", - "TICKTICK_CLIENT_SECRET": "", - "TICKTICK_TOKEN": "", - "HOME_ASSISTANT_BASE_URL": "", - "HOME_ASSISTANT_AUTH_TOKEN": "", - "HOME_ASSISTANT_TIMEOUT_SECONDS": "1.0", - "HOME_ASSISTANT_ACTION_TASK_PROJECT_ID": "", - }, - follow_redirects=False, - ) - assert response.status_code == 303 - - 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["SMTP_PASSWORD"] == "persist-me" - assert rows["APP_NAME"] == "SMTP Config Updated" - - -def test_smtp_test_endpoint_requires_authentication(client: TestClient) -> None: - response = client.post("/config/smtp/test", data={"csrf_token": "ignored"}, follow_redirects=False) - - assert response.status_code == 303 - assert response.headers["location"] == "/login" - - -def test_smtp_test_endpoint_success_and_failure_do_not_expose_password( - client: TestClient, monkeypatch -) -> None: - from app.api.routes import pages - - _login(client) - config_page = client.get("/config") - csrf_token = _extract_csrf_token(config_page.text) - - monkeypatch.setattr(pages, "send_smtp_test_email", lambda settings: None) - response = client.post("/config/smtp/test", data={"csrf_token": csrf_token}, follow_redirects=False) - assert response.status_code == 303 - assert response.headers["location"] == "/config?smtp_test=success" - - follow_up = client.get(response.headers["location"]) - assert follow_up.status_code == 200 - assert "SMTP test email sent successfully." in follow_up.text - assert "super-secret-password" not in follow_up.text - - monkeypatch.setattr( - pages, - "send_smtp_test_email", - lambda settings: (_ for _ in ()).throw(EmailDeliveryError("smtp auth failed for [redacted]")), - ) - response = client.post("/config/smtp/test", data={"csrf_token": csrf_token}, follow_redirects=False) - assert response.status_code == 303 - assert response.headers["location"] == "/config?smtp_test=failed" - - follow_up = client.get(response.headers["location"]) - assert follow_up.status_code == 200 - assert "SMTP test failed. Check saved SMTP settings and server reachability." in follow_up.text - assert "super-secret-password" not in follow_up.text - - -def test_config_page_renders_smtp_test_button_with_formaction( - client: TestClient, test_database_urls -) -> None: - _login(client) - - conn = sqlite3.connect(test_database_urls["app_path"]) - try: - conn.executemany( - "INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", - [ - ("SMTP_ENABLED", "true"), - ("SMTP_HOST", "smtp.example.com"), - ("SMTP_PORT", "587"), - ("SMTP_FROM_ADDRESS", "sender@example.com"), - ("SMTP_TO_ADDRESS", "recipient@example.com"), - ], - ) - conn.commit() - finally: - conn.close() - - response = client.get("/config") - - assert response.status_code == 200 - assert 'formaction="/config/smtp/test"' in response.text \ No newline at end of file diff --git a/tests/test_spa_hosting.py b/tests/test_spa_hosting.py new file mode 100644 index 0000000..8ee5db8 --- /dev/null +++ b/tests/test_spa_hosting.py @@ -0,0 +1,243 @@ +"""Tests for M2-T11: SPA hosting + fallback behavior in app/main.py. + +Uses SPA_DIST_DIR env var to point at a temporary directory containing a fake +index.html and an asset file, so tests are hermetic and don't depend on the +real frontend/dist build. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.config import get_settings +from app.db import reset_db_caches +from app.main import create_app + + +@pytest.fixture +def spa_dist(tmp_path: Path) -> Path: + """Create a minimal fake SPA dist directory. + + Layout: + tmp_path/ + secret.txt ← OUTSIDE dist — must never be served + fake_dist/ + index.html + assets/ + main.js + """ + # A secret file placed OUTSIDE the dist dir — used by traversal tests. + (tmp_path / "secret.txt").write_text("TOP_SECRET_SENTINEL", encoding="utf-8") + + dist = tmp_path / "fake_dist" + dist.mkdir() + (dist / "index.html").write_text( + "SPA INDEX", encoding="utf-8" + ) + assets = dist / "assets" + assets.mkdir() + (assets / "main.js").write_text("console.log('app');", encoding="utf-8") + return dist + + +@pytest.fixture +def spa_client(spa_dist: Path, auth_database, monkeypatch: pytest.MonkeyPatch) -> TestClient: + """TestClient with a fresh app wired to the fake SPA dist.""" + monkeypatch.setenv("SPA_DIST_DIR", str(spa_dist)) + get_settings.cache_clear() + reset_db_caches() + app = create_app() + with TestClient(app) as client: + yield client + get_settings.cache_clear() + reset_db_caches() + + +# --------------------------------------------------------------------------- +# SPA fallback — client routes served as index.html +# --------------------------------------------------------------------------- + + +def test_spa_root_returns_index_html(spa_client: TestClient) -> None: + """GET / returns the SPA index.html (200).""" + response = spa_client.get("/", follow_redirects=False) + assert response.status_code == 200 + assert "SPA INDEX" in response.text + + +def test_spa_config_route_returns_index_html(spa_client: TestClient) -> None: + """/config is a client-side route; the fallback must serve index.html.""" + response = spa_client.get("/config", follow_redirects=False) + assert response.status_code == 200 + assert "SPA INDEX" in response.text + + +def test_spa_records_route_returns_index_html(spa_client: TestClient) -> None: + """/records is a client-side route; the fallback must serve index.html.""" + response = spa_client.get("/records", follow_redirects=False) + assert response.status_code == 200 + assert "SPA INDEX" in response.text + + +def test_spa_deep_link_returns_index_html(spa_client: TestClient) -> None: + """/some/deep/path that doesn't exist on disk returns index.html (deep-link support).""" + response = spa_client.get("/some/deep/path", follow_redirects=False) + assert response.status_code == 200 + assert "SPA INDEX" in response.text + + +# --------------------------------------------------------------------------- +# SPA asset serving +# --------------------------------------------------------------------------- + + +def test_spa_asset_is_served(spa_client: TestClient) -> None: + """/assets/main.js must be served directly from the dist/assets directory.""" + response = spa_client.get("/assets/main.js") + assert response.status_code == 200 + assert "console.log" in response.text + + +# --------------------------------------------------------------------------- +# API routes not swallowed by fallback +# --------------------------------------------------------------------------- + + +def test_unauthenticated_api_session_returns_401_not_index(spa_client: TestClient) -> None: + """/api/session without a session cookie must return 401, not index.html.""" + response = spa_client.get("/api/session") + assert response.status_code == 401 + assert "SPA INDEX" not in response.text + + +def test_unknown_api_path_returns_404_not_index(spa_client: TestClient) -> None: + """/api/does-not-exist must return 404, not index.html.""" + response = spa_client.get("/api/does-not-exist") + assert response.status_code == 404 + assert "SPA INDEX" not in response.text + + +def test_api_typo_returns_404_not_index(spa_client: TestClient) -> None: + """/api/typo returns 404 (the fallback must not serve index.html for /api/*).""" + response = spa_client.get("/api/typo") + assert response.status_code == 404 + assert "SPA INDEX" not in response.text + + +# --------------------------------------------------------------------------- +# FastAPI built-in endpoints not swallowed by fallback +# --------------------------------------------------------------------------- + + +def test_openapi_json_is_served(spa_client: TestClient) -> None: + """/openapi.json must be served by FastAPI, not the SPA fallback.""" + response = spa_client.get("/openapi.json") + assert response.status_code == 200 + body = response.json() + assert "openapi" in body + + +def test_docs_endpoint_is_served(spa_client: TestClient) -> None: + """/docs must be served by FastAPI Swagger UI, not index.html.""" + response = spa_client.get("/docs") + assert response.status_code == 200 + assert "SPA INDEX" not in response.text + + +def test_status_endpoint_is_served(spa_client: TestClient) -> None: + """/status must remain reachable and return JSON.""" + response = spa_client.get("/status") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +# --------------------------------------------------------------------------- +# Path-traversal containment — security regression tests +# --------------------------------------------------------------------------- + + +def test_path_traversal_encoded_dotdot_slash_returns_index_not_secret( + spa_client: TestClient, spa_dist: Path +) -> None: + """GET /..%2fsecret.txt must NOT return the secret file outside the dist dir. + + Starlette URL-decodes {full_path:path} but does not normalise it, so an + encoded '../' can escape the dist root without the containment guard. + The guarded implementation resolves the candidate and checks is_relative_to; + a path that escapes the root falls back to index.html instead. + """ + response = spa_client.get("/..%2fsecret.txt", follow_redirects=False) + # Must not expose the secret content. + assert "TOP_SECRET_SENTINEL" not in response.text + # Should be a successful SPA index response (not a server error). + assert response.status_code == 200 + assert "SPA INDEX" in response.text + + +def test_path_traversal_pct_encoded_dotdot_returns_index_not_secret( + spa_client: TestClient, spa_dist: Path +) -> None: + """GET /%2e%2e%2fsecret.txt must NOT expose the file outside dist. + + Covers the %2e%2e encoding variant of '..'. + """ + response = spa_client.get("/%2e%2e%2fsecret.txt", follow_redirects=False) + assert "TOP_SECRET_SENTINEL" not in response.text + assert response.status_code == 200 + assert "SPA INDEX" in response.text + + +def test_path_traversal_nested_encoded_dotdot_returns_index_not_secret( + spa_client: TestClient, spa_dist: Path +) -> None: + """GET /fake_dist/..%2f..%2fsecret.txt (deeper traversal) must not leak.""" + response = spa_client.get("/fake_dist/..%2f..%2fsecret.txt", follow_redirects=False) + assert "TOP_SECRET_SENTINEL" not in response.text + assert response.status_code == 200 + assert "SPA INDEX" in response.text + + +def test_legit_nested_asset_inside_dist_is_served( + spa_client: TestClient, spa_dist: Path +) -> None: + """A real file inside the dist dir is still served correctly after the fix. + + Place a nested asset directly inside dist (not under /assets) and confirm + the catch-all serves it. + """ + nested = spa_dist / "nested" / "chunk.js" + nested.parent.mkdir() + nested.write_text("// nested chunk", encoding="utf-8") + + response = spa_client.get("/nested/chunk.js", follow_redirects=False) + assert response.status_code == 200 + assert "nested chunk" in response.text + + +# --------------------------------------------------------------------------- +# SPA disabled when dist dir is missing +# --------------------------------------------------------------------------- + + +def test_spa_disabled_when_dist_missing( + tmp_path: Path, auth_database, monkeypatch: pytest.MonkeyPatch +) -> None: + """When SPA_DIST_DIR points to a non-existent directory, the app still starts + and API routes work normally — the SPA fallback is simply absent.""" + empty = tmp_path / "no_dist_here" + monkeypatch.setenv("SPA_DIST_DIR", str(empty)) + get_settings.cache_clear() + reset_db_caches() + app = create_app() + with TestClient(app) as client: + response = client.get("/status") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + # API still returns 401 for unauthenticated access + api_response = client.get("/api/session") + assert api_response.status_code == 401 + get_settings.cache_clear() + reset_db_caches() diff --git a/tests/test_ticktick.py b/tests/test_ticktick.py index f16cfab..4ef70ee 100644 --- a/tests/test_ticktick.py +++ b/tests/test_ticktick.py @@ -49,14 +49,6 @@ def _configured_settings(**overrides) -> Settings: return Settings(_env_file=None, **payload) -def _extract_csrf_token(html: str) -> str: - import re - - match = re.search(r'name="csrf_token" value="([^"]+)"', html) - assert match is not None - return match.group(1) - - def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None: client = TickTickClient(settings=_configured_settings()) monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123") @@ -263,17 +255,11 @@ def test_ticktick_auth_start_redirects_authenticated_user( monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect") with TestClient(create_app()) as client: - 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, + resp = client.post( + "/api/auth/login", + json={"username": "admin", "password": "test-password"}, ) + assert resp.status_code == 200, f"API login failed: {resp.status_code}" response = client.get("/ticktick/auth/start", follow_redirects=False)