19 Commits

Author SHA1 Message Date
tliu93 6cc6382515 docs(m2): mark M2-T08 done 2026-06-13 15:20:50 +02:00
tliu93 ef2bd3c9c5 M2-T08: build config UI (replaces Jinja config page)
- GET /api/config renders sections; secret fields shown as empty password inputs
- save handles full-field submission semantics: always send non-secret values,
  send secret only when user typed a new value (blank secret keeps old)
- SMTP test button reflects tri-state (success / config-error 400 / failed 502)
  by reading ApiError.body.result
- typed client only; responsive Mantine layout; vitest tests
2026-06-13 15:20:50 +02:00
tliu93 cc2c02a2e2 docs(m2): mark M2-T07 done 2026-06-13 15:20:50 +02:00
tliu93 b2e26f0b17 M2-T07: build auth UI (login, session bootstrap, forced password change, logout)
- real Mantine login form -> POST /api/auth/login; 401 inline error; redirect when already authed
- ProtectedRoute: loading state, preserves intended destination, gates force_password_change
- ChangePasswordPage forced-change gate -> POST /api/auth/password
- logout control in AppLayout nav -> POST /api/auth/logout
- typed client only; vitest tests for the login flow
2026-06-13 15:20:50 +02:00
tliu93 8975acc48b docs(m2): mark M2-T06 done 2026-06-13 15:20:50 +02:00
tliu93 6cfeb2b865 M2-T06: scaffold React SPA frontend with typed OpenAPI client
- Vite + React 18 + TypeScript + Mantine + TanStack Query + react-router-dom
- typed client: openapi-typescript -> src/api/schema.d.ts (committed), openapi-fetch
- fetch wrapper middleware: cookies, X-CSRF-Token on writes, 401 -> /login,
  non-401 errors carry parsed JSON body
- SessionProvider/useSession (GET /api/session), ProtectedRoute skeleton
- app shell (Mantine + router) with placeholder login/home/config pages + gear nav
- dev proxy to FastAPI; vitest smoke test; frontend README
- npm scripts: dev/build/preview/lint/typecheck/test/codegen
2026-06-13 15:20:50 +02:00
tliu93 dba9e28540 docs(m2): mark M2-T05 done 2026-06-13 15:20:50 +02:00
tliu93 2bc5d6ea9a M2-T05: add SMTP test action API (POST /api/config/smtp/test)
- reuses send_smtp_test_email; tri-state result success(200)/config-error(400)/failed(502)
- session + CSRF protected; never echoes SMTP secrets
- SmtpTestResponse schema; regenerate openapi/
- extend tests/test_api_config.py (3 states + 401 + missing-CSRF 403)
2026-06-13 15:20:50 +02:00
tliu93 3ec663e138 docs(m2): mark M2-T04 done 2026-06-12 23:35:56 +02:00
tliu93 048414c5cb M2-T04: add single-row record CRUD API (patch/delete)
- PATCH/DELETE /api/locations/{person}/{datetime} and /api/poo/{timestamp}
- update only non-PK fields (PK immutable); 404 on missing PK
- delete scoped to exact full PK with rowcount guard (0->404, 1->ok);
  no batch/truncate/drop path
- session + CSRF protected; bare ingestion endpoints untouched
- service helpers in app/services/location.py and poo.py; regenerate openapi/
- tests/test_api_record_crud.py
2026-06-12 23:33:08 +02:00
tliu93 9ce3f2a0b8 docs(m2): mark M2-T03 done 2026-06-12 23:27:02 +02:00
tliu93 0fba7cfe11 M2-T03: add read-only data JSON API
- GET /api/locations (inclusive time window start/end, pagination, cap 5000)
- GET /api/poo (pagination, cap 1000, newest first)
- GET /api/public-ip (current state + recent history, cap 1000)
- all session-protected, read-only, bounded (no full-table export)
- typed response schemas; register router; regenerate openapi/
- tests/test_api_data.py
2026-06-12 23:24:17 +02:00
tliu93 d8303eaa3d docs(m2): mark M2-T02 done 2026-06-12 23:18:43 +02:00
tliu93 8da1f13e60 M2-T02: add session/auth JSON API for the SPA
- GET /api/session (user + csrf_token, 401 when unauthenticated)
- POST /api/auth/login (sets HttpOnly session cookie; 401 on bad creds; no CSRF)
- POST /api/auth/logout (session+CSRF; revokes session, clears cookie; 204)
- POST /api/auth/password (session+CSRF; reuses change_password; 400 on failure; 204)
- reuses app/services/auth.py and shared require_session/require_csrf deps
- register router in app/main.py; regenerate openapi/
- tests/test_api_session.py
2026-06-12 23:15:56 +02:00
tliu93 de77019ce3 docs(m2): mark M2-T01 done 2026-06-12 23:11:38 +02:00
tliu93 c2b1b7b751 M2-T01: add config JSON API (GET/PUT /api/config)
- new app/api/routes/api/ package with shared require_session (401) and
  require_csrf (presence-only X-CSRF-Token, 403) dependencies
- GET /api/config returns masked config sections; PUT /api/config reuses
  save_config_updates (blank secret keeps old; invalid -> 422, no write)
