M2-T11: serve React SPA from FastAPI and remove Jinja pages

- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist):
  mounts /assets and a GET catch-all returning index.html for client routes;
  catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets,
  ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI)
- delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/
  (all replaced by /api/* + SPA; auth service layer kept)
- remove/replace Jinja page tests (JSON coverage already in test_api_*);
  add tests/test_spa_hosting.py for the fallback contract
- regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
This commit is contained in:
2026-06-13 11:29:14 +02:00
parent 8aa7316b26
commit a9830c42d8
18 changed files with 319 additions and 2094 deletions
+4 -18
View File
@@ -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:
+2 -22
View File
@@ -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
+4 -2
View File
@@ -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:
+4 -264
View File
@@ -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.
"""
+3 -17
View File
@@ -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:
+6 -181
View File
@@ -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
+243
View File
@@ -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(
"<!DOCTYPE html><html><body>SPA INDEX</body></html>", 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()
+4 -18
View File
@@ -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)