Compare commits
19 Commits
v1.1.0
..
6cc6382515
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc6382515 | |||
| ef2bd3c9c5 | |||
| cc2c02a2e2 | |||
| b2e26f0b17 | |||
| 8975acc48b | |||
| 6cfeb2b865 | |||
| dba9e28540 | |||
| 2bc5d6ea9a | |||
| 3ec663e138 | |||
| 048414c5cb | |||
| 9ce3f2a0b8 | |||
| 0fba7cfe11 | |||
| d8303eaa3d | |||
| 8da1f13e60 | |||
| de77019ce3 | |||
| c2b1b7b751 | |||
| 3628ac51e5 | |||
| 1756192270 | |||
| 66ec9979cc |
@@ -45,6 +45,14 @@
|
|||||||
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
|
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
|
||||||
- **Reviewer**(强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。
|
- **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` 后:
|
根目录、激活 `.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)。
|
前端任务(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/`)
|
||||||
|
|
||||||
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
|
每轮工作都要在 `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` 去掉后再继续。
|
- 每次提交前**自检**:`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
|
||||||
|
|
||||||
### Review 后返工
|
### Review 后返工
|
||||||
- 返工产生的提交**一律用 fixup**,指向本轮对应的 base commit,**不写新的独立 message**:
|
- **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit,**不写新的独立 message**:
|
||||||
```bash
|
```bash
|
||||||
git add -A
|
git add -A
|
||||||
git commit --fixup=<base-commit-sha>
|
git commit --fixup=<base-commit-sha>
|
||||||
```
|
```
|
||||||
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit。
|
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)。
|
||||||
|
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
|
||||||
|
|
||||||
### 本轮 / feature 收尾(用户确认收尾后)
|
### 本轮 / feature 收尾(用户确认收尾后)
|
||||||
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**:
|
- 用 **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 等对外操作先确认。
|
- 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)。
|
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
|
||||||
|
|||||||
@@ -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."},
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -8,6 +8,9 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import models # noqa: F401
|
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.auth import router as auth_router
|
||||||
from app.api.routes import pages, status
|
from app.api.routes import pages, status
|
||||||
from app.db import get_session_local
|
from app.db import get_session_local
|
||||||
@@ -91,6 +94,9 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(status.router)
|
app.include_router(status.router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(pages.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(homeassistant_router)
|
||||||
app.include_router(location_router)
|
app.include_router(location_router)
|
||||||
app.include_router(poo_router)
|
app.include_router(poo_router)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import insert
|
from sqlalchemy import delete, insert, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
|
|||||||
)
|
)
|
||||||
session.execute(stmt)
|
session.execute(stmt)
|
||||||
session.commit()
|
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
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import desc, insert, select
|
from sqlalchemy import delete, desc, insert, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -74,6 +74,53 @@ def record_poo(
|
|||||||
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
|
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:
|
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
|
||||||
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
|
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
|
||||||
record = session.execute(stmt).scalar_one_or_none()
|
record = session.execute(stmt).scalar_one_or_none()
|
||||||
|
|||||||
@@ -27,15 +27,16 @@
|
|||||||
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
|
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
|
||||||
|
|
||||||
- 继续用现有 **HttpOnly session cookie**(同源自动携带),M2 **不引入 token**(token 属 M3)。
|
- 继续用现有 **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**。
|
- 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record`、`POST /poo/record`)维持现状到 M3**。
|
||||||
|
|
||||||
### 3.3 前端工程
|
### 3.3 前端工程
|
||||||
|
|
||||||
- `frontend/`:**Vite + React + TypeScript**。
|
- `frontend/`:**Vite + React + TypeScript**。
|
||||||
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装,或同类工具)。生成物入库或在 build 时生成(见 T06 决策)。
|
- 组件库:**Mantine**(已定;批电池齐、TS 优先、视觉中性,最贴近此前 Vue 侧 Naive UI 的用法)。
|
||||||
- 可视化:地图 + 热力图(location 轨迹 / poo 点位)。建议 **MapLibre GL 或 Leaflet + heatmap 插件**(最终选型见 §5 决策)。
|
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装)。**生成物入库** + `npm run codegen` + CI 校验"生成物与 openapi 同步"(已定)。fetch 封装统一带 cookie、写请求注入自定义 CSRF header、401 跳登录。
|
||||||
- 状态/数据请求:轻量即可(如 TanStack Query),不引入重型框架。
|
- 可视化:**Leaflet**(已定)—— `react-leaflet` + `leaflet.heat`(热力图,**头号功能**)+ `leaflet.markercluster`(点多时聚合)+ OSM 栅格瓦片(零 key)。**地图封在一个自包含组件后面**(如 `<RecordsMap points mode onSelect>`,全应用只此处 import leaflet),数据获取/时间窗 state 在外面;这样将来若要换 **MapLibre GL** 是被隔离的局部重写,不波及其它。
|
||||||
|
- 状态/数据请求:轻量即可(**TanStack Query**,已定),不引入重型框架。
|
||||||
|
|
||||||
### 3.4 构建与部署
|
### 3.4 构建与部署
|
||||||
|
|
||||||
@@ -65,14 +66,31 @@
|
|||||||
|
|
||||||
> 记录 CRUD 依赖现有 PK 作行标识(location PK=`person+datetime`,poo PK=`timestamp`)。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。
|
> 记录 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. 任务依赖图
|
## 6. 任务依赖图
|
||||||
|
|
||||||
@@ -104,7 +122,7 @@
|
|||||||
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
|
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
|
||||||
|
|
||||||
### M2-T01 — config JSON API
|
### M2-T01 — config JSON API
|
||||||
- **Status**: `todo` · **Depends**: none(M1 完成后)
|
- **Status**: `done` · **Depends**: none(M1 完成后)
|
||||||
- **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。
|
- **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`
|
- **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 不回显、留空保留旧值语义照搬。
|
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`;session 保护;secret 不回显、留空保留旧值语义照搬。
|
||||||
@@ -116,7 +134,7 @@
|
|||||||
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
|
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
|
||||||
|
|
||||||
### M2-T02 — session / auth JSON API
|
### M2-T02 — session / auth JSON API
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。
|
- **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`
|
- **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`。
|
- **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,不明文。
|
- **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env`、`SameSite=Lax`;密码仍 Argon2,不明文。
|
||||||
|
|
||||||
### M2-T03 — 数据读取 API(locations / poo / public-ip)
|
### M2-T03 — 数据读取 API(locations / 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`
|
- **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 保护;查询参数有上限防全表导出。
|
- **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`(state + history);session 保护;查询参数有上限防全表导出。
|
||||||
- **Acceptance**:
|
- **Acceptance**:
|
||||||
@@ -139,7 +157,7 @@
|
|||||||
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
|
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
|
||||||
|
|
||||||
### M2-T04 — 记录 CRUD API(修正 / 删除)
|
### 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`
|
- **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` location(PK person+datetime)与 poo(PK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
|
- **Steps**: `PATCH`/`DELETE` location(PK person+datetime)与 poo(PK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
|
||||||
- **Acceptance**:
|
- **Acceptance**:
|
||||||
@@ -151,7 +169,7 @@
|
|||||||
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
|
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
|
||||||
|
|
||||||
### M2-T05 — SMTP 测试 / 动作类 JSON API
|
### 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`
|
- **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)。
|
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。
|
||||||
- **Acceptance**:
|
- **Acceptance**:
|
||||||
@@ -159,7 +177,7 @@
|
|||||||
- [ ] 校验闸门全绿。
|
- [ ] 校验闸门全绿。
|
||||||
|
|
||||||
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
|
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
|
||||||
- **Status**: `todo` · **Depends**: M2-T01..T05(OpenAPI 已稳定)
|
- **Status**: `done` · **Depends**: M2-T01..T05(OpenAPI 已稳定)
|
||||||
- **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。
|
- **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` 脚本
|
- **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 跳登录。
|
- **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
|
||||||
@@ -170,17 +188,17 @@
|
|||||||
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
|
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
|
||||||
|
|
||||||
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
|
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
|
||||||
- **Status**: `todo` · **Depends**: M2-T06
|
- **Status**: `done` · **Depends**: M2-T06
|
||||||
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
|
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
|
||||||
|
|
||||||
### M2-T08 — 配置 UI(取代 Jinja config 页)
|
### M2-T08 — 配置 UI(取代 Jinja config 页)
|
||||||
- **Status**: `todo` · **Depends**: M2-T06
|
- **Status**: `done` · **Depends**: M2-T06
|
||||||
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
|
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
|
||||||
|
|
||||||
### M2-T09 — 数据可视化 UI(地图 + 热力图)
|
### M2-T09 — 数据可视化 UI(热力图为主的地图)
|
||||||
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03)
|
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03)
|
||||||
- **Context**: 接管 Grafana 原职责:location 轨迹/热力图、poo 点位。
|
- **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉。
|
||||||
- **Acceptance**: 地图渲染 location/poo 点;热力图层可切换;时间范围筛选生效;前端闸门全绿。
|
- **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。
|
||||||
|
|
||||||
### M2-T10 — 记录管理 UI(按需展示 + 增删改)
|
### M2-T10 — 记录管理 UI(按需展示 + 增删改)
|
||||||
- **Status**: `todo` · **Depends**: M2-T06(CRUD 来自 T04)
|
- **Status**: `todo` · **Depends**: M2-T06(CRUD 来自 T04)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Future Ideas / Backlog(暂无 Milestone)
|
||||||
|
|
||||||
|
记录尚未排期的想法。等某条成形、值得集中推进时,再升级为 `docs/roadmap.md` 里的 milestone 并展开成 `docs/design/` 任务卡。**这里只是备忘,不是承诺。**
|
||||||
|
|
||||||
|
> 项目定位:**个人自用、针对自家场景特化,不开源**。因此设计可按单用户 / 自家需求简化,不必为通用性、多租户、对外发布做过度抽象。
|
||||||
|
|
||||||
|
## 数据与存储
|
||||||
|
- 增加更多数据类型 / 来源(持续扩展)。
|
||||||
|
- 针对**需要长期保存**的数据,考虑更合适的存储方案(当前全 SQLite;长期 / 大量数据可能需要更强的数据库)。
|
||||||
|
- 把 **Home Assistant 接收到的数据**纳入本系统做持久化 / 展示。
|
||||||
|
|
||||||
|
## 集成
|
||||||
|
- **MQTT**:让后端作为一个 MQTT client,双向收发数据。
|
||||||
|
|
||||||
|
## 前端 / 移动端
|
||||||
|
- **PWA**(**近期、可能并入 M2 或单独小里程碑**):在 React Native(M3)之前,用 PWA 把 web SPA 包装成"准手机 App"——可安装到桌面、响应式、离线壳。
|
||||||
|
- 影响当下设计:**M2 的 UI 从一开始就按移动端布局考虑**(响应式 + 合理的参数显示),为之后加 PWA 铺路。
|
||||||
|
|
||||||
|
## 备注
|
||||||
|
- 以上为临时记录(讨论 M2 范围时随手想到),后续可增删、重排优先级。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -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 T07–T10.
|
||||||
|
|
||||||
|
## 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 T07–T10 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.
|
||||||
@@ -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: '^_' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
Generated
+7204
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
+1651
File diff suppressed because it is too large
Load Diff
@@ -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}</>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -168,6 +168,563 @@ paths:
|
|||||||
text/html:
|
text/html:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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:
|
/homeassistant/publish:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -302,6 +859,84 @@ components:
|
|||||||
required:
|
required:
|
||||||
- csrf_token
|
- csrf_token
|
||||||
title: Body_logout_logout_post
|
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:
|
HTTPValidationError:
|
||||||
properties:
|
properties:
|
||||||
detail:
|
detail:
|
||||||
@@ -311,6 +946,163 @@ components:
|
|||||||
title: Detail
|
title: Detail
|
||||||
type: object
|
type: object
|
||||||
title: HTTPValidationError
|
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:
|
PublicIPCheckResponse:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
@@ -334,6 +1126,145 @@ components:
|
|||||||
- checked_at
|
- checked_at
|
||||||
- changed
|
- changed
|
||||||
title: PublicIPCheckResponse
|
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:
|
StatusResponse:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
|
|||||||
@@ -26,3 +26,8 @@ pythonpath = ["."]
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
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"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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,6 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user