- session-protected; PUT also CSRF-protected
- register router in app/main.py; regenerate openapi/
- tests/test_api_config.py
2026-06-12 23:08:14 +02:00
tliu93 3628ac51e5 chore(m2): green the ruff baseline before M2 orchestration
- ignore E402 in scripts/*.py (deliberate sys.path bootstrap before app imports)
- drop unused pathlib.Path import in tests/test_auth.py

Establishes a clean ruff gate so each M2 task can be verified green at its boundary.
2026-06-12 22:56:21 +02:00
tliu93 1756192270 docs: record future-ideas backlog and refine CLAUDE.md workflow rules
pytest / test (push) Successful in 46s
- Add docs/future-ideas.md: unscheduled backlog (more data sources/types,
  long-term storage, Home Assistant data persistence, MQTT client, near-term PWA).
- CLAUDE.md: codify M1 lessons — reviewer blind-review discipline,
  build-context integrity checks when deleting/moving files,
  fixup-vs-standalone-commit boundary, and a pre-release walkthrough
  (run app + docker build + manual smoke) before tagging.
2026-06-12 22:50:47 +02:00
tliu93 66ec9979cc docs(m2): lock M2 frontend design decisions
pytest / test (push) Failing after 11m46s
Record the decisions reached in planning into docs/design/m2-frontend-v2.md:
component library = Mantine; map = Leaflet (react-leaflet + leaflet.heat +
markercluster, isolated behind a component seam for a future MapLibre swap);
OpenAPI typed client committed + CI-checked; CSRF simplified to SameSite=Lax +
a custom write header (no per-session token); heatmap-first map as the home
view with a required time-range picker and an auxiliary paginated list;
record CRUD edits non-PK fields and deletes single rows (no UI create); bare
ingestion endpoints stay until M3; trips optional. Wireframes intentionally
skipped for this milestone.
2026-06-12 22:40:57 +02:00
50 changed files with 16432 additions and 26 deletions
+28 -2
View File
@@ -45,6 +45,14 @@
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
- **Reviewer**(强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。
#### Reviewer 盲审纪律(M1 教训)
M1 里 review **从未触发过一次 rework**,根因是 orchestrator 把自己的结论 / 辩护喂给了 reviewer,造成 context bleed、review 沦为橡皮图章。所以:
- reviewer 必须**冷启动(Clear-Agent)、最小化喂料**——spawn prompt 只给:① 任务卡(`Acceptance criteria` + `Reviewer checklist`)、② 对应的 `review-notes/<task>-impl|rework-<n>.md` 路径、③ 要审的 diff / commit 范围。
- **不要**在 prompt 里塞 orchestrator 自己的判断、"我觉得没问题"、对实现选择的辩护,或上一轮 reviewer 的倾向性结论。让它**独立得出结论、独立重跑校验闸门**。
- 事后另起的整库**独立盲审**(如对抗复审)同理:Clear-Agent、最小上下文,把它当"**外部审计**"而非"确认自己没错"。
### 校验闸门(每个任务结束都要全绿)
根目录、激活 `.venv` 后:
@@ -56,6 +64,14 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
### 构建上下文完整性(M1 Dockerfile 教训)
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile``COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
- 任务**删除 / 移动 / 重命名文件或目录**时,必须 grep 构建清单是否还在引用它们:`Dockerfile`(尤其 `COPY` 源)、`docker/``*.ini`、CI workflow、`requirements*.txt` 等。
- 已有回归测试 `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 守"Dockerfile `COPY` 源必须存在于构建上下文";新增 / 改动 `COPY` 时确保它仍覆盖得到。
- Reviewer 审"删 / 移文件"类任务时,**必须顺带核对构建清单引用**,把它当 acceptance 的一部分。
## 每轮简报(`review-notes/`
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
@@ -97,12 +113,13 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
- 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
### Review 后返工
- 返工产生的提交**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
- **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
```bash
git add -A
git commit --fixup=<base-commit-sha>
```
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit。
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
### 本轮 / feature 收尾(用户确认收尾后)
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**
@@ -115,6 +132,15 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
### 一般约束
- commit / push 只在用户要求时进行;push、force-push、开/改 PR 等对外操作先确认。
## 发版前置走查(打 tag 前必做)
单元闸门绿 ≠ 真的能跑、能构建、能用。M1 出过"绿了但 docker 构建坏了"的事故,所以**打版本 tag(触发镜像 CI)之前**,除了 `pytest` / `ruff` 全绿,还要:
- **真起 app**:迁移(`python -m scripts.run_migrations`)→ `uvicorn app.main:app ...`,确认能正常启动、关键路由不 500。
- **真跑镜像构建**:本地 `docker build`(多阶段就跑完整条),确认构建通过、`COPY` 源都在。
- **关键功能人工瞄一眼**:尤其前端 / 可视化类(M2 的热力图、首页地图)——自动闸门判断不了"渲染对不对、UX 顺不顺",这部分**靠看跑起来的 app,不靠读代码**。
- 上述任一不过 → **不打 tag**。tag 一旦 push 会触发 docker 镜像 CI / 对外发布,属对外操作,**先确认**。
## 数据安全红线(不可违反)
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
View File
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_db
from app.schemas.config import (
ConfigField,
ConfigResponse,
ConfigSection,
ConfigUpdateRequest,
ConfigUpdateResponse,
SmtpTestResponse,
)
from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-config"])
def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]:
result = []
for section in sections_raw:
fields = [ConfigField(**f) for f in section["fields"]]
result.append(ConfigSection(name=section["name"], fields=fields))
return result
@router.get("/config", response_model=ConfigResponse)
def get_config(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
) -> ConfigResponse:
"""Return all configuration sections. Secret field values are masked (empty string)."""
sections_raw = build_config_sections(db, settings)
return ConfigResponse(sections=_sections_from_raw(sections_raw))
@router.put("/config", response_model=ConfigUpdateResponse)
def put_config(
body: ConfigUpdateRequest,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ConfigUpdateResponse:
"""
Save configuration updates.
- Blank secret value keeps the existing stored value (no change).
- Invalid values return 422 and nothing is written to the database.
"""
try:
save_config_updates(db, body.updates, settings)
except ConfigSaveError as exc:
logger.warning("Rejected config update via API: %s", exc)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="invalid config submission",
) from exc
# Re-read settings after save (save_config_updates clears the settings cache)
refreshed_settings = get_settings()
sections_raw = build_config_sections(db, refreshed_settings)
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
@router.post(
"/config/smtp/test",
responses={
200: {"model": SmtpTestResponse},
400: {"model": SmtpTestResponse},
502: {"model": SmtpTestResponse},
},
)
def post_smtp_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""
Send a test SMTP email using the current runtime settings.
Returns a structured result indicating success or the category of failure.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
SMTP credentials are never echoed in the response.
"""
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test rejected due to configuration: %s", exc)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"result": "config-error", "message": str(exc)},
)
except EmailDeliveryError as exc:
logger.warning("SMTP test delivery failed: %s", exc)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"result": "failed", "message": str(exc)},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"result": "success", "message": "Test email sent successfully."},
)
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.location import Location
from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState
from app.schemas.data import (
LocationRecord,
LocationUpdateRequest,
LocationsResponse,
PooRecord as PooRecordSchema,
PooResponse,
PooUpdateRequest,
PublicIPHistorySchema,
PublicIPResponse,
PublicIPStateSchema,
)
from app.services.auth import AuthenticatedSession
from app.services.location import delete_location, update_location
from app.services.poo import delete_poo_record, update_poo_record
router = APIRouter(prefix="/api", tags=["api-data"])
@router.get("/locations", response_model=LocationsResponse)
def get_locations(
limit: int = Query(default=1000, ge=1, le=5000),
offset: int = Query(default=0, ge=0),
start: str | None = Query(default=None),
end: str | None = Query(default=None),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> LocationsResponse:
"""
Return location records with optional time-window filtering and pagination.
- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds.
- Results are ordered by ``datetime`` ascending.
- ``limit`` is capped at 5000 to prevent full-table exports.
"""
stmt = select(Location)
if start is not None:
stmt = stmt.where(Location.datetime >= start)
if end is not None:
stmt = stmt.where(Location.datetime <= end)
stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit)
rows = db.execute(stmt).scalars().all()
items = [
LocationRecord(
person=row.person,
datetime=row.datetime,
latitude=row.latitude,
longitude=row.longitude,
altitude=row.altitude,
)
for row in rows
]
return LocationsResponse(items=items, limit=limit, offset=offset)
@router.get("/poo", response_model=PooResponse)
def get_poo(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> PooResponse:
"""
Return poo records ordered by timestamp descending (most recent first).
``limit`` is capped at 1000 to prevent full-table exports.
"""
stmt = (
select(PooRecord)
.order_by(desc(PooRecord.timestamp))
.offset(offset)
.limit(limit)
)
rows = db.execute(stmt).scalars().all()
items = [
PooRecordSchema(
timestamp=row.timestamp,
status=row.status,
latitude=row.latitude,
longitude=row.longitude,
)
for row in rows
]
return PooResponse(items=items, limit=limit, offset=offset)
@router.get("/public-ip", response_model=PublicIPResponse)
def get_public_ip(
limit: int = Query(default=100, ge=1, le=1000),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> PublicIPResponse:
"""
Return the current public IP state and recent history.
- ``state`` is ``null`` if no IP check has been performed yet.
- ``history`` is ordered by ``observed_at`` descending (most recent first).
- ``limit`` applies to the history list and is capped at 1000.
"""
state_row = db.execute(
select(PublicIPState).where(PublicIPState.id == 1).limit(1)
).scalar_one_or_none()
history_rows = db.execute(
select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit)
).scalars().all()
state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None
history = [PublicIPHistorySchema.model_validate(row) for row in history_rows]
return PublicIPResponse(state=state, history=history)
# ---------------------------------------------------------------------------
# PATCH /api/locations/{person}/{datetime}
# ---------------------------------------------------------------------------
@router.patch("/locations/{person}/{datetime}", response_model=LocationRecord)
def patch_location(
person: str,
datetime: str,
body: LocationUpdateRequest = Body(default=LocationUpdateRequest()),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> LocationRecord:
"""
Update the non-PK fields of a single location record.
- ``person`` and ``datetime`` identify the row (composite PK) and are immutable.
- Only ``latitude``, ``longitude``, and ``altitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.
"""
row = update_location(
db,
person,
datetime,
latitude=body.latitude,
longitude=body.longitude,
altitude=body.altitude,
)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="location record not found",
)
return LocationRecord(
person=row.person,
datetime=row.datetime,
latitude=row.latitude,
longitude=row.longitude,
altitude=row.altitude,
)
# ---------------------------------------------------------------------------
# DELETE /api/locations/{person}/{datetime}
# ---------------------------------------------------------------------------
@router.delete(
"/locations/{person}/{datetime}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_location_record(
person: str,
datetime: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
"""
Delete the single location record identified by its composite PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.
"""
deleted = delete_location(db, person, datetime)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="location record not found",
)
# ---------------------------------------------------------------------------
# PATCH /api/poo/{timestamp}
# ---------------------------------------------------------------------------
@router.patch("/poo/{timestamp}", response_model=PooRecordSchema)
def patch_poo(
timestamp: str,
body: PooUpdateRequest = Body(default=PooUpdateRequest()),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> PooRecordSchema:
"""
Update the non-PK fields of a single poo record.
- ``timestamp`` is the PK and is immutable.
- Only ``status``, ``latitude``, and ``longitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.
"""
row = update_poo_record(
db,
timestamp,
status=body.status,
latitude=body.latitude,
longitude=body.longitude,
)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="poo record not found",
)
return PooRecordSchema(
timestamp=row.timestamp,
status=row.status,
latitude=row.latitude,
longitude=row.longitude,
)
# ---------------------------------------------------------------------------
# DELETE /api/poo/{timestamp}
# ---------------------------------------------------------------------------
@router.delete(
"/poo/{timestamp}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_poo(
timestamp: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
"""
Delete the single poo record identified by its PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.
"""
deleted = delete_poo_record(db, timestamp)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="poo record not found",
)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from fastapi import Depends, Header, HTTPException, status
from app.dependencies import get_current_auth_session
from app.services.auth import AuthenticatedSession
def require_session(
auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> AuthenticatedSession:
if auth is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="authentication required",
)
return auth
def require_csrf(
_auth: AuthenticatedSession = Depends(require_session),
x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"),
) -> None:
if not x_csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="missing CSRF token",
)
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings
from app.dependencies import get_app_settings, get_db
from app.schemas.session import (
LoginRequest,
PasswordChangeRequest,
SessionResponse,
SessionUser,
)
from app.services.auth import (
AuthPasswordChangeError,
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
revoke_session,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-session"])
def _build_session_response(auth: AuthenticatedSession) -> SessionResponse:
return SessionResponse(
user=SessionUser(
username=auth.user.username,
force_password_change=auth.user.force_password_change,
),
csrf_token=auth.session.csrf_token,
)
@router.get("/session", response_model=SessionResponse)
def get_session(
auth: AuthenticatedSession = Depends(require_session),
) -> SessionResponse:
"""Return the current session user and CSRF token. Returns 401 if not authenticated."""
return _build_session_response(auth)
@router.post("/auth/login", response_model=SessionResponse)
def post_login(
body: LoginRequest,
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> SessionResponse:
"""
Authenticate with username and password.
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
On failure, returns 401 with no cookie set.
No X-CSRF-Token required (unauthenticated endpoint).
"""
user = authenticate_user(db, username=body.username, password=body.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
auth_session, raw_token = create_session(db, user=user, settings=settings)
logger.info("Created API authenticated session for user '%s'", user.username)
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="/",
)
auth = AuthenticatedSession(user=user, session=auth_session)
return _build_session_response(auth)
@router.post("/auth/logout")
def post_logout(
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Revoke the current session and clear the session cookie.
Requires authentication and X-CSRF-Token header.
Returns 204 No Content.
"""
revoke_session(db, auth_session=auth.session)
logger.info("Revoked API authenticated session for user '%s'", auth.user.username)
no_content = Response(status_code=status.HTTP_204_NO_CONTENT)
no_content.delete_cookie(settings.auth_session_cookie_name, path="/")
return no_content
@router.post("/auth/password")
def post_change_password(
body: PasswordChangeRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Change the current user's password.
Requires authentication and X-CSRF-Token header.
On AuthPasswordChangeError returns 400 with a generic message.
On success, force_password_change becomes False (handled by the service).
Returns 204 No Content.
"""
try:
change_password(
db,
user=auth.user,
current_password=body.current_password,
new_password=body.new_password,
confirm_password=body.confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
auth.user.username,
exc,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="password change failed",
) from exc
logger.info("Password updated for user '%s'", auth.user.username)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+6
View File
@@ -8,6 +8,9 @@ from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session
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.db import get_session_local
@@ -91,6 +94,9 @@ def create_app() -> FastAPI:
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)
app.include_router(homeassistant_router)
app.include_router(location_router)
app.include_router(poo_router)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
class ConfigField(BaseModel):
env_name: str
label: str
value: str
secret: bool
input_type: str
configured: bool
class ConfigSection(BaseModel):
name: str
fields: list[ConfigField]
class ConfigResponse(BaseModel):
sections: list[ConfigSection]
class ConfigUpdateRequest(BaseModel):
"""Flat mapping of env_name → value, mirroring the existing form semantics."""
updates: dict[str, str]
class ConfigUpdateResponse(BaseModel):
sections: list[ConfigSection]
class SmtpTestResponse(BaseModel):
"""Response from POST /api/config/smtp/test."""
result: Literal["success", "config-error", "failed"]
message: str
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Location
# ---------------------------------------------------------------------------
class LocationRecord(BaseModel):
person: str
datetime: str
latitude: float
longitude: float
altitude: float | None
class LocationsResponse(BaseModel):
items: list[LocationRecord]
limit: int
offset: int
class LocationUpdateRequest(BaseModel):
"""PATCH body for a location record — all fields optional; PK fields excluded."""
latitude: float | None = None
longitude: float | None = None
altitude: float | None = None
# ---------------------------------------------------------------------------
# Poo
# ---------------------------------------------------------------------------
class PooRecord(BaseModel):
timestamp: str
status: str
latitude: float
longitude: float
class PooResponse(BaseModel):
items: list[PooRecord]
limit: int
offset: int
class PooUpdateRequest(BaseModel):
"""PATCH body for a poo record — all fields optional; PK field excluded."""
status: str | None = None
latitude: float | None = None
longitude: float | None = None
# ---------------------------------------------------------------------------
# Public IP
# ---------------------------------------------------------------------------
class PublicIPStateSchema(BaseModel):
id: int
current_ipv4: str
previous_ipv4: str | None
first_seen_at: datetime
last_checked_at: datetime
last_changed_at: datetime | None
last_check_status: str
last_check_error: str | None
last_provider: str | None
model_config = {"from_attributes": True}
class PublicIPHistorySchema(BaseModel):
id: int
ipv4: str
observed_at: datetime
change_type: str
provider: str | None
model_config = {"from_attributes": True}
class PublicIPResponse(BaseModel):
state: PublicIPStateSchema | None
history: list[PublicIPHistorySchema]
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from pydantic import BaseModel
class SessionUser(BaseModel):
username: str
force_password_change: bool
class SessionResponse(BaseModel):
user: SessionUser
csrf_token: str
class LoginRequest(BaseModel):
username: str
password: str
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str
confirm_password: str
+56 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import insert
from sqlalchemy import delete, insert, select
from sqlalchemy.orm import Session
from app.models.location import Location
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
)
session.execute(stmt)
session.commit()
def update_location(
session: Session,
person: str,
datetime_pk: str,
*,
latitude: float | None,
longitude: float | None,
altitude: float | None,
) -> Location | None:
"""Update non-PK fields of a single location row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The caller must not pass PK fields — they are immutable.
Only fields with a non-``None`` value are written; ``altitude`` being
``None`` in the request means "leave unchanged", not "clear to NULL".
"""
row = session.execute(
select(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
).scalar_one_or_none()
if row is None:
return None
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
if altitude is not None:
row.altitude = altitude
session.commit()
session.refresh(row)
return row
def delete_location(session: Session, person: str, datetime_pk: str) -> bool:
"""Delete the single location row identified by its full composite PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
— no batch/truncate path exists.
"""
result = session.execute(
delete(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
)
session.commit()
return result.rowcount == 1
+48 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
import logging
from sqlalchemy import desc, insert, select
from sqlalchemy import delete, desc, insert, select
from sqlalchemy.orm import Session
from app.config import Settings
@@ -74,6 +74,53 @@ def record_poo(
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
def update_poo_record(
session: Session,
timestamp_pk: str,
*,
status: str | None,
latitude: float | None,
longitude: float | None,
) -> PooRecord | None:
"""Update non-PK fields of a single poo record row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The ``timestamp`` PK is immutable and must not be passed as an update field.
Only fields with a non-``None`` value are written.
"""
row = session.execute(
select(PooRecord).where(PooRecord.timestamp == timestamp_pk)
).scalar_one_or_none()
if row is None:
return None
if status is not None:
row.status = status
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
session.commit()
session.refresh(row)
return row
def delete_poo_record(session: Session, timestamp_pk: str) -> bool:
"""Delete the single poo record row identified by its PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
— no batch/truncate path exists.
"""
result = session.execute(
delete(PooRecord).where(PooRecord.timestamp == timestamp_pk)
)
session.commit()
return result.rowcount == 1
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
record = session.execute(stmt).scalar_one_or_none()
+39 -21
View File
@@ -27,15 +27,16 @@
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
- 继续用现有 **HttpOnly session cookie**(同源自动携带),M2 **不引入 token**token 属 M3)。
- CSRF:新增 `GET /api/session` 返回当前用户 + 该会话的 `csrf_token`SPA 在所有写请求(POST/PUT/PATCH/DELETE)放 `X-CSRF-Token` header,后端校验其与 session 内 `csrf_token` 一致。等价于把现有表单 CSRF 平移到 header
- CSRF(已定·简化版):依赖 `SameSite=Lax` 的 session cookie——跨站发起的写请求(POST/PUT/PATCH/DELETE**不会自动携带 cookie**,经典 CSRF 主路已被堵;再要求所有写请求带一个**自定义 header**(跨站无 CORS 预检发不出,且本应用不对外站开放 CORS)作为纵深防御。**不做 per-session token 比对**(个人自用场景足够)。`GET /api/session` 仍保留,用途是返回当前登录用户、引导 SPA(不再以下发/校验 `csrf_token` 为目的)
- 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record``POST /poo/record`)维持现状到 M3**。
### 3.3 前端工程
- `frontend/`**Vite + React + TypeScript**。
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装,或同类工具)。生成物入库或在 build 时生成(见 T06 决策)。
- 可视化:地图 + 热力图(location 轨迹 / poo 点位)。建议 **MapLibre GL 或 Leaflet + heatmap 插件**(最终选型见 §5 决策)
- 状态/数据请求:轻量即可(如 TanStack Query),不引入重型框架
- 组件库:**Mantine**(已定;批电池齐、TS 优先、视觉中性,最贴近此前 Vue 侧 Naive UI 的用法)。
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装)。**生成物入库** + `npm run codegen` + CI 校验"生成物与 openapi 同步"(已定)。fetch 封装统一带 cookie、写请求注入自定义 CSRF header、401 跳登录
- 可视化:**Leaflet**(已定)—— `react-leaflet` + `leaflet.heat`(热力图,**头号功能**+ `leaflet.markercluster`(点多时聚合)+ OSM 栅格瓦片(零 key)。**地图封在一个自包含组件后面**(如 `<RecordsMap points mode onSelect>`,全应用只此处 import leaflet),数据获取/时间窗 state 在外面;这样将来若要换 **MapLibre GL** 是被隔离的局部重写,不波及其它
- 状态/数据请求:轻量即可(**TanStack Query**,已定),不引入重型框架。
### 3.4 构建与部署
@@ -65,14 +66,31 @@
> 记录 CRUD 依赖现有 PK 作行标识(location PK=`person+datetime`poo PK=`timestamp`)。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。
## 5. 需先拍板的决策(Orchestrator 在派 T06 前确认
## 5. 已锁定决策(讨论后拍板
1. **地图/热力图库**MapLibre GL(矢量、现代)vs Leaflet(简单、生态大)。推荐 Leaflet + `leaflet.heat`(试水门槛低)。
2. **OpenAPI client 生成物**:入库(确定性、便于 review)vs build 时生成(仓库干净)。推荐**入库**,并加一个 `npm run codegen` + CI 校验"生成物与 openapi 同步"。
3. **CSRF 落地**header `X-CSRF-Token` + `GET /api/session` 下发(推荐)vs 双提交 cookie。
4. **是否保留少量 Jinja**:建议 SPA 对齐后**全量移除** `templates/`,只留 SPA。
> 以下为与项目所有者讨论后**已定**的选择。**线框图本里程碑不画**——按本节 + 各任务卡描述,由实现侧自行合理排版(含移动端布局)。
> 这些可用 1 个轻量"决策任务"或直接由 Orchestrator 在本节记录选择,再开 T06。
**技术选型**
1. **组件库 = Mantine**。批电池齐、TS 优先、视觉中性、文档好,最贴近此前 Naive UI 的用法,利于 agent 产出一致 UI。
2. **地图库 = Leaflet**`react-leaflet` + `leaflet.heat` + `leaflet.markercluster`,OSM 栅格、零 key)。**封在自包含组件后**,预留将来迁 MapLibre 的接缝(见 §3.3)。
3. **OpenAPI client = 生成物入库** + `npm run codegen` + CI 校验"与 openapi 同步"。
4. **CSRF = 简化版**`SameSite=Lax` cookie + 写请求带自定义 header**不做 per-session token**(见 §3.2)。
5. **前端栈**Vite + React + TS + TanStack Query + Mantine。
6. **Jinja**SPA 功能对齐后**全量移除** `templates/``pages.py`
**信息架构 / UX**
7. **首页主视图 = 地图(热力图为主)+ 时间范围选择器**。可视化优先级:**热力图(最重要)> 时间选择器(必须)> 散点点位/列表(辅助)**。
8. **列表 = 辅助页面,分页**(默认页大小 ~100、有上限;前端换页取数,不拉全量)。
9. **记录编辑/删除****location 靠点地图上的点**触发(不做 75k 行大列表);**poo 靠列表 + 地图点位**。
10. **配置入口**:config 作为普通页之一,由界面上一个**齿轮图标**进入。`/admin``/` 现状只是重定向到 `/config`SPA **不需要单独 admin 页**`/` 首页直接给地图主视图(概览 dashboard 列为**可选/后续**,非 M2 核心)。
11. **响应式 = 要**(手机浏览器可用、合理移动端布局)。**PWA** 列为近期 backlog(见 `docs/future-ideas.md`),M2 设计即按移动端友好铺路。
**范围边界**
12. **CRUD = 改非主键字段 + 删单行**;主键(location=`person+datetime`、poo=`timestamp`)**不可改**;**不提供 UI 新建**(记录由设备 ingestion 产生)。
13. **裸 ingestion 端点**`POST /location/record``POST /poo/record`)**维持现状到 M3**,本里程碑不加保护、不改动。
14. **trip / 轨迹连线**为**可选 / 后续**(5 分钟一点 + 手机记录较糙,先不做核心)。
> 项目定位:个人自用、家庭特化、不开源——设计可按单用户场景简化,不为通用性过度抽象。
## 6. 任务依赖图
@@ -104,7 +122,7 @@
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
### M2-T01 — config JSON API
- **Status**: `todo` · **Depends**: noneM1 完成后)
- **Status**: `done` · **Depends**: noneM1 完成后)
- **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。
- **Files**: `create app/api/routes/api/config.py``create app/schemas/config.py``modify app/main.py`(注册路由);`create tests/test_api_config.py`
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`session 保护;secret 不回显、留空保留旧值语义照搬。
@@ -116,7 +134,7 @@
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
### M2-T02 — session / auth JSON API
- **Status**: `todo` · **Depends**: none
- **Status**: `done` · **Depends**: none
- **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。
- **Files**: `create app/api/routes/api/session.py``app/schemas/session.py``modify app/main.py``create tests/test_api_session.py`
- **Steps**: `GET /api/session`401 或 user+csrf)、`POST /api/auth/login``POST /api/auth/logout``POST /api/auth/password`,复用 `app/services/auth.py`
@@ -129,7 +147,7 @@
- **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env``SameSite=Lax`;密码仍 Argon2,不明文。
### M2-T03 — 数据读取 APIlocations / poo / public-ip
- **Status**: `todo` · **Depends**: none
- **Status**: `done` · **Depends**: none
- **Files**: `create app/api/routes/api/data.py``app/schemas/data.py``modify app/main.py``create tests/test_api_data.py`
- **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`state + history);session 保护;查询参数有上限防全表导出。
- **Acceptance**:
@@ -139,7 +157,7 @@
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
### M2-T04 — 记录 CRUD API(修正 / 删除)
- **Status**: `todo` · **Depends**: M2-T03
- **Status**: `done` · **Depends**: M2-T03
- **Files**: `modify app/api/routes/api/data.py``app/services/location.py``app/services/poo.py``create tests/test_api_record_crud.py`
- **Steps**: `PATCH`/`DELETE` locationPK person+datetime)与 pooPK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
- **Acceptance**:
@@ -151,7 +169,7 @@
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
### M2-T05 — SMTP 测试 / 动作类 JSON API
- **Status**: `todo` · **Depends**: M2-T01
- **Status**: `done` · **Depends**: M2-T01
- **Files**: `modify app/api/routes/api/config.py``modify tests/test_api_config.py`
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。
- **Acceptance**:
@@ -159,7 +177,7 @@
- [ ] 校验闸门全绿。
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
- **Status**: `todo` · **Depends**: M2-T01..T05OpenAPI 已稳定)
- **Status**: `done` · **Depends**: M2-T01..T05OpenAPI 已稳定)
- **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。
- **Files**: `create frontend/`Vite+React+TS 脚手架、`package.json``tsconfig.json`、eslint、vitest、`.gitignore`)、`frontend/src/api/`codegen 产物 + fetch 封装,自动注入 `X-CSRF-Token`)、`frontend/README.md``npm run codegen` 脚本
- **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
@@ -170,17 +188,17 @@
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
- **Status**: `todo` · **Depends**: M2-T06
- **Status**: `done` · **Depends**: M2-T06
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
### M2-T08 — 配置 UI(取代 Jinja config 页)
- **Status**: `todo` · **Depends**: M2-T06
- **Status**: `done` · **Depends**: M2-T06
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
### M2-T09 — 数据可视化 UI地图 + 热力图)
### M2-T09 — 数据可视化 UI(热力图为主的地图)
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03
- **Context**: 接管 Grafana 原职责location 轨迹/热力图、poo 点位
- **Acceptance**: 地图渲染 location/poo 点;热力图层可切换;时间范围筛选生效;前端闸门全绿。
- **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉
- **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。
### M2-T10 — 记录管理 UI(按需展示 + 增删改)
- **Status**: `todo` · **Depends**: M2-T06CRUD 来自 T04
+20
View File
@@ -0,0 +1,20 @@
# Future Ideas / Backlog(暂无 Milestone
记录尚未排期的想法。等某条成形、值得集中推进时,再升级为 `docs/roadmap.md` 里的 milestone 并展开成 `docs/design/` 任务卡。**这里只是备忘,不是承诺。**
> 项目定位:**个人自用、针对自家场景特化,不开源**。因此设计可按单用户 / 自家需求简化,不必为通用性、多租户、对外发布做过度抽象。
## 数据与存储
- 增加更多数据类型 / 来源(持续扩展)。
- 针对**需要长期保存**的数据,考虑更合适的存储方案(当前全 SQLite;长期 / 大量数据可能需要更强的数据库)。
- 把 **Home Assistant 接收到的数据**纳入本系统做持久化 / 展示。
## 集成
- **MQTT**:让后端作为一个 MQTT client,双向收发数据。
## 前端 / 移动端
- **PWA**(**近期、可能并入 M2 或单独小里程碑**):在 React NativeM3)之前,用 PWA 把 web SPA 包装成"准手机 App"——可安装到桌面、响应式、离线壳。
- 影响当下设计:**M2 的 UI 从一开始就按移动端布局考虑**(响应式 + 合理的参数显示),为之后加 PWA 铺路。
## 备注
- 以上为临时记录(讨论 M2 范围时随手想到),后续可增删、重排优先级。
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
dist-ssr/
*.local
.env
.env.*
!.env.example
+209
View File
@@ -0,0 +1,209 @@
# Home Automation — Frontend
React SPA for the home-automation backend. Built with Vite + React 18 + TypeScript.
Scaffolded in M2-T06; feature pages filled in by T07T10.
## Stack
| Layer | Library | Version |
|---|---|---|
| Build | Vite | 6.x |
| UI framework | React | 18.x |
| Language | TypeScript | 5.x |
| Component library | Mantine | 7.x |
| Data fetching | TanStack Query | 5.x |
| Routing | react-router-dom | 6.x |
| API client codegen | openapi-typescript | 7.x |
| API client runtime | openapi-fetch | 0.17.x |
| Testing | Vitest + @testing-library/react | 4.x / 14.x |
## npm Scripts
| Command | What it does |
|---|---|
| `npm run dev` | Start Vite dev server (with backend proxy — see below) |
| `npm run build` | `tsc -b && vite build` — type-check then build to `dist/` |
| `npm run preview` | Serve the built `dist/` locally |
| `npm run lint` | ESLint (flat config, React + TypeScript rules) |
| `npm run typecheck` | `tsc --noEmit` — type-check without emitting files |
| `npm run test` | Vitest (run once, no watch) |
| `npm run codegen` | Regenerate `src/api/schema.d.ts` from `../openapi/openapi.json` |
All frontend gates must pass before any task is considered done:
```bash
npm run codegen
npm run lint
npm run typecheck
npm run test
npm run build # must produce dist/
```
## Directory Structure
```
frontend/
├── index.html Vite entry HTML
├── vite.config.ts Vite + Vitest config; dev proxy
├── tsconfig.json References tsconfig.app.json + tsconfig.node.json
├── tsconfig.app.json App source TS config (strict, react-jsx)
├── tsconfig.node.json Vite config TS config
├── eslint.config.js Flat ESLint config (React + TypeScript rules)
├── package.json Dependencies + npm scripts
├── package-lock.json Lockfile (committed; CI uses npm ci)
└── src/
├── main.tsx Entry point; mounts <App> into #root
├── App.tsx Provider stack + route tree (MantineProvider → QueryClient → Router → SessionProvider)
├── vite-env.d.ts /// <reference types="vite/client" /> for CSS imports
├── test-setup.ts Vitest global setup (@testing-library/jest-dom)
├── api/
│ ├── schema.d.ts AUTO-GENERATED from openapi/openapi.json (committed)
│ ├── client.ts openapi-fetch client + CSRF/cookie/401 middleware
│ └── csrf.ts Module-level CSRF token holder (setCsrfToken / getCsrfToken)
├── auth/
│ ├── SessionProvider.tsx TanStack Query against GET /api/session; exposes useSession()
│ └── ProtectedRoute.tsx Redirects to /login when unauthenticated
└── pages/
├── LoginPage.tsx Placeholder → T07 builds the real form
├── HomePage.tsx Placeholder → T09 builds the map/heatmap view
└── ConfigPage.tsx Placeholder → T08 builds the config editor
```
## Dev Proxy (local development)
`npm run dev` starts Vite on port 5173. The Vite config proxies API/auth paths
to the FastAPI backend running on port 8000:
| Proxied path | Backend URL |
|---|---|
| `/api/*` | `http://localhost:8000` |
| `/login` | `http://localhost:8000` |
| `/logout` | `http://localhost:8000` |
| `/static/*` | `http://localhost:8000` |
| `/docs` | `http://localhost:8000` |
| `/openapi.json` | `http://localhost:8000` |
To develop locally:
1. Start the backend: `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000`
2. Start the frontend: `cd frontend && npm run dev`
3. Open `http://localhost:5173` — the app proxies all API calls to the backend.
Since the dev server proxies the session cookie path, auth flows work exactly as
they would in the deployed (same-origin) setup.
## Adding a New Page + Typed Query
This is the pattern every task T07T10 follows to wire up a real page:
### 1. Run codegen (if the OpenAPI contract changed)
```bash
npm run codegen
```
The generated `src/api/schema.d.ts` is committed to the repo. CI enforces that
the file is in sync with `openapi/openapi.json` via:
```bash
npm run codegen && git diff --exit-code frontend/src/api/schema.d.ts
```
### 2. Import the typed client
```typescript
// src/pages/SomePage.tsx
import apiClient from '../api/client'
```
### 3. Write a typed TanStack Query
```typescript
import { useQuery } from '@tanstack/react-query'
import apiClient from '../api/client'
function usePooRecords(limit = 100) {
return useQuery({
queryKey: ['poo', { limit }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', { params: { query: { limit } } })
// res.data is typed as PooResponse | undefined
// On non-2xx the middleware throws ApiError; TanStack Query catches it.
return res.data
},
})
}
```
The `params.query` and `params.path` objects are fully typed from `schema.d.ts`.
TypeScript will error if you pass unknown query params or mistype a path param.
### 4. Write a typed mutation (write request)
```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client'
function useDeletePoo() {
const qc = useQueryClient()
return useMutation({
mutationFn: (timestamp: string) =>
apiClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp } },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
})
}
```
The middleware (`src/api/client.ts`) automatically injects the `X-CSRF-Token` header
on all non-GET/HEAD requests (sourced from `getCsrfToken()`). You do not need to
handle CSRF manually in page code.
### 5. Add the route in App.tsx
```typescript
// App.tsx
import { SomePage } from './pages/SomePage'
// Inside <Routes>:
<Route path="/some-path" element={<SomePage />} />
// or, if protected:
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/some-path" element={<SomePage />} />
</Route>
```
## OpenAPI codegen + CI sync rule
`src/api/schema.d.ts` is committed to the repository (not gitignored).
**Rule**: whenever `openapi/openapi.json` changes (any backend task that modifies
a route or schema), CI must run:
```bash
cd frontend && npm run codegen
git diff --exit-code frontend/src/api/schema.d.ts
```
If the file has changed but the new version was not committed, CI fails.
To update manually after a backend change:
```bash
cd frontend
npm run codegen
git add src/api/schema.d.ts
git commit -m "M2-Txx: update generated OpenAPI types"
```
## Production Build
The production build (`npm run build`) writes static files to `frontend/dist/`.
In the deployed setup (M2-T11 onwards), FastAPI serves `dist/` as a static
directory and falls back to `dist/index.html` for all non-`/api` paths,
enabling client-side routing with deep links.
The multi-stage Dockerfile (M2-T12) builds the frontend in a Node container and
copies only `dist/` into the Python image — the production image does not
contain Node or npm.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactPlugin from 'eslint-plugin-react'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'src/api/schema.d.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
react: reactPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home Automation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7204
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "home-automation-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"codegen": "openapi-typescript ../openapi/openapi.json -o ./src/api/schema.d.ts"
},
"dependencies": {
"@mantine/core": "^7.17.8",
"@mantine/hooks": "^7.17.8",
"@tanstack/react-query": "^5.101.0",
"openapi-fetch": "^0.17.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.13.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.61.0",
"vite": "^6.4.3",
"vitest": "^4.1.8"
}
}
+162
View File
@@ -0,0 +1,162 @@
/**
* App — top-level provider stack and route tree.
*
* Provider order (outermost first):
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
*
* Route tree:
* /login → LoginPage (public)
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
* / → ProtectedRoute → AppLayout → HomePage (T09)
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
*
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
*/
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
import { Button, Group } from '@mantine/core'
// Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css'
import { SessionProvider } from './auth/SessionProvider'
import { ProtectedRoute } from './auth/ProtectedRoute'
import { LoginPage } from './pages/LoginPage'
import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage'
import apiClient from './api/client'
import { useQueryClient } from '@tanstack/react-query'
// ---------------------------------------------------------------------------
// TanStack Query client (singleton, created outside render to avoid re-creation)
// ---------------------------------------------------------------------------
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Don't retry on 4xx — we handle 401 in the middleware
retry: (failureCount, error) => {
if (error instanceof Error && 'status' in error) {
const status = (error as unknown as { status: number }).status
if (status >= 400 && status < 500) return false
}
return failureCount < 2
},
},
},
})
// ---------------------------------------------------------------------------
// Logout button component (needs navigate + queryClient hooks, so it's a component)
// ---------------------------------------------------------------------------
function LogoutButton() {
const navigate = useNavigate()
const qc = useQueryClient()
async function handleLogout() {
try {
await apiClient.POST('/api/auth/logout')
} catch {
// Ignore errors on logout — we clear the session regardless.
}
// Invalidate session so SessionProvider becomes unauthenticated.
await qc.invalidateQueries({ queryKey: ['session'] })
navigate('/login', { replace: true })
}
return (
<Button variant="subtle" size="xs" onClick={handleLogout} data-testid="logout-button">
Log out
</Button>
)
}
// ---------------------------------------------------------------------------
// App shell layout (used by all protected pages)
// ---------------------------------------------------------------------------
function AppLayout() {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Top nav */}
<nav
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.5rem 1rem',
borderBottom: '1px solid #eee',
}}
>
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
Home Automation
</Link>
<Group gap="xs">
{/* Gear icon nav slot — links to config page (§5#10) */}
<Link
to="/config"
aria-label="Configuration"
style={{ fontSize: '1.25rem', textDecoration: 'none' }}
title="Configuration"
>
</Link>
<LogoutButton />
</Group>
</nav>
{/* Page content */}
<main style={{ flex: 1 }}>
<Outlet />
</main>
</div>
)
}
// ---------------------------------------------------------------------------
// Root app
// ---------------------------------------------------------------------------
export default function App() {
return (
<MantineProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SessionProvider>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
<Route
path="/change-password"
element={
<ProtectedRoute>
<ChangePasswordPage />
</ProtectedRoute>
}
/>
{/* Protected routes — all nested under AppLayout */}
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<HomePage />} />
<Route path="/config" element={<ConfigPage />} />
</Route>
</Routes>
</SessionProvider>
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
)
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Typed API client built on openapi-fetch + generated schema.d.ts.
*
* Middleware contract (orchestrator-decisions.md §11):
* 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear).
* 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder.
* Exception: POST /api/auth/login skips injection (unauthenticated endpoint).
* 3. 401 responses → clear session state + navigate to /login.
* 4. Other non-2xx responses → throw an ApiError carrying the parsed JSON body,
* so callers (e.g. SMTP test) can inspect body.result.
*/
import createClient, { type Middleware } from 'openapi-fetch'
import type { paths } from './schema.d.ts'
import { getCsrfToken } from './csrf'
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */
export class ApiError extends Error {
constructor(
public readonly status: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public readonly body: any,
) {
super(`API error ${status}`)
this.name = 'ApiError'
}
}
// ---------------------------------------------------------------------------
// Internal navigation helper (avoids React-router import at module level)
// ---------------------------------------------------------------------------
let _navigateToLogin: (() => void) | null = null
/**
* Register a callback that the middleware calls on 401.
* SessionProvider calls this during its setup.
*/
export function registerLoginRedirect(fn: () => void): void {
_navigateToLogin = fn
}
// ---------------------------------------------------------------------------
// CSRF middleware
// ---------------------------------------------------------------------------
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const LOGIN_PATH = '/api/auth/login'
const csrfMiddleware: Middleware = {
async onRequest({ request }) {
// Always include cookies (same-origin; explicit for clarity)
// Note: credentials is set at client level; this is belt-and-suspenders doc.
const method = request.method.toUpperCase()
const url = new URL(request.url)
if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) {
const token = getCsrfToken()
if (token) {
request.headers.set('X-CSRF-Token', token)
}
}
return request
},
async onResponse({ response }) {
if (response.status === 401) {
// Clear any cached session state by triggering a page navigation.
// The SessionProvider query will refetch and find no session.
if (_navigateToLogin) {
_navigateToLogin()
}
// Return the original response so callers can handle 401 if needed.
return response
}
if (!response.ok) {
// Parse body and throw; caller can catch ApiError and read .body
let body: unknown
try {
body = await response.clone().json()
} catch {
body = null
}
throw new ApiError(response.status, body)
}
return response
},
}
// ---------------------------------------------------------------------------
// Client instance
// ---------------------------------------------------------------------------
const apiClient = createClient<paths>({
baseUrl: '/',
credentials: 'include',
})
apiClient.use(csrfMiddleware)
export default apiClient
+35
View File
@@ -0,0 +1,35 @@
/**
* Smoke tests for the CSRF token holder.
* These run in isolation (no DOM, no React) and validate the module contract.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { setCsrfToken, getCsrfToken } from './csrf'
describe('csrf holder', () => {
beforeEach(() => {
// Reset to empty between tests by setting empty string
setCsrfToken('')
})
it('returns empty string before any token is set', () => {
expect(getCsrfToken()).toBe('')
})
it('stores and returns the token that was set', () => {
setCsrfToken('test-token-abc123')
expect(getCsrfToken()).toBe('test-token-abc123')
})
it('overwrites a previously set token', () => {
setCsrfToken('first')
setCsrfToken('second')
expect(getCsrfToken()).toBe('second')
})
it('can be reset to empty', () => {
setCsrfToken('some-token')
setCsrfToken('')
expect(getCsrfToken()).toBe('')
})
})
+23
View File
@@ -0,0 +1,23 @@
/**
* Module-level CSRF token holder.
*
* The token is populated by SessionProvider after a successful GET /api/session.
* The fetch client middleware reads it on every non-GET/HEAD request.
*
* Per the project CSRF contract (m2-frontend-v2.md §3.2, orchestrator-decisions.md §3):
* - Server checks presence/non-empty only, does NOT validate the value.
* - Sending an empty-string or stale value will result in a 403; callers must
* ensure setCsrfToken() is called before issuing write requests.
*/
let _csrfToken = ''
/** Store the CSRF token returned by GET /api/session. */
export function setCsrfToken(token: string): void {
_csrfToken = token
}
/** Return the current CSRF token (may be empty string if not yet set). */
export function getCsrfToken(): string {
return _csrfToken
}
+1651
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
/**
* ProtectedRoute renders children when authenticated; redirects to /login otherwise.
*
* Additional gate (M2-T07):
* - If the authenticated user has force_password_change === true, redirect to
* /change-password instead of rendering children. This prevents access to any
* protected page until the password is changed.
* - Shows a loading spinner while the session is still resolving to avoid flash-of-login.
* - On unauthenticated access, preserves the intended destination in location.state.from
* so LoginPage can redirect back after login.
*/
import type { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { Center, Loader } from '@mantine/core'
import { useSession } from './SessionProvider'
interface ProtectedRouteProps {
children: ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { status, user } = useSession()
const location = useLocation()
if (status === 'loading') {
// Render a centred spinner while we check the session — avoids a flash to /login.
return (
<Center mih="100vh">
<Loader />
</Center>
)
}
if (status === 'unauthenticated') {
// Preserve the intended destination so LoginPage can redirect back after login.
return <Navigate to="/login" state={{ from: location }} replace />
}
// Authenticated but forced to change password — gate all protected pages.
if (user?.force_password_change && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />
}
return <>{children}</>
}
+109
View File
@@ -0,0 +1,109 @@
/**
* SessionProvider fetches GET /api/session once on mount via TanStack Query.
*
* Contract (orchestrator-decisions.md §4, §11):
* - 200 authenticated; calls setCsrfToken(data.csrf_token) so write requests work.
* - 401 unauthenticated (not an error toast; normal state before login).
* - Exposes { user, status } to descendants via useSession().
*
* Also registers the 401 /login redirect with the API client middleware.
*/
import { createContext, useContext, useEffect, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import apiClient, { registerLoginRedirect } from '../api/client'
import { setCsrfToken } from '../api/csrf'
import type { components } from '../api/schema.d.ts'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type SessionUser = components['schemas']['SessionUser']
type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated'
interface SessionContextValue {
user: SessionUser | null
status: SessionStatus
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const SessionContext = createContext<SessionContextValue>({
user: null,
status: 'loading',
})
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/** Access the current session from any descendant component. */
export function useSession(): SessionContextValue {
return useContext(SessionContext)
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
interface SessionProviderProps {
children: ReactNode
}
export function SessionProvider({ children }: SessionProviderProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
// Register the 401 redirect callback with the API client once.
useEffect(() => {
registerLoginRedirect(() => {
// Invalidate the session query so any subscriber re-fetches (→ unauthenticated).
queryClient.invalidateQueries({ queryKey: ['session'] })
navigate('/login', { replace: true })
})
}, [navigate, queryClient])
const { data, status, error } = useQuery({
queryKey: ['session'],
queryFn: async () => {
const res = await apiClient.GET('/api/session')
// openapi-fetch returns { data, error, response }.
// On 401 the middleware already navigates; here data will be undefined.
return res.data ?? null
},
// Don't treat 401 as a React Query "error" — it's a normal unauthenticated state.
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
})
// When we get session data, store the CSRF token.
useEffect(() => {
if (data?.csrf_token) {
setCsrfToken(data.csrf_token)
}
}, [data])
let sessionStatus: SessionStatus
if (status === 'pending') {
sessionStatus = 'loading'
} else if (status === 'error' || data === null || !data) {
// 401 returns null from our queryFn; any actual network error → unauthenticated.
sessionStatus = 'unauthenticated'
// Suppress unused variable warning for error in non-401 cases
void error
} else {
sessionStatus = 'authenticated'
}
const value: SessionContextValue = {
user: data?.user ?? null,
status: sessionStatus,
}
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Entry point mounts the React app into #root.
*/
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element #root not found in document')
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,193 @@
/**
* Tests for ChangePasswordPage (M2-T07 rework-1).
*
* Strategy: vi.mock the apiClient and useSession modules so we can control
* POST /api/auth/password responses and session state without a real server.
*
* Coverage:
* 1. Renders the change-password form when user has force_password_change=true.
* 2. Successful password change navigates to '/' (proceeds into the app).
* 3. Client-side mismatch shows error, does NOT call the API.
* 4. API 400 error shows generic error, stays on form.
* 5. Guard: non-forced user visiting /change-password redirected to '/'.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { ChangePasswordPage } from './ChangePasswordPage'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
POST: (...args: unknown[]) => mockPost(...args),
GET: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Mock useSession — default: forced-change user
// ---------------------------------------------------------------------------
const mockUseSession = vi.fn(() => ({
status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated',
user: { username: 'admin', force_password_change: true } as
| null
| { username: string; force_password_change: boolean },
}))
vi.mock('../auth/SessionProvider', () => ({
useSession: () => mockUseSession(),
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderChangePw(initialPath = '/change-password') {
return renderWithProviders(<ChangePasswordPage />, {
initialPath,
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
})
}
function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) {
fireEvent.change(screen.getByTestId('current-password-input'), {
target: { value: currentPw },
})
fireEvent.change(screen.getByTestId('new-password-input'), {
target: { value: newPw },
})
fireEvent.change(screen.getByTestId('confirm-password-input'), {
target: { value: confirmPw },
})
fireEvent.submit(screen.getByTestId('change-password-form'))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ChangePasswordPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: authenticated user with force_password_change=true
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: true },
})
})
it('renders the change-password form for a forced-change user', () => {
renderChangePw()
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
expect(screen.getByTestId('current-password-input')).toBeInTheDocument()
expect(screen.getByTestId('new-password-input')).toBeInTheDocument()
expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument()
expect(screen.getByTestId('change-password-submit')).toBeInTheDocument()
})
it('navigates to "/" after a successful password change', async () => {
// Simulate successful POST /api/auth/password
mockPost.mockResolvedValueOnce({
data: {},
response: { status: 200, ok: true },
})
renderChangePw()
fillAndSubmit('old-password', 'new-password', 'new-password')
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
it('calls POST /api/auth/password with the correct body', async () => {
mockPost.mockResolvedValueOnce({
data: {},
response: { status: 200, ok: true },
})
renderChangePw()
fillAndSubmit('current123', 'newpass456', 'newpass456')
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/api/auth/password', {
body: {
current_password: 'current123',
new_password: 'newpass456',
confirm_password: 'newpass456',
},
})
})
})
it('shows error and does NOT call the API when new passwords do not match', async () => {
renderChangePw()
fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2')
await waitFor(() => {
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
})
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
/do not match/i,
)
expect(mockPost).not.toHaveBeenCalled()
// Should remain on the form
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
})
it('shows generic error on API 400 and stays on form', async () => {
// Simulate 400 via ApiError throw (as the client middleware does)
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' }))
renderChangePw()
fillAndSubmit('wrong-current', 'newpass', 'newpass')
await waitFor(() => {
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
})
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
/password change failed/i,
)
// Should NOT have navigated away
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
})
it('redirects a non-forced user away from /change-password to "/"', async () => {
// A user who has already changed their password
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: false },
})
renderChangePw()
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
// The change-password form must NOT be shown
expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument()
})
})
+168
View File
@@ -0,0 +1,168 @@
/**
* ChangePasswordPage forced password change gate (M2-T07).
*
* Shown when the authenticated user has force_password_change === true.
* Blocks access to all other pages until the password is changed.
*
* Behaviours:
* - If the current user does NOT have force_password_change, redirect to '/'
* (mirrors LoginPage's already-authenticated guard).
* - POST /api/auth/password with { current_password, new_password, confirm_password }.
* - On ApiError 400 show a generic failure message (do not leak details).
* - On success invalidate ['session'] so SessionProvider re-fetches with
* force_password_change=false, then navigate to '/' to enter the app.
*/
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import {
Container,
Paper,
Title,
Text,
PasswordInput,
Button,
Alert,
Stack,
Center,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient from '../api/client'
import { ApiError } from '../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LocationState {
from?: { pathname: string }
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ChangePasswordPage() {
const { user } = useSession()
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Guard: if the user is authenticated but NOT in forced-change state, redirect
// to the app. This prevents a non-forced user from sitting on /change-password.
// (Mirrors LoginPage's already-authenticated redirect.)
if (user && !user.force_password_change) {
const from = (location.state as LocationState)?.from?.pathname ?? '/'
return <Navigate to={from} replace />
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// Client-side validation: confirm passwords match before hitting the server.
if (newPassword !== confirmPassword) {
setError('New passwords do not match.')
return
}
setLoading(true)
try {
await apiClient.POST('/api/auth/password', {
body: {
current_password: currentPassword,
new_password: newPassword,
confirm_password: confirmPassword,
},
})
// Success: refresh session so force_password_change becomes false,
// then navigate into the app — the guard above (and ProtectedRoute) will
// no longer block access once the session is updated.
await queryClient.invalidateQueries({ queryKey: ['session'] })
navigate('/', { replace: true })
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
// Generic failure message — do not leak backend detail.
setError('Password change failed. Please check your current password and try again.')
} else {
setError('An unexpected error occurred. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<Center mih="100vh">
<Container size="xs" w="100%">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Title order={2} mb="xs" ta="center">
Change Password
</Title>
<Text c="dimmed" size="sm" mb="lg" ta="center">
You must change your password before continuing.
</Text>
{error && (
<Alert color="red" mb="md" role="alert" data-testid="change-password-error">
{error}
</Alert>
)}
<form onSubmit={handleSubmit} data-testid="change-password-form">
<Stack gap="md">
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="current-password-input"
/>
<PasswordInput
label="New Password"
placeholder="Enter your new password"
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required
autoComplete="new-password"
data-testid="new-password-input"
/>
<PasswordInput
label="Confirm New Password"
placeholder="Confirm your new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
autoComplete="new-password"
data-testid="confirm-password-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="change-password-submit"
>
Change Password
</Button>
</Stack>
</form>
</Paper>
</Container>
</Center>
)
}
+337
View File
@@ -0,0 +1,337 @@
/**
* Tests for ConfigPage (M2-T08).
*
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
* without a real server.
*
* Coverage:
* 1. Renders config sections from a mocked GET /api/config response.
* 2. Secret fields start as empty (never display masked value).
* 3. Non-secret fields show their loaded values.
* 4. Save: updates map includes all non-secret fields and excludes untouched secrets.
* 5. Save: updates map includes a secret only when the user typed a new value.
* 6. Save success shows success notice.
* 7. Save error shows error notice.
* 8. SMTP test button: success state (200 result=success).
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
* 10. SMTP test button: failed state (502/ApiError result=failed).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { ConfigPage } from './ConfigPage'
// ---------------------------------------------------------------------------
// Fixture: config sections
// ---------------------------------------------------------------------------
const MOCK_CONFIG = {
sections: [
{
name: 'General',
fields: [
{ env_name: 'APP_NAME', label: 'App Name', value: 'My Home', secret: false, input_type: 'text', configured: true },
{ env_name: 'APP_PORT', label: 'Port', value: '8000', secret: false, input_type: 'number', configured: true },
],
},
{
name: 'SMTP',
fields: [
{ env_name: 'SMTP_HOST', label: 'SMTP Host', value: 'smtp.example.com', secret: false, input_type: 'text', configured: true },
{ env_name: 'SMTP_PASSWORD', label: 'SMTP Password', value: '', secret: true, input_type: 'password', configured: true },
],
},
],
}
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPut = vi.fn()
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
PUT: (...args: unknown[]) => mockPut(...args),
POST: (...args: unknown[]) => mockPost(...args),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderConfig() {
return renderWithProviders(<ConfigPage />, { initialPath: '/config' })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ConfigPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: GET /api/config returns the fixture
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
})
// -------------------------------------------------------------------------
// 1. Renders sections
// -------------------------------------------------------------------------
it('renders section names and field labels', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByText('General')).toBeInTheDocument()
})
expect(screen.getByText('SMTP')).toBeInTheDocument()
expect(screen.getByText('App Name')).toBeInTheDocument()
expect(screen.getByText('SMTP Host')).toBeInTheDocument()
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Secret fields start empty
// -------------------------------------------------------------------------
it('renders secret fields with empty value (never displays masked value)', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
})
// Mantine puts data-testid on the <input> element itself
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
expect(secretInput.value).toBe('')
})
// -------------------------------------------------------------------------
// 3. Non-secret fields show their loaded values
// -------------------------------------------------------------------------
it('renders non-secret fields with their loaded values', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('field-APP_NAME')).toBeInTheDocument()
})
// Mantine puts data-testid on the <input> element itself for TextInput
const appNameInput = screen.getByTestId('field-APP_NAME') as HTMLInputElement
expect(appNameInput.value).toBe('My Home')
const smtpHostInput = screen.getByTestId('field-SMTP_HOST') as HTMLInputElement
expect(smtpHostInput.value).toBe('smtp.example.com')
})
// -------------------------------------------------------------------------
// 4. Save: updates includes all non-secrets, excludes untouched secrets
// -------------------------------------------------------------------------
it('save sends all non-secret fields and excludes untouched (blank) secrets', async () => {
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
// After save, refetch
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-form')).toBeInTheDocument()
})
// Submit without touching any field
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(mockPut).toHaveBeenCalled()
})
const putCall = mockPut.mock.calls[0]
const body = putCall[1].body as { updates: Record<string, string> }
const updates = body.updates
// Non-secret fields MUST be present
expect(updates).toHaveProperty('APP_NAME', 'My Home')
expect(updates).toHaveProperty('APP_PORT', '8000')
expect(updates).toHaveProperty('SMTP_HOST', 'smtp.example.com')
// Untouched secret field MUST NOT be present
expect(updates).not.toHaveProperty('SMTP_PASSWORD')
})
// -------------------------------------------------------------------------
// 5. Save: updates includes secret when user typed a new value
// -------------------------------------------------------------------------
it('save includes a secret field when the user typed a new value', async () => {
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('field-secret-SMTP_PASSWORD')).toBeInTheDocument()
})
// Mantine puts data-testid on the <input> element itself
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
fireEvent.change(secretInput, { target: { value: 'new-secret-value' } })
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(mockPut).toHaveBeenCalled()
})
const putCall = mockPut.mock.calls[0]
const body = putCall[1].body as { updates: Record<string, string> }
const updates = body.updates
// Secret MUST be included because the user typed a value
expect(updates).toHaveProperty('SMTP_PASSWORD', 'new-secret-value')
// Non-secrets still present
expect(updates).toHaveProperty('APP_NAME', 'My Home')
})
// -------------------------------------------------------------------------
// 6. Save success → shows success notice
// -------------------------------------------------------------------------
it('shows success alert after a successful save', async () => {
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-form')).toBeInTheDocument()
})
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(screen.getByTestId('save-success')).toBeInTheDocument()
})
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 7. Save error → shows error notice
// -------------------------------------------------------------------------
it('shows error alert when save fails', async () => {
const { ApiError } = await import('../api/client')
mockPut.mockRejectedValueOnce(new ApiError(422, { detail: 'invalid value' }))
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-form')).toBeInTheDocument()
})
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(screen.getByTestId('save-error')).toBeInTheDocument()
})
expect(screen.queryByTestId('save-success')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 8. SMTP test button: success state
// -------------------------------------------------------------------------
it('shows success alert after SMTP test succeeds', async () => {
mockPost.mockResolvedValueOnce({
data: { result: 'success', message: 'Email delivered.' },
response: { status: 200, ok: true },
})
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('smtp-test-button'))
await waitFor(() => {
expect(screen.getByTestId('smtp-result-success')).toBeInTheDocument()
})
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 9. SMTP test button: config-error state (400)
// -------------------------------------------------------------------------
it('shows config-error alert when SMTP test returns config-error', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(
new ApiError(400, { result: 'config-error', message: 'SMTP host not configured.' }),
)
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('smtp-test-button'))
await waitFor(() => {
expect(screen.getByTestId('smtp-result-config-error')).toBeInTheDocument()
})
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 10. SMTP test button: failed state (502)
// -------------------------------------------------------------------------
it('shows failed alert when SMTP test returns failed', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(
new ApiError(502, { result: 'failed', message: 'Connection refused.' }),
)
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('smtp-test-button'))
await waitFor(() => {
expect(screen.getByTestId('smtp-result-failed')).toBeInTheDocument()
})
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
})
})
+398
View File
@@ -0,0 +1,398 @@
/**
* ConfigPage config editor (M2-T08).
*
* Behaviours:
* 1. Load config: GET /api/config render sections (grouped) with Mantine inputs.
* - Non-secret fields show their value.
* - Secret fields render as empty PasswordInput (never show a masked value).
* 2. Save config: PUT /api/config with full-field submission semantics.
* - All non-secret fields are ALWAYS included (to avoid backend zeroing absent fields).
* - Secret fields are included ONLY when the user typed a new (non-empty) value.
* - On success: show a success notice and refetch config.
* - On ApiError 422: show an error notice, nothing was written.
* 3. SMTP test button: POST /api/config/smtp/test.
* - Tri-state: success / config-error / failed.
* - Errors read `err.body.result` from ApiError.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Container,
Title,
Text,
TextInput,
PasswordInput,
Button,
Alert,
Stack,
Group,
Divider,
Loader,
Center,
Paper,
Badge,
} from '@mantine/core'
import apiClient, { ApiError } from '../api/client'
import type { components } from '../api/schema.d.ts'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ConfigField = components['schemas']['ConfigField']
type ConfigSection = components['schemas']['ConfigSection']
/** SMTP test result tri-state. */
type SmtpResult =
| { kind: 'success'; message: string }
| { kind: 'config-error'; message: string }
| { kind: 'failed'; message: string }
| null
// ---------------------------------------------------------------------------
// Hook: load config
// ---------------------------------------------------------------------------
function useConfig() {
return useQuery({
queryKey: ['config'],
queryFn: async () => {
const res = await apiClient.GET('/api/config')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Helper: build updates map for PUT /api/config
//
// Full-field submission semantics (§6):
// - Non-secret fields: ALWAYS include current value (even if unchanged) so
// the backend does not zero out absent fields.
// - Secret fields: include ONLY when the user typed a non-empty value.
// Blank secret = keep old value; sending blank would also keep it per
// backend semantics, but we omit it to be explicit and avoid confusion.
// ---------------------------------------------------------------------------
function buildUpdates(
sections: ConfigSection[],
localValues: Record<string, string>,
): Record<string, string> {
const updates: Record<string, string> = {}
for (const section of sections) {
for (const field of section.fields) {
const localVal = localValues[field.env_name] ?? ''
if (field.secret) {
// Only include secret if the user typed something (non-empty).
if (localVal !== '') {
updates[field.env_name] = localVal
}
// blank secret → omit → backend keeps the existing stored value
} else {
// Non-secret: always include current local value.
updates[field.env_name] = localVal
}
}
}
return updates
}
// ---------------------------------------------------------------------------
// ConfigFieldInput — renders a single config field
// ---------------------------------------------------------------------------
interface ConfigFieldInputProps {
field: ConfigField
value: string
onChange: (envName: string, value: string) => void
}
function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(field.env_name, e.currentTarget.value)
}
if (field.secret) {
return (
<PasswordInput
label={field.label}
placeholder={field.configured ? '(configured — leave blank to keep)' : 'Enter value'}
value={value}
onChange={handleChange}
data-testid={`field-secret-${field.env_name}`}
autoComplete="off"
/>
)
}
if (field.input_type === 'number') {
return (
<TextInput
label={field.label}
type="number"
value={value}
onChange={handleChange}
data-testid={`field-${field.env_name}`}
/>
)
}
return (
<TextInput
label={field.label}
type={field.input_type === 'email' ? 'email' : 'text'}
value={value}
onChange={handleChange}
data-testid={`field-${field.env_name}`}
/>
)
}
// ---------------------------------------------------------------------------
// ConfigSectionPanel — one section
// ---------------------------------------------------------------------------
interface ConfigSectionPanelProps {
section: ConfigSection
localValues: Record<string, string>
onChange: (envName: string, value: string) => void
}
function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
return (
<Paper withBorder p="md" radius="md">
<Title order={4} mb="md">
{section.name}
</Title>
<Stack gap="sm">
{section.fields.map((field) => (
<ConfigFieldInput
key={field.env_name}
field={field}
value={localValues[field.env_name] ?? ''}
onChange={onChange}
/>
))}
</Stack>
</Paper>
)
}
// ---------------------------------------------------------------------------
// SmtpTestButton — sends POST /api/config/smtp/test and displays tri-state result
// ---------------------------------------------------------------------------
interface SmtpTestButtonProps {
smtpResult: SmtpResult
setSmtpResult: (r: SmtpResult) => void
}
function SmtpTestButton({ smtpResult, setSmtpResult }: SmtpTestButtonProps) {
const [testing, setTesting] = useState(false)
async function handleTest() {
setSmtpResult(null)
setTesting(true)
try {
const res = await apiClient.POST('/api/config/smtp/test')
if (res.data) {
setSmtpResult({ kind: 'success', message: res.data.message })
}
} catch (err) {
if (err instanceof ApiError) {
const body = err.body as { result?: string; message?: string } | null
const result = body?.result
const message = body?.message ?? 'Unknown error'
if (result === 'config-error') {
setSmtpResult({ kind: 'config-error', message })
} else {
// result === 'failed' or any other error
setSmtpResult({ kind: 'failed', message })
}
} else {
setSmtpResult({ kind: 'failed', message: 'Unexpected error sending test email.' })
}
} finally {
setTesting(false)
}
}
return (
<Stack gap="xs">
<Button
variant="outline"
onClick={handleTest}
loading={testing}
data-testid="smtp-test-button"
>
Send Test Email
</Button>
{smtpResult?.kind === 'success' && (
<Alert color="green" data-testid="smtp-result-success">
Test email sent successfully. {smtpResult.message}
</Alert>
)}
{smtpResult?.kind === 'config-error' && (
<Alert color="orange" data-testid="smtp-result-config-error">
SMTP configuration error check your SMTP settings. {smtpResult.message}
</Alert>
)}
{smtpResult?.kind === 'failed' && (
<Alert color="red" data-testid="smtp-result-failed">
Test email send failed. {smtpResult.message}
</Alert>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// ConfigPage — main component
// ---------------------------------------------------------------------------
export function ConfigPage() {
const queryClient = useQueryClient()
const { data, isLoading, isError } = useConfig()
// Local field values — mirrors the loaded config but allows user edits.
// Secret fields always start as empty string (never display masked values).
const [localValues, setLocalValues] = useState<Record<string, string>>({})
const [valuesInitialized, setValuesInitialized] = useState(false)
// Initialise local state once when data arrives (or re-arrives after refetch).
if (data && !valuesInitialized) {
const initial: Record<string, string> = {}
for (const section of data.sections) {
for (const field of section.fields) {
// Secret fields start empty (never display the masked/empty backend value).
initial[field.env_name] = field.secret ? '' : (field.value ?? '')
}
}
setLocalValues(initial)
setValuesInitialized(true)
}
// Save notice state
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null)
// SMTP test tri-state
const [smtpResult, setSmtpResult] = useState<SmtpResult>(null)
function handleChange(envName: string, value: string) {
setLocalValues((prev) => ({ ...prev, [envName]: value }))
setSaveStatus(null)
}
const saveMutation = useMutation({
mutationFn: async () => {
if (!data) return
const updates = buildUpdates(data.sections, localValues)
await apiClient.PUT('/api/config', { body: { updates } })
},
})
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaveStatus(null)
try {
await saveMutation.mutateAsync()
setSaveStatus('success')
// Refetch config so the page reflects the saved state.
await queryClient.invalidateQueries({ queryKey: ['config'] })
// After refetch, reset initialised flag so local state rebuilds from fresh data.
setValuesInitialized(false)
} catch {
setSaveStatus('error')
}
}
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if (isLoading) {
return (
<Center pt="xl">
<Loader data-testid="config-loading" />
</Center>
)
}
if (isError || !data) {
return (
<Container pt="xl">
<Alert color="red" data-testid="config-load-error">
Failed to load configuration. Please refresh the page.
</Alert>
</Container>
)
}
// Detect if there is an SMTP section (to show the test button).
const hasSmtpSection = data.sections.some((s) =>
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
)
return (
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
<Group justify="space-between" mb="lg" wrap="nowrap">
<Title order={2}>Configuration</Title>
<Badge variant="outline" color="gray" size="sm">
{data.sections.length} section{data.sections.length !== 1 ? 's' : ''}
</Badge>
</Group>
<form onSubmit={handleSave} data-testid="config-form">
<Stack gap="lg">
{data.sections.map((section) => (
<ConfigSectionPanel
key={section.name}
section={section}
localValues={localValues}
onChange={handleChange}
/>
))}
<Divider />
{saveStatus === 'success' && (
<Alert color="green" data-testid="save-success">
Configuration saved successfully.
</Alert>
)}
{saveStatus === 'error' && (
<Alert color="red" data-testid="save-error">
Failed to save configuration. Please check the values and try again.
</Alert>
)}
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Button
type="submit"
loading={saveMutation.isPending}
data-testid="config-save-button"
>
Save Configuration
</Button>
{hasSmtpSection && (
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
)}
</Group>
</Stack>
</form>
{!hasSmtpSection && (
<Stack mt="md">
<Text c="dimmed" size="sm">
Configure SMTP settings to enable email notifications.
</Text>
</Stack>
)}
</Container>
)
}
+19
View File
@@ -0,0 +1,19 @@
/**
* HomePage placeholder for M2-T09.
*
* T09 replaces this with the real home view: Leaflet map, heatmap layer,
* time-range selector, scatter-point layer, and poo overlay.
*/
import { Container, Title, Text } from '@mantine/core'
export function HomePage() {
return (
<Container pt="xl">
<Title order={2}>Home</Title>
<Text c="dimmed" mt="sm">
Map / heatmap visualisation implemented in M2-T09.
</Text>
</Container>
)
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Tests for LoginPage (M2-T07).
*
* Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
* responses without a real server. We also mock useSession so tests can control
* the authentication state.
*
* Coverage:
* 1. Renders the login form.
* 2. Successful login invalidates session query + navigates.
* 3. 401 bad credentials shows inline error, does not navigate.
* 4. Already-authenticated users visiting /login redirected to '/'.
* 5. Unexpected error shows generic error message.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { LoginPage } from './LoginPage'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
// We mock the entire api/client module. Each test can override POST as needed.
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
POST: (...args: unknown[]) => mockPost(...args),
GET: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Mock useSession — default: unauthenticated
// ---------------------------------------------------------------------------
// Typed as returning the wider union so mockReturnValue accepts all status variants.
const mockUseSession = vi.fn(() => ({
status: 'unauthenticated' as 'loading' | 'authenticated' | 'unauthenticated',
user: null as null | { username: string; force_password_change: boolean },
}))
vi.mock('../auth/SessionProvider', () => ({
useSession: () => mockUseSession(),
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderLogin(initialPath = '/login') {
return renderWithProviders(<LoginPage />, {
initialPath,
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
})
}
function fillAndSubmit(username: string, password: string) {
fireEvent.change(screen.getByTestId('username-input'), { target: { value: username } })
fireEvent.change(screen.getByTestId('password-input'), { target: { value: password } })
fireEvent.submit(screen.getByTestId('login-form'))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to unauthenticated by default
mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
})
it('renders the login form with username and password fields', () => {
renderLogin()
expect(screen.getByTestId('login-form')).toBeInTheDocument()
expect(screen.getByTestId('username-input')).toBeInTheDocument()
expect(screen.getByTestId('password-input')).toBeInTheDocument()
expect(screen.getByTestId('login-submit')).toBeInTheDocument()
})
it('shows Sign In heading', () => {
renderLogin()
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
})
it('navigates to "/" on successful login', async () => {
// Simulate a successful POST /api/auth/login response
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
it('calls POST /api/auth/login with the correct body', async () => {
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('myuser', 'mypassword')
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
body: { username: 'myuser', password: 'mypassword' },
})
})
})
it('shows inline error on 401 and does NOT navigate', async () => {
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
mockPost.mockResolvedValueOnce({
data: undefined,
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'wrong-password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(
/incorrect username or password/i,
)
// Should still be on the login form, not navigated away
expect(screen.getByTestId('login-form')).toBeInTheDocument()
})
it('does not include the password in the error message', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'super-secret-password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
})
it('shows generic error on unexpected network failure', async () => {
mockPost.mockRejectedValueOnce(new Error('Network error'))
renderLogin()
fillAndSubmit('admin', 'password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
})
it('redirects already-authenticated users to "/"', async () => {
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: false },
})
renderLogin()
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
})
+147
View File
@@ -0,0 +1,147 @@
/**
* LoginPage real login form (M2-T07).
*
* Behaviours:
* - Renders a Mantine form with username + password fields.
* - On submit POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
* - On success invalidate ['session'] so SessionProvider re-fetches, then navigate to the
* originally-requested route (from location.state.from) or fall back to '/'.
* - On 401 (bad credentials) show an inline error without leaking the password.
* - Already-authenticated users visiting /login redirect to '/'.
*/
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import {
Container,
Paper,
Title,
TextInput,
PasswordInput,
Button,
Alert,
Stack,
Center,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient from '../api/client'
import { setCsrfToken } from '../api/csrf'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LocationState {
from?: { pathname: string }
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LoginPage() {
const { status } = useSession()
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Already authenticated → redirect to intended destination or home.
if (status === 'authenticated') {
const from = (location.state as LocationState)?.from?.pathname ?? '/'
return <Navigate to={from} replace />
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await apiClient.POST('/api/auth/login', {
body: { username, password },
})
if (res.response.status === 401 || !res.data) {
// Bad credentials — do not leak the password in the message.
setError('Incorrect username or password.')
return
}
// Success: store the CSRF token returned by login (same shape as session response).
if (res.data.csrf_token) {
setCsrfToken(res.data.csrf_token)
}
// Refresh session state: invalidate the ['session'] query so SessionProvider
// picks up the new authenticated state (which may include force_password_change).
await queryClient.invalidateQueries({ queryKey: ['session'] })
// Navigate to the originally-requested route or home.
const from = (location.state as LocationState)?.from?.pathname ?? '/'
navigate(from, { replace: true })
} catch {
// Any unexpected error (network, 5xx, etc.)
setError('Login failed. Please try again.')
} finally {
setLoading(false)
}
}
return (
<Center mih="100vh">
<Container size="xs" w="100%">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Title order={2} mb="lg" ta="center">
Sign In
</Title>
{error && (
<Alert color="red" mb="md" role="alert" data-testid="login-error">
{error}
</Alert>
)}
<form onSubmit={handleSubmit} data-testid="login-form">
<Stack gap="md">
<TextInput
label="Username"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
autoComplete="username"
data-testid="username-input"
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="password-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="login-submit"
>
Sign In
</Button>
</Stack>
</form>
</Paper>
</Container>
</Center>
)
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Vitest global setup file.
* Imports @testing-library/jest-dom to extend vitest matchers with DOM assertions.
*
* Also polyfills browser APIs that jsdom does not implement but Mantine needs:
* - window.matchMedia (Mantine uses it for color-scheme detection)
* - ResizeObserver (Mantine uses it for responsive components)
*/
import '@testing-library/jest-dom'
// ---------------------------------------------------------------------------
// window.matchMedia polyfill (jsdom does not implement this)
// ---------------------------------------------------------------------------
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
// ---------------------------------------------------------------------------
// ResizeObserver polyfill (jsdom does not implement this)
// ---------------------------------------------------------------------------
if (typeof ResizeObserver === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(globalThis as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Shared test utilities wraps components in the providers they need.
*
* Usage:
* import { renderWithProviders } from '../test-utils'
* renderWithProviders(<LoginPage />, { initialPath: '/login' })
*/
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
// ---------------------------------------------------------------------------
// Provider wrapper
// ---------------------------------------------------------------------------
interface RenderOptions {
/** Initial URL path (default: '/'). */
initialPath?: string
/**
* Extra routes to register alongside the component under test.
* Useful for asserting navigation (e.g. render a /home sentinel and check
* that the component navigates there after login).
*/
routes?: Array<{ path: string; element: ReactNode }>
/**
* React-router initial entries (overrides initialPath when provided).
* Use when you need to seed location.state (e.g. from-path for redirect-after-login).
*/
initialEntries?: Array<string | { pathname: string; state?: unknown }>
}
/**
* Render `ui` inside MantineProvider + a fresh QueryClientProvider + MemoryRouter.
* SessionProvider is NOT included tests that need session state should mock
* `GET /api/session` via vi.fn() on the apiClient or use MSW.
*/
export function renderWithProviders(ui: ReactNode, options: RenderOptions = {}) {
const { initialPath = '/', routes = [], initialEntries } = options
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const entries = initialEntries ?? [initialPath]
function Wrapper() {
return (
<MantineProvider>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={entries}>
<Routes>
<Route path={initialPath} element={ui} />
{routes.map(({ path, element }) => (
<Route key={path} path={path} element={element} />
))}
</Routes>
</MemoryRouter>
</QueryClientProvider>
</MantineProvider>
)
}
return render(<Wrapper />)
}
/**
* Create a minimal SessionProvider-less wrapper that just supplies the
* query and router context. Returns the queryClient so tests can prime it.
*/
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"allowArbitraryExtensions": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
'/login': 'http://localhost:8000',
'/logout': 'http://localhost:8000',
'/static': 'http://localhost:8000',
'/docs': 'http://localhost:8000',
'/openapi.json': 'http://localhost:8000',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
})
+1328
View File
File diff suppressed because it is too large Load Diff
+931
View File
@@ -168,6 +168,563 @@ paths:
text/html:
schema:
type: string
/api/config:
get:
tags:
- api-config
summary: Get Config
description: Return all configuration sections. Secret field values are masked
(empty string).
operationId: get_config_api_config_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigResponse'
put:
tags:
- api-config
summary: Put Config
description: 'Save configuration updates.
- Blank secret value keeps the existing stored value (no change).
- Invalid values return 422 and nothing is written to the database.'
operationId: put_config_api_config_put
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigUpdateRequest'
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigUpdateResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/config/smtp/test:
post:
tags:
- api-config
summary: Post Smtp Test
description: 'Send a test SMTP email using the current runtime settings.
Returns a structured result indicating success or the category of failure.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
SMTP credentials are never echoed in the response.'
operationId: post_smtp_test_api_config_smtp_test_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SmtpTestResponse'
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/SmtpTestResponse'
description: Bad Request
'502':
content:
application/json:
schema:
$ref: '#/components/schemas/SmtpTestResponse'
description: Bad Gateway
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/locations:
get:
tags:
- api-data
summary: Get Locations
description: 'Return location records with optional time-window filtering and
pagination.
- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both
bounds.
- Results are ordered by ``datetime`` ascending.
- ``limit`` is capped at 5000 to prevent full-table exports.'
operationId: get_locations_api_locations_get
parameters:
- name: limit
in: query
required: false
schema:
type: integer
maximum: 5000
minimum: 1
default: 1000
title: Limit
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
title: Offset
- name: start
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: Start
- name: end
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: End
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/LocationsResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/poo:
get:
tags:
- api-data
summary: Get Poo
description: 'Return poo records ordered by timestamp descending (most recent
first).
``limit`` is capped at 1000 to prevent full-table exports.'
operationId: get_poo_api_poo_get
parameters:
- name: limit
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
default: 100
title: Limit
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
title: Offset
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/PooResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/public-ip:
get:
tags:
- api-data
summary: Get Public Ip
description: 'Return the current public IP state and recent history.
- ``state`` is ``null`` if no IP check has been performed yet.
- ``history`` is ordered by ``observed_at`` descending (most recent first).
- ``limit`` applies to the history list and is capped at 1000.'
operationId: get_public_ip_api_public_ip_get
parameters:
- name: limit
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
default: 100
title: Limit
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/PublicIPResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/locations/{person}/{datetime}:
patch:
tags:
- api-data
summary: Patch Location
description: 'Update the non-PK fields of a single location record.
- ``person`` and ``datetime`` identify the row (composite PK) and are immutable.
- Only ``latitude``, ``longitude``, and ``altitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.'
operationId: patch_location_api_locations__person___datetime__patch
parameters:
- name: person
in: path
required: true
schema:
type: string
title: Person
- name: datetime
in: path
required: true
schema:
type: string
title: Datetime
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LocationUpdateRequest'
default: {}
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/LocationRecord'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
delete:
tags:
- api-data
summary: Delete Location Record
description: 'Delete the single location record identified by its composite
PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.'
operationId: delete_location_record_api_locations__person___datetime__delete
parameters:
- name: person
in: path
required: true
schema:
type: string
title: Person
- name: datetime
in: path
required: true
schema:
type: string
title: Datetime
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'204':
description: Successful Response
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/poo/{timestamp}:
patch:
tags:
- api-data
summary: Patch Poo
description: 'Update the non-PK fields of a single poo record.
- ``timestamp`` is the PK and is immutable.
- Only ``status``, ``latitude``, and ``longitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.'
operationId: patch_poo_api_poo__timestamp__patch
parameters:
- name: timestamp
in: path
required: true
schema:
type: string
title: Timestamp
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PooUpdateRequest'
default: {}
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/PooRecord'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
delete:
tags:
- api-data
summary: Delete Poo
description: 'Delete the single poo record identified by its PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.'
operationId: delete_poo_api_poo__timestamp__delete
parameters:
- name: timestamp
in: path
required: true
schema:
type: string
title: Timestamp
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'204':
description: Successful Response
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/session:
get:
tags:
- api-session
summary: Get Session
description: Return the current session user and CSRF token. Returns 401 if
not authenticated.
operationId: get_session_api_session_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
/api/auth/login:
post:
tags:
- api-session
summary: Post Login
description: 'Authenticate with username and password.
On success, sets an HttpOnly session cookie and returns the session user +
CSRF token.
On failure, returns 401 with no cookie set.
No X-CSRF-Token required (unauthenticated endpoint).'
operationId: post_login_api_auth_login_post
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/logout:
post:
tags:
- api-session
summary: Post Logout
description: 'Revoke the current session and clear the session cookie.
Requires authentication and X-CSRF-Token header.
Returns 204 No Content.'
operationId: post_logout_api_auth_logout_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/password:
post:
tags:
- api-session
summary: Post Change Password
description: 'Change the current user''s password.
Requires authentication and X-CSRF-Token header.
On AuthPasswordChangeError returns 400 with a generic message.
On success, force_password_change becomes False (handled by the service).
Returns 204 No Content.'
operationId: post_change_password_api_auth_password_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordChangeRequest'
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/homeassistant/publish:
post:
tags:
@@ -302,6 +859,84 @@ components:
required:
- csrf_token
title: Body_logout_logout_post
ConfigField:
properties:
env_name:
type: string
title: Env Name
label:
type: string
title: Label
value:
type: string
title: Value
secret:
type: boolean
title: Secret
input_type:
type: string
title: Input Type
configured:
type: boolean
title: Configured
type: object
required:
- env_name
- label
- value
- secret
- input_type
- configured
title: ConfigField
ConfigResponse:
properties:
sections:
items:
$ref: '#/components/schemas/ConfigSection'
type: array
title: Sections
type: object
required:
- sections
title: ConfigResponse
ConfigSection:
properties:
name:
type: string
title: Name
fields:
items:
$ref: '#/components/schemas/ConfigField'
type: array
title: Fields
type: object
required:
- name
- fields
title: ConfigSection
ConfigUpdateRequest:
properties:
updates:
additionalProperties:
type: string
type: object
title: Updates
type: object
required:
- updates
title: ConfigUpdateRequest
description: Flat mapping of env_name → value, mirroring the existing form semantics.
ConfigUpdateResponse:
properties:
sections:
items:
$ref: '#/components/schemas/ConfigSection'
type: array
title: Sections
type: object
required:
- sections
title: ConfigUpdateResponse
HTTPValidationError:
properties:
detail:
@@ -311,6 +946,163 @@ components:
title: Detail
type: object
title: HTTPValidationError
LocationRecord:
properties:
person:
type: string
title: Person
datetime:
type: string
title: Datetime
latitude:
type: number
title: Latitude
longitude:
type: number
title: Longitude
altitude:
anyOf:
- type: number
- type: 'null'
title: Altitude
type: object
required:
- person
- datetime
- latitude
- longitude
- altitude
title: LocationRecord
LocationUpdateRequest:
properties:
latitude:
anyOf:
- type: number
- type: 'null'
title: Latitude
longitude:
anyOf:
- type: number
- type: 'null'
title: Longitude
altitude:
anyOf:
- type: number
- type: 'null'
title: Altitude
type: object
title: LocationUpdateRequest
description: PATCH body for a location record — all fields optional; PK fields
excluded.
LocationsResponse:
properties:
items:
items:
$ref: '#/components/schemas/LocationRecord'
type: array
title: Items
limit:
type: integer
title: Limit
offset:
type: integer
title: Offset
type: object
required:
- items
- limit
- offset
title: LocationsResponse
LoginRequest:
properties:
username:
type: string
title: Username
password:
type: string
title: Password
type: object
required:
- username
- password
title: LoginRequest
PasswordChangeRequest:
properties:
current_password:
type: string
title: Current Password
new_password:
type: string
title: New Password
confirm_password:
type: string
title: Confirm Password
type: object
required:
- current_password
- new_password
- confirm_password
title: PasswordChangeRequest
PooRecord:
properties:
timestamp:
type: string
title: Timestamp
status:
type: string
title: Status
latitude:
type: number
title: Latitude
longitude:
type: number
title: Longitude
type: object
required:
- timestamp
- status
- latitude
- longitude
title: PooRecord
PooResponse:
properties:
items:
items:
$ref: '#/components/schemas/PooRecord'
type: array
title: Items
limit:
type: integer
title: Limit
offset:
type: integer
title: Offset
type: object
required:
- items
- limit
- offset
title: PooResponse
PooUpdateRequest:
properties:
status:
anyOf:
- type: string
- type: 'null'
title: Status
latitude:
anyOf:
- type: number
- type: 'null'
title: Latitude
longitude:
anyOf:
- type: number
- type: 'null'
title: Longitude
type: object
title: PooUpdateRequest
description: PATCH body for a poo record — all fields optional; PK field excluded.
PublicIPCheckResponse:
properties:
status:
@@ -334,6 +1126,145 @@ components:
- checked_at
- changed
title: PublicIPCheckResponse
PublicIPHistorySchema:
properties:
id:
type: integer
title: Id
ipv4:
type: string
title: Ipv4
observed_at:
type: string
format: date-time
title: Observed At
change_type:
type: string
title: Change Type
provider:
anyOf:
- type: string
- type: 'null'
title: Provider
type: object
required:
- id
- ipv4
- observed_at
- change_type
- provider
title: PublicIPHistorySchema
PublicIPResponse:
properties:
state:
anyOf:
- $ref: '#/components/schemas/PublicIPStateSchema'
- type: 'null'
history:
items:
$ref: '#/components/schemas/PublicIPHistorySchema'
type: array
title: History
type: object
required:
- state
- history
title: PublicIPResponse
PublicIPStateSchema:
properties:
id:
type: integer
title: Id
current_ipv4:
type: string
title: Current Ipv4
previous_ipv4:
anyOf:
- type: string
- type: 'null'
title: Previous Ipv4
first_seen_at:
type: string
format: date-time
title: First Seen At
last_checked_at:
type: string
format: date-time
title: Last Checked At
last_changed_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Last Changed At
last_check_status:
type: string
title: Last Check Status
last_check_error:
anyOf:
- type: string
- type: 'null'
title: Last Check Error
last_provider:
anyOf:
- type: string
- type: 'null'
title: Last Provider
type: object
required:
- id
- current_ipv4
- previous_ipv4
- first_seen_at
- last_checked_at
- last_changed_at
- last_check_status
- last_check_error
- last_provider
title: PublicIPStateSchema
SessionResponse:
properties:
user:
$ref: '#/components/schemas/SessionUser'
csrf_token:
type: string
title: Csrf Token
type: object
required:
- user
- csrf_token
title: SessionResponse
SessionUser:
properties:
username:
type: string
title: Username
force_password_change:
type: boolean
title: Force Password Change
type: object
required:
- username
- force_password_change
title: SessionUser
SmtpTestResponse:
properties:
result:
type: string
enum:
- success
- config-error
- failed
title: Result
message:
type: string
title: Message
type: object
required:
- result
- message
title: SmtpTestResponse
description: Response from POST /api/config/smtp/test.
StatusResponse:
properties:
status:
+5
View File
@@ -26,3 +26,8 @@ pythonpath = ["."]
[tool.ruff]
line-length = 100
[tool.ruff.lint.per-file-ignores]
# Scripts bootstrap sys.path before importing app modules, so their top-level
# app imports legitimately sit below executable setup code.
"scripts/*.py" = ["E402"]
+426
View File
@@ -0,0 +1,426 @@
"""Tests for M2-T01: GET /api/config and PUT /api/config.
Tests for M2-T05: POST /api/config/smtp/test."""
from __future__ import annotations
import re
import sqlite3
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.config import get_settings
from app.services.config_page import CONFIG_FIELDS
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)
resp = client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
assert resp.status_code == 303, f"Login failed: {resp.status_code}"
def _stringify(value) -> str:
if value is None:
return ""
if isinstance(value, bool):
return str(value).lower()
return str(value)
def _full_config_payload(overrides: dict[str, str] | None = None) -> dict[str, str]:
"""Build a complete env_name→value dict mirroring the Jinja form's full submission.
Secrets default to "" (keep-old semantics). Non-secrets use current settings defaults.
"""
settings = get_settings()
payload: dict[str, str] = {}
for field in CONFIG_FIELDS:
if field.secret:
payload[field.env_name] = "" # blank → keep existing
else:
payload[field.env_name] = _stringify(getattr(settings, field.setting_attr))
if overrides:
payload.update(overrides)
return payload
# ---------------------------------------------------------------------------
# GET /api/config — unauthenticated
# ---------------------------------------------------------------------------
def test_get_config_unauthenticated_returns_401(client: TestClient) -> None:
response = client.get("/api/config")
assert response.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/config — authenticated
# ---------------------------------------------------------------------------
def test_get_config_authenticated_returns_sections(client: TestClient) -> None:
_login(client)
response = client.get("/api/config")
assert response.status_code == 200
body = response.json()
assert "sections" in body
assert isinstance(body["sections"], list)
assert len(body["sections"]) > 0
def test_get_config_sections_have_expected_structure(client: TestClient) -> None:
_login(client)
response = client.get("/api/config")
body = response.json()
for section in body["sections"]:
assert "name" in section
assert "fields" in section
assert isinstance(section["fields"], list)
for field in section["fields"]:
assert "env_name" in field
assert "label" in field
assert "value" in field
assert "secret" in field
assert "input_type" in field
assert "configured" in field
def test_get_config_secret_fields_have_empty_string_value(client: TestClient) -> None:
_login(client)
response = client.get("/api/config")
body = response.json()
for section in body["sections"]:
for field in section["fields"]:
if field["secret"]:
assert field["value"] == "", (
f"Secret field {field['env_name']} should be masked (empty string), "
f"got {field['value']!r}"
)
def test_get_config_includes_known_sections(client: TestClient) -> None:
_login(client)
response = client.get("/api/config")
body = response.json()
section_names = {s["name"] for s in body["sections"]}
assert "System" in section_names
assert "SMTP" in section_names
assert "Authentication" in section_names
# ---------------------------------------------------------------------------
# PUT /api/config — unauthenticated
# ---------------------------------------------------------------------------
def test_put_config_unauthenticated_returns_401(client: TestClient) -> None:
response = client.put(
"/api/config",
json={"updates": _full_config_payload()},
headers={"X-CSRF-Token": "any-token"},
)
assert response.status_code == 401
# ---------------------------------------------------------------------------
# PUT /api/config — authenticated but missing CSRF
# ---------------------------------------------------------------------------
def test_put_config_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
_login(client)
# No X-CSRF-Token header at all
response = client.put(
"/api/config",
json={"updates": _full_config_payload()},
)
assert response.status_code == 403
def test_put_config_authenticated_empty_csrf_returns_403(client: TestClient) -> None:
_login(client)
# Empty string X-CSRF-Token header counts as missing
response = client.put(
"/api/config",
json={"updates": _full_config_payload()},
headers={"X-CSRF-Token": ""},
)
assert response.status_code == 403
# ---------------------------------------------------------------------------
# PUT /api/config — authenticated + CSRF present (any non-empty value)
# ---------------------------------------------------------------------------
def test_put_config_with_csrf_header_updates_app_name(
client: TestClient, test_database_urls
) -> None:
_login(client)
payload = _full_config_payload({"APP_NAME": "Updated via API"})
response = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "any-non-empty-value"},
)
assert response.status_code == 200
body = response.json()
assert "sections" in body
# The refreshed config in the response should reflect the new name
system_section = next(s for s in body["sections"] if s["name"] == "System")
app_name_field = next(f for f in system_section["fields"] if f["env_name"] == "APP_NAME")
assert app_name_field["value"] == "Updated via API"
def test_put_config_blank_secret_keeps_existing_value(
client: TestClient, test_database_urls
) -> None:
"""Submitting a blank value for a secret field must NOT overwrite the stored secret."""
_login(client)
# First: store a secret via a full PUT with the secret value set
payload_with_secret = _full_config_payload({"SMTP_PASSWORD": "original-secret"})
resp1 = client.put(
"/api/config",
json={"updates": payload_with_secret},
headers={"X-CSRF-Token": "token"},
)
assert resp1.status_code == 200, f"First PUT failed: {resp1.status_code} {resp1.text}"
# Second: PUT with blank for that secret (keep-old semantics)
payload_blank_secret = _full_config_payload({"SMTP_PASSWORD": ""})
resp2 = client.put(
"/api/config",
json={"updates": payload_blank_secret},
headers={"X-CSRF-Token": "token"},
)
assert resp2.status_code == 200, f"Second PUT failed: {resp2.status_code} {resp2.text}"
# The stored value in the DB should still be the original secret
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.get("SMTP_PASSWORD") == "original-secret"
def test_put_config_returns_refreshed_sections(client: TestClient) -> None:
_login(client)
payload = _full_config_payload({"APP_NAME": "Refreshed Name"})
response = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 200
body = response.json()
assert "sections" in body
assert isinstance(body["sections"], list)
assert len(body["sections"]) > 0
# Sections should reflect updated value
system_section = next(s for s in body["sections"] if s["name"] == "System")
app_name_field = next(f for f in system_section["fields"] if f["env_name"] == "APP_NAME")
assert app_name_field["value"] == "Refreshed Name"
def test_put_config_invalid_value_returns_422_and_does_not_write(
client: TestClient, test_database_urls
) -> None:
"""An invalid config value (e.g. bad type for a typed field) must return 4xx and not persist."""
_login(client)
# SMTP_PORT expects an integer; submit something that fails Settings validation
payload = _full_config_payload({"SMTP_PORT": "not-a-number"})
response = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 422
# Confirm the bad value was not persisted
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.get("SMTP_PORT") != "not-a-number"
# ---------------------------------------------------------------------------
# Response schema correctness — secret values never leak in response
# ---------------------------------------------------------------------------
def test_put_config_response_does_not_leak_secret_values(client: TestClient) -> None:
_login(client)
# Set a secret
payload1 = _full_config_payload({"HOME_ASSISTANT_AUTH_TOKEN": "super-secret-token"})
resp1 = client.put(
"/api/config",
json={"updates": payload1},
headers={"X-CSRF-Token": "token"},
)
assert resp1.status_code == 200
# Do another PUT and check response doesn't leak the secret
payload2 = _full_config_payload({"APP_NAME": "check-secrets"})
response = client.put(
"/api/config",
json={"updates": payload2},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 200
body = response.json()
for section in body["sections"]:
for field in section["fields"]:
if field["secret"]:
assert field["value"] == "", (
f"Secret field {field['env_name']} leaked in PUT response"
)
# The secret value itself should not appear anywhere in the raw response
assert "super-secret-token" not in str(body)
# ---------------------------------------------------------------------------
# POST /api/config/smtp/test — M2-T05
# ---------------------------------------------------------------------------
_SMTP_TEST_URL = "/api/config/smtp/test"
_SMTP_SEND_PATH = "app.api.routes.api.config.send_smtp_test_email"
def test_smtp_test_unauthenticated_returns_401(client: TestClient) -> None:
"""Unauthenticated request must return 401 (require_session fires before require_csrf)."""
response = client.post(
_SMTP_TEST_URL,
headers={"X-CSRF-Token": "any-token"},
)
assert response.status_code == 401
def test_smtp_test_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
"""Authenticated but no X-CSRF-Token header must return 403."""
_login(client)
response = client.post(_SMTP_TEST_URL)
assert response.status_code == 403
def test_smtp_test_authenticated_empty_csrf_returns_403(client: TestClient) -> None:
"""Authenticated but empty X-CSRF-Token header must return 403."""
_login(client)
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": ""})
assert response.status_code == 403
def test_smtp_test_success_returns_200(client: TestClient) -> None:
"""When send_smtp_test_email succeeds (returns None), endpoint returns 200 with result=success."""
_login(client)
with patch(_SMTP_SEND_PATH, return_value=None) as mock_send:
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"})
mock_send.assert_called_once()
assert response.status_code == 200
body = response.json()
assert body["result"] == "success"
assert "message" in body
def test_smtp_test_config_error_returns_400(client: TestClient) -> None:
"""When send_smtp_test_email raises EmailConfigurationError, endpoint returns 400 with result=config-error."""
_login(client)
with patch(_SMTP_SEND_PATH, side_effect=EmailConfigurationError("SMTP host is required")):
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"})
assert response.status_code == 400
body = response.json()
assert body["result"] == "config-error"
assert "message" in body
assert "SMTP host is required" in body["message"]
def test_smtp_test_delivery_error_returns_502(client: TestClient) -> None:
"""When send_smtp_test_email raises EmailDeliveryError, endpoint returns 502 with result=failed."""
_login(client)
with patch(_SMTP_SEND_PATH, side_effect=EmailDeliveryError("connection refused")):
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"})
assert response.status_code == 502
body = response.json()
assert body["result"] == "failed"
assert "message" in body
assert "connection refused" in body["message"]
def test_smtp_test_response_does_not_echo_smtp_password(client: TestClient) -> None:
"""The SMTP password stored in config must not appear in any API response body."""
_login(client)
smtp_password = "s3cr3t-smtp-pass"
# Store a fake SMTP password in config
payload = _full_config_payload({"SMTP_PASSWORD": smtp_password})
client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
# Simulate a delivery error whose message has already been sanitised by the
# service layer (i.e. the password does not appear in the exception text).
# This mirrors production behaviour: email.py's _sanitize_error_message
# replaces any password occurrence with "[redacted]" before raising.
with patch(
_SMTP_SEND_PATH,
side_effect=EmailDeliveryError("authentication failure: [redacted]"),
):
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "token"})
assert response.status_code == 502
body = response.json()
assert "result" in body
assert "message" in body
# The plaintext password must not appear anywhere in the response body
assert smtp_password not in response.text
+611
View File
@@ -0,0 +1,611 @@
"""Tests for M2-T03: GET /api/locations, GET /api/poo, GET /api/public-ip."""
from __future__ import annotations
import re
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from sqlalchemy import insert
from sqlalchemy.engine import Engine
from app.models.location import Location
from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState
# ---------------------------------------------------------------------------
# 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 _api_login(client: TestClient) -> None:
"""Log in via POST /api/auth/login so the TestClient has a session cookie."""
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code}"
def _seed_locations(engine: Engine, rows: list[dict]) -> None:
with engine.begin() as conn:
conn.execute(insert(Location), rows)
def _seed_poo(engine: Engine, rows: list[dict]) -> None:
with engine.begin() as conn:
conn.execute(insert(PooRecord), rows)
def _seed_public_ip(engine: Engine) -> None:
now = datetime.now(UTC)
with engine.begin() as conn:
conn.execute(
insert(PublicIPState),
[
{
"id": 1,
"current_ipv4": "1.2.3.4",
"previous_ipv4": "1.2.3.3",
"first_seen_at": now,
"last_checked_at": now,
"last_changed_at": now,
"last_check_status": "changed",
"last_check_error": None,
"last_provider": "ipify",
}
],
)
conn.execute(
insert(PublicIPHistory),
[
{
"ipv4": "1.2.3.3",
"observed_at": datetime(2026, 1, 1, tzinfo=UTC),
"change_type": "first_seen",
"provider": "ipify",
},
{
"ipv4": "1.2.3.4",
"observed_at": now,
"change_type": "changed",
"provider": "ipify",
},
],
)
# ---------------------------------------------------------------------------
# Unauthenticated → 401
# ---------------------------------------------------------------------------
def test_locations_unauthenticated_returns_401(client: TestClient) -> None:
response = client.get("/api/locations")
assert response.status_code == 401
def test_poo_unauthenticated_returns_401(client: TestClient) -> None:
response = client.get("/api/poo")
assert response.status_code == 401
def test_public_ip_unauthenticated_returns_401(client: TestClient) -> None:
response = client.get("/api/public-ip")
assert response.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/locations — basic
# ---------------------------------------------------------------------------
def test_locations_empty_returns_empty_list(location_client) -> None:
client, _engine = location_client
_api_login(client)
resp = client.get("/api/locations")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["limit"] == 1000
assert body["offset"] == 0
def test_locations_returns_seeded_rows(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": "2026-06-01T10:00:00Z",
"latitude": 51.5,
"longitude": -0.1,
"altitude": None,
},
{
"person": "bob",
"datetime": "2026-06-02T12:00:00Z",
"latitude": 48.8,
"longitude": 2.3,
"altitude": 35.0,
},
],
)
_api_login(client)
resp = client.get("/api/locations")
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 2
# ordered by datetime ascending
assert items[0]["datetime"] == "2026-06-01T10:00:00Z"
assert items[1]["datetime"] == "2026-06-02T12:00:00Z"
# altitude nullable
assert items[0]["altitude"] is None
assert items[1]["altitude"] == 35.0
def test_locations_returns_all_fields(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": "2026-06-01T10:00:00Z",
"latitude": 51.5,
"longitude": -0.1,
"altitude": 10.0,
}
],
)
_api_login(client)
resp = client.get("/api/locations")
item = resp.json()["items"][0]
assert set(item.keys()) == {"person", "datetime", "latitude", "longitude", "altitude"}
assert item["person"] == "alice"
assert item["latitude"] == 51.5
assert item["longitude"] == -0.1
assert item["altitude"] == 10.0
# ---------------------------------------------------------------------------
# GET /api/locations — pagination
# ---------------------------------------------------------------------------
def test_locations_limit_and_offset(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": f"2026-06-{i:02d}T10:00:00Z",
"latitude": 51.0 + i,
"longitude": -0.1,
"altitude": None,
}
for i in range(1, 6)
],
)
_api_login(client)
resp = client.get("/api/locations", params={"limit": 2, "offset": 1})
assert resp.status_code == 200
body = resp.json()
assert body["limit"] == 2
assert body["offset"] == 1
items = body["items"]
assert len(items) == 2
# offset=1 skips the first row (2026-06-01), so we start at 2026-06-02
assert items[0]["datetime"] == "2026-06-02T10:00:00Z"
def test_locations_limit_at_cap_returns_200(location_client) -> None:
client, _engine = location_client
_api_login(client)
resp = client.get("/api/locations", params={"limit": 5000})
assert resp.status_code == 200
assert resp.json()["limit"] == 5000
def test_locations_limit_exceeds_cap_returns_422(location_client) -> None:
client, _engine = location_client
_api_login(client)
resp = client.get("/api/locations", params={"limit": 5001})
assert resp.status_code == 422
def test_locations_limit_zero_returns_422(location_client) -> None:
client, _engine = location_client
_api_login(client)
resp = client.get("/api/locations", params={"limit": 0})
assert resp.status_code == 422
def test_locations_negative_offset_returns_422(location_client) -> None:
client, _engine = location_client
_api_login(client)
resp = client.get("/api/locations", params={"offset": -1})
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# GET /api/locations — time-window filtering (inclusive bounds)
# ---------------------------------------------------------------------------
def test_locations_start_filter_inclusive(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": "2026-06-01T10:00:00Z",
"latitude": 51.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-02T10:00:00Z",
"latitude": 52.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-03T10:00:00Z",
"latitude": 53.0,
"longitude": -0.1,
"altitude": None,
},
],
)
_api_login(client)
# start is inclusive: 2026-06-02 should be included
resp = client.get("/api/locations", params={"start": "2026-06-02T10:00:00Z"})
assert resp.status_code == 200
items = resp.json()["items"]
datetimes = [it["datetime"] for it in items]
assert "2026-06-02T10:00:00Z" in datetimes # inclusive
assert "2026-06-03T10:00:00Z" in datetimes
assert "2026-06-01T10:00:00Z" not in datetimes
def test_locations_end_filter_inclusive(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": "2026-06-01T10:00:00Z",
"latitude": 51.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-02T10:00:00Z",
"latitude": 52.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-03T10:00:00Z",
"latitude": 53.0,
"longitude": -0.1,
"altitude": None,
},
],
)
_api_login(client)
# end is inclusive: 2026-06-02 should be included
resp = client.get("/api/locations", params={"end": "2026-06-02T10:00:00Z"})
assert resp.status_code == 200
items = resp.json()["items"]
datetimes = [it["datetime"] for it in items]
assert "2026-06-01T10:00:00Z" in datetimes
assert "2026-06-02T10:00:00Z" in datetimes # inclusive
assert "2026-06-03T10:00:00Z" not in datetimes
def test_locations_start_and_end_filter(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": "2026-06-01T10:00:00Z",
"latitude": 51.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-02T10:00:00Z",
"latitude": 52.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-03T10:00:00Z",
"latitude": 53.0,
"longitude": -0.1,
"altitude": None,
},
],
)
_api_login(client)
resp = client.get(
"/api/locations",
params={"start": "2026-06-02T10:00:00Z", "end": "2026-06-02T10:00:00Z"},
)
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
assert items[0]["datetime"] == "2026-06-02T10:00:00Z"
# ---------------------------------------------------------------------------
# GET /api/poo — basic
# ---------------------------------------------------------------------------
def test_poo_empty_returns_empty_list(poo_client) -> None:
client, _engine = poo_client
_api_login(client)
resp = client.get("/api/poo")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["limit"] == 100
assert body["offset"] == 0
def test_poo_returns_seeded_rows_desc(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[
{
"timestamp": "2026-06-01T10:00Z",
"status": "success",
"latitude": 51.0,
"longitude": -0.1,
},
{
"timestamp": "2026-06-03T10:00Z",
"status": "fail",
"latitude": 52.0,
"longitude": -0.2,
},
{
"timestamp": "2026-06-02T10:00Z",
"status": "success",
"latitude": 53.0,
"longitude": -0.3,
},
],
)
_api_login(client)
resp = client.get("/api/poo")
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 3
# ordered by timestamp desc
assert items[0]["timestamp"] == "2026-06-03T10:00Z"
assert items[1]["timestamp"] == "2026-06-02T10:00Z"
assert items[2]["timestamp"] == "2026-06-01T10:00Z"
def test_poo_returns_all_fields(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[
{
"timestamp": "2026-06-01T10:00Z",
"status": "success",
"latitude": 51.5,
"longitude": -0.1,
}
],
)
_api_login(client)
resp = client.get("/api/poo")
item = resp.json()["items"][0]
assert set(item.keys()) == {"timestamp", "status", "latitude", "longitude"}
assert item["status"] == "success"
# ---------------------------------------------------------------------------
# GET /api/poo — pagination
# ---------------------------------------------------------------------------
def test_poo_limit_and_offset(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[
{
"timestamp": f"2026-06-{i:02d}T10:00Z",
"status": "success",
"latitude": 51.0,
"longitude": -0.1,
}
for i in range(1, 6)
],
)
_api_login(client)
resp = client.get("/api/poo", params={"limit": 2, "offset": 1})
assert resp.status_code == 200
body = resp.json()
assert body["limit"] == 2
assert body["offset"] == 1
items = body["items"]
assert len(items) == 2
# desc order: rows are 06-05, 06-04, 06-03, 06-02, 06-01
# offset=1 skips 06-05, so first item should be 06-04
assert items[0]["timestamp"] == "2026-06-04T10:00Z"
def test_poo_limit_at_cap_returns_200(poo_client) -> None:
client, _engine = poo_client
_api_login(client)
resp = client.get("/api/poo", params={"limit": 1000})
assert resp.status_code == 200
assert resp.json()["limit"] == 1000
def test_poo_limit_exceeds_cap_returns_422(poo_client) -> None:
client, _engine = poo_client
_api_login(client)
resp = client.get("/api/poo", params={"limit": 1001})
assert resp.status_code == 422
def test_poo_limit_zero_returns_422(poo_client) -> None:
client, _engine = poo_client
_api_login(client)
resp = client.get("/api/poo", params={"limit": 0})
assert resp.status_code == 422
def test_poo_negative_offset_returns_422(poo_client) -> None:
client, _engine = poo_client
_api_login(client)
resp = client.get("/api/poo", params={"offset": -1})
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# GET /api/public-ip
# ---------------------------------------------------------------------------
def test_public_ip_empty_returns_null_state_and_empty_history(client: TestClient) -> None:
_api_login(client)
resp = client.get("/api/public-ip")
assert resp.status_code == 200
body = resp.json()
assert body["state"] is None
assert body["history"] == []
def test_public_ip_returns_state_and_history(location_client) -> None:
client, engine = location_client
_seed_public_ip(engine)
_api_login(client)
resp = client.get("/api/public-ip")
assert resp.status_code == 200
body = resp.json()
state = body["state"]
assert state is not None
assert state["current_ipv4"] == "1.2.3.4"
assert state["previous_ipv4"] == "1.2.3.3"
assert state["last_check_status"] == "changed"
history = body["history"]
assert len(history) == 2
# ordered by observed_at desc — more recent item first
assert history[0]["ipv4"] == "1.2.3.4"
assert history[1]["ipv4"] == "1.2.3.3"
def test_public_ip_history_limit_at_cap_returns_200(client: TestClient) -> None:
_api_login(client)
resp = client.get("/api/public-ip", params={"limit": 1000})
assert resp.status_code == 200
def test_public_ip_history_limit_exceeds_cap_returns_422(client: TestClient) -> None:
_api_login(client)
resp = client.get("/api/public-ip", params={"limit": 1001})
assert resp.status_code == 422
def test_public_ip_history_limit_zero_returns_422(client: TestClient) -> None:
_api_login(client)
resp = client.get("/api/public-ip", params={"limit": 0})
assert resp.status_code == 422
def test_public_ip_state_has_expected_fields(location_client) -> None:
client, engine = location_client
_seed_public_ip(engine)
_api_login(client)
resp = client.get("/api/public-ip")
state = resp.json()["state"]
expected_keys = {
"id",
"current_ipv4",
"previous_ipv4",
"first_seen_at",
"last_checked_at",
"last_changed_at",
"last_check_status",
"last_check_error",
"last_provider",
}
assert set(state.keys()) == expected_keys
def test_public_ip_history_has_expected_fields(location_client) -> None:
client, engine = location_client
_seed_public_ip(engine)
_api_login(client)
resp = client.get("/api/public-ip")
h = resp.json()["history"][0]
assert set(h.keys()) == {"id", "ipv4", "observed_at", "change_type", "provider"}
+545
View File
@@ -0,0 +1,545 @@
"""Tests for M2-T04: PATCH/DELETE /api/locations and /api/poo."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import insert, select
from sqlalchemy.engine import Engine
from app.models.location import Location
from app.models.poo import PooRecord
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
CSRF_HEADER = {"X-CSRF-Token": "any-value"}
def _api_login(client: TestClient) -> None:
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code}"
def _seed_locations(engine: Engine, rows: list[dict]) -> None:
with engine.begin() as conn:
conn.execute(insert(Location), rows)
def _seed_poo(engine: Engine, rows: list[dict]) -> None:
with engine.begin() as conn:
conn.execute(insert(PooRecord), rows)
def _count_locations(engine: Engine) -> int:
with engine.connect() as conn:
return conn.execute(select(Location)).rowcount or len(conn.execute(select(Location)).all())
def _fetch_location(engine: Engine, person: str, dt: str) -> dict | None:
with engine.connect() as conn:
row = conn.execute(
select(Location).where(Location.person == person, Location.datetime == dt)
).one_or_none()
if row is None:
return None
return dict(row._mapping)
def _fetch_poo(engine: Engine, timestamp: str) -> dict | None:
with engine.connect() as conn:
row = conn.execute(
select(PooRecord).where(PooRecord.timestamp == timestamp)
).one_or_none()
if row is None:
return None
return dict(row._mapping)
def _all_location_count(engine: Engine) -> int:
with engine.connect() as conn:
return len(conn.execute(select(Location)).all())
def _all_poo_count(engine: Engine) -> int:
with engine.connect() as conn:
return len(conn.execute(select(PooRecord)).all())
# ---------------------------------------------------------------------------
# PATCH /api/locations/{person}/{datetime} — authentication / CSRF guards
# ---------------------------------------------------------------------------
def test_patch_location_unauthenticated_returns_401(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}],
)
resp = client.patch(
"/api/locations/alice/2026-06-01T10:00:00Z",
json={"latitude": 9.9},
headers=CSRF_HEADER,
)
assert resp.status_code == 401
def test_patch_location_missing_csrf_returns_403(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}],
)
_api_login(client)
resp = client.patch(
"/api/locations/alice/2026-06-01T10:00:00Z",
json={"latitude": 9.9},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# PATCH /api/locations/{person}/{datetime} — 404 for nonexistent PK
# ---------------------------------------------------------------------------
def test_patch_location_nonexistent_pk_returns_404(location_client) -> None:
client, _engine = location_client
_api_login(client)
resp = client.patch(
"/api/locations/nobody/2099-01-01T00:00:00Z",
json={"latitude": 1.0},
headers=CSRF_HEADER,
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PATCH /api/locations/{person}/{datetime} — updates exactly one row's fields
# ---------------------------------------------------------------------------
def test_patch_location_updates_single_row_fields(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": "2026-06-01T10:00:00Z",
"latitude": 51.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-02T10:00:00Z",
"latitude": 52.0,
"longitude": -0.2,
"altitude": None,
},
],
)
_api_login(client)
resp = client.patch(
"/api/locations/alice/2026-06-01T10:00:00Z",
json={"latitude": 99.0, "longitude": 88.0},
headers=CSRF_HEADER,
)
assert resp.status_code == 200
body = resp.json()
assert body["latitude"] == 99.0
assert body["longitude"] == 88.0
assert body["person"] == "alice"
assert body["datetime"] == "2026-06-01T10:00:00Z"
# Confirm DB state — row 1 changed, row 2 unchanged
row1 = _fetch_location(engine, "alice", "2026-06-01T10:00:00Z")
assert row1 is not None
assert row1["latitude"] == 99.0
assert row1["longitude"] == 88.0
row2 = _fetch_location(engine, "alice", "2026-06-02T10:00:00Z")
assert row2 is not None
assert row2["latitude"] == 52.0 # unchanged
assert row2["longitude"] == -0.2 # unchanged
# Row count unchanged — no spurious rows added/removed
assert _all_location_count(engine) == 2
def test_patch_location_partial_update_leaves_other_fields_unchanged(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "bob",
"datetime": "2026-06-10T08:00:00Z",
"latitude": 48.8,
"longitude": 2.3,
"altitude": 100.0,
}
],
)
_api_login(client)
# Only update altitude
resp = client.patch(
"/api/locations/bob/2026-06-10T08:00:00Z",
json={"altitude": 200.0},
headers=CSRF_HEADER,
)
assert resp.status_code == 200
body = resp.json()
assert body["altitude"] == 200.0
assert body["latitude"] == 48.8 # unchanged
assert body["longitude"] == 2.3 # unchanged
def test_patch_location_empty_body_is_noop(location_client) -> None:
"""Sending an empty body should not change the record but still return 200."""
client, engine = location_client
_seed_locations(
engine,
[{"person": "carol", "datetime": "2026-06-05T12:00:00Z", "latitude": 10.0, "longitude": 20.0, "altitude": None}],
)
_api_login(client)
resp = client.patch(
"/api/locations/carol/2026-06-05T12:00:00Z",
json={},
headers=CSRF_HEADER,
)
assert resp.status_code == 200
row = _fetch_location(engine, "carol", "2026-06-05T12:00:00Z")
assert row["latitude"] == 10.0
assert row["longitude"] == 20.0
def test_patch_location_response_has_correct_schema(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": 5.0}],
)
_api_login(client)
resp = client.patch(
"/api/locations/alice/2026-06-01T10:00:00Z",
json={"latitude": 3.0},
headers=CSRF_HEADER,
)
assert resp.status_code == 200
keys = set(resp.json().keys())
assert keys == {"person", "datetime", "latitude", "longitude", "altitude"}
# ---------------------------------------------------------------------------
# DELETE /api/locations/{person}/{datetime} — guards
# ---------------------------------------------------------------------------
def test_delete_location_unauthenticated_returns_401(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}],
)
resp = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER)
assert resp.status_code == 401
def test_delete_location_missing_csrf_returns_403(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}],
)
_api_login(client)
resp = client.delete("/api/locations/alice/2026-06-01T10:00:00Z")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /api/locations/{person}/{datetime} — 404 for nonexistent PK
# ---------------------------------------------------------------------------
def test_delete_location_nonexistent_pk_returns_404(location_client) -> None:
client, _engine = location_client
_api_login(client)
resp = client.delete("/api/locations/nobody/2099-01-01T00:00:00Z", headers=CSRF_HEADER)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE /api/locations/{person}/{datetime} — deletes exactly one row
# ---------------------------------------------------------------------------
def test_delete_location_removes_exactly_one_row(location_client) -> None:
client, engine = location_client
_seed_locations(
engine,
[
{
"person": "alice",
"datetime": "2026-06-01T10:00:00Z",
"latitude": 51.0,
"longitude": -0.1,
"altitude": None,
},
{
"person": "alice",
"datetime": "2026-06-02T10:00:00Z",
"latitude": 52.0,
"longitude": -0.2,
"altitude": None,
},
],
)
_api_login(client)
before = _all_location_count(engine)
assert before == 2
resp = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER)
assert resp.status_code == 204
after = _all_location_count(engine)
assert after == 1 # exactly one row removed
# The deleted row is gone
assert _fetch_location(engine, "alice", "2026-06-01T10:00:00Z") is None
# The other row still exists
assert _fetch_location(engine, "alice", "2026-06-02T10:00:00Z") is not None
def test_delete_location_second_delete_returns_404(location_client) -> None:
"""Deleting the same PK twice must return 404 on the second attempt."""
client, engine = location_client
_seed_locations(
engine,
[{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}],
)
_api_login(client)
resp1 = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER)
assert resp1.status_code == 204
resp2 = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER)
assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# PATCH /api/poo/{timestamp} — guards
# ---------------------------------------------------------------------------
def test_patch_poo_unauthenticated_returns_401(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}],
)
resp = client.patch(
"/api/poo/2026-06-01T10:00Z",
json={"status": "fail"},
headers=CSRF_HEADER,
)
assert resp.status_code == 401
def test_patch_poo_missing_csrf_returns_403(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}],
)
_api_login(client)
resp = client.patch(
"/api/poo/2026-06-01T10:00Z",
json={"status": "fail"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# PATCH /api/poo/{timestamp} — 404
# ---------------------------------------------------------------------------
def test_patch_poo_nonexistent_pk_returns_404(poo_client) -> None:
client, _engine = poo_client
_api_login(client)
resp = client.patch(
"/api/poo/2099-01-01T00:00Z",
json={"status": "fail"},
headers=CSRF_HEADER,
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PATCH /api/poo/{timestamp} — updates single row
# ---------------------------------------------------------------------------
def test_patch_poo_updates_single_row_fields(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[
{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 51.0, "longitude": -0.1},
{"timestamp": "2026-06-02T10:00Z", "status": "success", "latitude": 52.0, "longitude": -0.2},
],
)
_api_login(client)
resp = client.patch(
"/api/poo/2026-06-01T10:00Z",
json={"status": "fail", "latitude": 99.0},
headers=CSRF_HEADER,
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "fail"
assert body["latitude"] == 99.0
assert body["timestamp"] == "2026-06-01T10:00Z"
# Other row unchanged
row2 = _fetch_poo(engine, "2026-06-02T10:00Z")
assert row2 is not None
assert row2["status"] == "success"
assert row2["latitude"] == 52.0
# Row count unchanged
assert _all_poo_count(engine) == 2
def test_patch_poo_partial_update_leaves_other_fields_unchanged(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 51.0, "longitude": -0.1}],
)
_api_login(client)
resp = client.patch(
"/api/poo/2026-06-01T10:00Z",
json={"longitude": 99.9},
headers=CSRF_HEADER,
)
assert resp.status_code == 200
body = resp.json()
assert body["longitude"] == 99.9
assert body["latitude"] == 51.0 # unchanged
assert body["status"] == "success" # unchanged
def test_patch_poo_response_has_correct_schema(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}],
)
_api_login(client)
resp = client.patch(
"/api/poo/2026-06-01T10:00Z",
json={"status": "fail"},
headers=CSRF_HEADER,
)
assert resp.status_code == 200
assert set(resp.json().keys()) == {"timestamp", "status", "latitude", "longitude"}
# ---------------------------------------------------------------------------
# DELETE /api/poo/{timestamp} — guards
# ---------------------------------------------------------------------------
def test_delete_poo_unauthenticated_returns_401(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}],
)
resp = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER)
assert resp.status_code == 401
def test_delete_poo_missing_csrf_returns_403(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}],
)
_api_login(client)
resp = client.delete("/api/poo/2026-06-01T10:00Z")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /api/poo/{timestamp} — 404
# ---------------------------------------------------------------------------
def test_delete_poo_nonexistent_pk_returns_404(poo_client) -> None:
client, _engine = poo_client
_api_login(client)
resp = client.delete("/api/poo/2099-01-01T00:00Z", headers=CSRF_HEADER)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE /api/poo/{timestamp} — deletes exactly one row
# ---------------------------------------------------------------------------
def test_delete_poo_removes_exactly_one_row(poo_client) -> None:
client, engine = poo_client
_seed_poo(
engine,
[
{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 51.0, "longitude": -0.1},
{"timestamp": "2026-06-02T10:00Z", "status": "fail", "latitude": 52.0, "longitude": -0.2},
],
)
_api_login(client)
before = _all_poo_count(engine)
assert before == 2
resp = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER)
assert resp.status_code == 204
after = _all_poo_count(engine)
assert after == 1 # exactly one row removed
# Deleted row is gone
assert _fetch_poo(engine, "2026-06-01T10:00Z") is None
# Other row still exists
assert _fetch_poo(engine, "2026-06-02T10:00Z") is not None
def test_delete_poo_second_delete_returns_404(poo_client) -> None:
"""Deleting the same PK twice must return 404 on the second attempt."""
client, engine = poo_client
_seed_poo(
engine,
[{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}],
)
_api_login(client)
resp1 = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER)
assert resp1.status_code == 204
resp2 = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER)
assert resp2.status_code == 404
+352
View File
@@ -0,0 +1,352 @@
"""Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password."""
from __future__ import annotations
import re
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# 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 _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(
"/api/auth/login",
json={"username": username, "password": password},
)
# ---------------------------------------------------------------------------
# GET /api/session — unauthenticated
# ---------------------------------------------------------------------------
def test_get_session_unauthenticated_returns_401(client: TestClient) -> None:
response = client.get("/api/session")
assert response.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/session — authenticated (via Jinja login)
# ---------------------------------------------------------------------------
def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> None:
_jinja_login(client)
response = client.get("/api/session")
assert response.status_code == 200
body = response.json()
assert "user" in body
assert "csrf_token" in body
assert body["user"]["username"] == "admin"
assert isinstance(body["user"]["force_password_change"], bool)
assert isinstance(body["csrf_token"], str)
assert body["csrf_token"] # non-empty
def test_get_session_does_not_leak_password(client: TestClient) -> None:
_jinja_login(client)
response = client.get("/api/session")
body_str = str(response.json())
assert "test-password" not in body_str
assert "password_hash" not in body_str
# ---------------------------------------------------------------------------
# POST /api/auth/login
# ---------------------------------------------------------------------------
def test_post_login_valid_credentials_returns_200_with_session(client: TestClient) -> None:
response = _api_login(client)
assert response.status_code == 200
body = response.json()
assert "user" in body
assert "csrf_token" in body
assert body["user"]["username"] == "admin"
assert isinstance(body["csrf_token"], str)
assert body["csrf_token"]
def test_post_login_sets_httponly_session_cookie(client: TestClient) -> None:
response = _api_login(client)
assert response.status_code == 200
set_cookie = response.headers.get("set-cookie", "").lower()
assert "home_automation_session=" in set_cookie
assert "httponly" in set_cookie
assert "samesite=lax" in set_cookie
assert "path=/" in set_cookie
def test_post_login_cookie_secure_flag_follows_settings(client: TestClient) -> None:
"""In test mode AUTH_COOKIE_SECURE_OVERRIDE=false so secure should be absent."""
response = _api_login(client)
assert response.status_code == 200
set_cookie = response.headers.get("set-cookie", "").lower()
# secure is absent because AUTH_COOKIE_SECURE_OVERRIDE=false in conftest
assert "secure" not in set_cookie
def test_post_login_invalid_credentials_returns_401(client: TestClient) -> None:
response = _api_login(client, password="wrong-password")
assert response.status_code == 401
# No session cookie should be set
assert "set-cookie" not in response.headers or (
"home_automation_session=" not in response.headers.get("set-cookie", "").lower()
)
def test_post_login_unknown_user_returns_401(client: TestClient) -> None:
response = _api_login(client, username="nobody", password="irrelevant")
assert response.status_code == 401
def test_post_login_does_not_require_csrf_header(client: TestClient) -> None:
"""Login is unauthenticated; no X-CSRF-Token should be required."""
response = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
# Deliberately omit X-CSRF-Token
)
assert response.status_code == 200
def test_post_login_allows_subsequent_authenticated_request(client: TestClient) -> None:
login_resp = _api_login(client)
assert login_resp.status_code == 200
# GET /api/session should now succeed (cookie was set on the client)
session_resp = client.get("/api/session")
assert session_resp.status_code == 200
# ---------------------------------------------------------------------------
# POST /api/auth/logout
# ---------------------------------------------------------------------------
def test_post_logout_unauthenticated_returns_401(client: TestClient) -> None:
response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"})
assert response.status_code == 401
def test_post_logout_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
_api_login(client)
response = client.post("/api/auth/logout")
assert response.status_code == 403
def test_post_logout_authenticated_empty_csrf_returns_403(client: TestClient) -> None:
_api_login(client)
response = client.post("/api/auth/logout", headers={"X-CSRF-Token": ""})
assert response.status_code == 403
def test_post_logout_authenticated_with_csrf_returns_204(client: TestClient) -> None:
_api_login(client)
response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "any-non-empty-value"})
assert response.status_code == 204
def test_post_logout_invalidates_session(client: TestClient) -> None:
_api_login(client)
# Verify session is active
assert client.get("/api/session").status_code == 200
# Logout
client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"})
# Session should now be gone
assert client.get("/api/session").status_code == 401
# ---------------------------------------------------------------------------
# POST /api/auth/password
# ---------------------------------------------------------------------------
def test_post_password_unauthenticated_returns_401(client: TestClient) -> None:
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 401
def test_post_password_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
)
assert response.status_code == 403
def test_post_password_success_returns_204(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 204
def test_post_password_wrong_current_password_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "wrong-current",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
# Error message must be generic — no leaking which check failed
detail = response.json().get("detail", "")
assert "current password is invalid" not in detail
assert detail == "password change failed"
def test_post_password_mismatched_new_passwords_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "different-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
assert response.json()["detail"] == "password change failed"
def test_post_password_too_short_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "short",
"confirm_password": "short",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
assert response.json()["detail"] == "password change failed"
def test_post_password_same_as_current_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "test-password",
"confirm_password": "test-password",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
assert response.json()["detail"] == "password change failed"
def test_post_password_success_sets_force_password_change_false(client: TestClient) -> None:
"""After successful password change, force_password_change should be False."""
_api_login(client)
# The bootstrap user always has force_password_change=True; change it
resp = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert resp.status_code == 204
# Session still active; force_password_change should now be False
session_resp = client.get("/api/session")
assert session_resp.status_code == 200
assert session_resp.json()["user"]["force_password_change"] is False
def test_post_password_does_not_revoke_session(client: TestClient) -> None:
"""After password change, the session remains valid (not revoked)."""
_api_login(client)
client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
# Session must still be active
assert client.get("/api/session").status_code == 200
# ---------------------------------------------------------------------------
# Response schema correctness — no secrets in session response
# ---------------------------------------------------------------------------
def test_session_response_has_no_secret_fields(client: TestClient) -> None:
login_resp = _api_login(client)
assert login_resp.status_code == 200
body = login_resp.json()
# Must have exactly these top-level keys
assert set(body.keys()) == {"user", "csrf_token"}
# user must have exactly these keys
assert set(body["user"].keys()) == {"username", "force_password_change"}
-1
View File
@@ -1,6 +1,5 @@
import re
import sqlite3
from pathlib import Path
from fastapi.testclient import TestClient