feat: harden auth for public deploy + gantt, duration units, docker packaging
Security (fail-secure for production): - Refuse to start without PPM_PASSWORD/PPM_PASSWORD_HASH unless PPM_ALLOW_NO_AUTH=1 (local dev only) - Add login rate limiting (5 failures / 60s per client), enforced inside SessionManager.login so it cannot be bypassed at the endpoint layer - compose defaults PPM_COOKIE_SECURE=true for HTTPS behind reverse proxy - Add .dockerignore to keep runtime data/out of the image Features: - Task timeline gantt on home page (week/month zoom, horizontal scroll, infinite-arrow for open-ended dates, CSS Grid layout) - Duration metrics gain a second/minute/hour unit for input & display (canonical storage stays integer seconds) - Per-metric progress bars on home page (no more merged average) - Backfill historical records via date picker in the today-record card - Admin panel: stats overview, JSON import/restore, full task list mgmt Fixes: - Nullable task startDate; summarize handles missing start date - Task dialog cancel/close no longer submits the form - Import validates whole backup first, rejects path traversal, rebuilds archives, and replaces stale files Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 测试环境:允许无密码启动,避免模块级 create_app() 因 fail-secure 抛错。
|
||||
os.environ.setdefault("PPM_ALLOW_NO_AUTH", "1")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -52,3 +52,42 @@ def test_invalid_input_and_auth(auth_client):
|
||||
forbidden = auth_client.post("/api/tasks", json=payload())
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
def test_duration_unit_and_backfill(client):
|
||||
task = client.post("/api/tasks", json={
|
||||
"name": "时长任务", "startDate": "2026-08-01",
|
||||
"metrics": [{"name": "专注", "kind": "duration", "period": "day", "durationUnit": "minute", "target": 120}],
|
||||
}).json()
|
||||
assert task["metrics"][0]["durationUnit"] == "minute"
|
||||
metric_id = task["metrics"][0]["metricId"]
|
||||
saved = client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-10", json={"expectedVersion": 0, "values": [{"metricId": metric_id, "value": 3600}]})
|
||||
assert saved.status_code == 200
|
||||
summary = client.get(f"/api/tasks/{task['taskId']}/summary?date=2026-08-10").json()
|
||||
metric = summary["tasks"][task["taskId"]]["metrics"][0]
|
||||
assert metric["value"] == 3600, "时长仍以整数秒存储"
|
||||
assert metric.get("durationUnit") == "minute"
|
||||
|
||||
|
||||
def test_invalid_duration_unit_rejected(client):
|
||||
bad = client.post("/api/tasks", json={"name": "非法单位", "startDate": "2026-08-01", "metrics": [{"name": "x", "kind": "duration", "period": "day", "durationUnit": "year"}]})
|
||||
assert bad.status_code == 422
|
||||
|
||||
|
||||
def test_task_without_dates(client):
|
||||
task = client.post("/api/tasks", json={"name": "无日期任务", "metrics": [{"name": "完成", "kind": "checkin", "period": "day"}]}).json()
|
||||
assert task["startDate"] is None and task["endDate"] is None
|
||||
# 无开始日期的 lifetime 统计不应报错
|
||||
assert client.get(f"/api/tasks/{task['taskId']}/summary").status_code == 200
|
||||
|
||||
|
||||
def test_admin_stats_and_import(client):
|
||||
task = client.post("/api/tasks", json=payload("导入任务")).json()
|
||||
metric_id = task["metrics"][0]["metricId"]
|
||||
client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-20", json={"expectedVersion": 0, "values": [{"metricId": metric_id, "value": 1}]})
|
||||
stats = client.get("/api/admin/stats").json()
|
||||
assert stats["taskCount"] == 1 and stats["dailyFactCount"] == 1
|
||||
exported = client.get("/api/export").json()
|
||||
imported = client.post("/api/admin/import", json=exported)
|
||||
assert imported.status_code == 200 and imported.json()["imported"] == 1
|
||||
assert client.get("/api/admin/stats").json()["taskCount"] == 1
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import date, timedelta
|
||||
|
||||
from app.auth import SessionManager, make_password_hash
|
||||
from app.models import DailyRecordInput, MetricValue, TaskInput
|
||||
from app.repository import ConflictError, JsonRepository, period_window
|
||||
from app.repository import ConflictError, JsonRepository, ValidationError, period_window
|
||||
|
||||
|
||||
def task_payload():
|
||||
@@ -180,3 +180,76 @@ def test_session_ttl_keeps_fresh_sessions():
|
||||
session_id, session = manager.login("secret")
|
||||
session.created_at -= 5
|
||||
assert manager.get(session_id) is session
|
||||
|
||||
|
||||
def test_import_replaces_stale_files(tmp_path):
|
||||
repo = JsonRepository(tmp_path)
|
||||
first = repo.create_task(task_payload())
|
||||
second = repo.create_task(task_payload().model_copy(update={"name": "多出来的任务"}))
|
||||
assert repo.list_tasks()["items"] and len(repo.list_tasks()["items"]) == 2
|
||||
|
||||
# 用只含第一个任务的备份整体替换,第二个任务应被清除。
|
||||
export = repo.export_all()
|
||||
export["tasks"] = [first]
|
||||
export["revisions"] = [rev for rev in export["revisions"] if rev["taskId"] == first["taskId"]]
|
||||
export["daily"] = []
|
||||
repo.import_all(export)
|
||||
remaining = repo.list_tasks(include_deleted=True)["items"]
|
||||
assert [task["taskId"] for task in remaining] == [first["taskId"]]
|
||||
|
||||
|
||||
def test_import_rejects_path_traversal(tmp_path):
|
||||
repo = JsonRepository(tmp_path)
|
||||
payload = repo.export_all()
|
||||
payload["tasks"] = [{"taskId": "../evil", "schemaVersion": 1, "name": "x", "version": 1, "revision": 1, "createdAt": "x", "updatedAt": "x", "deletedAt": None, "metrics": []}]
|
||||
try:
|
||||
repo.import_all(payload)
|
||||
except ValidationError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("路径逃逸的 taskId 应被拒绝")
|
||||
|
||||
|
||||
def test_import_rebuilds_archives(tmp_path):
|
||||
repo = JsonRepository(tmp_path)
|
||||
task = repo.create_task(task_payload())
|
||||
metric_id = task["metrics"][0]["metricId"]
|
||||
repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=1)]))
|
||||
export = repo.export_all()
|
||||
# 清空归档目录,模拟“从未归档”的目标状态。
|
||||
for path in (tmp_path / "archive").glob("*/*.json"):
|
||||
path.unlink()
|
||||
assert list((tmp_path / "archive").glob("*/*.json")) == []
|
||||
repo.import_all(export)
|
||||
assert list((tmp_path / "archive").glob("*/*.json")), "导入后应重建归档"
|
||||
|
||||
|
||||
def test_session_manager_fail_secure(monkeypatch):
|
||||
from app.auth import SessionManager
|
||||
# 清掉所有相关环境变量后,from_environment 应拒绝启动
|
||||
for key in ("PPM_PASSWORD", "PPM_PASSWORD_HASH", "PPM_ALLOW_NO_AUTH"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
try:
|
||||
SessionManager.from_environment()
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("未配置密码时应 fail-secure 拒绝启动")
|
||||
|
||||
|
||||
def test_session_manager_allow_no_auth(monkeypatch):
|
||||
from app.auth import SessionManager
|
||||
monkeypatch.delenv("PPM_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("PPM_PASSWORD_HASH", raising=False)
|
||||
monkeypatch.setenv("PPM_ALLOW_NO_AUTH", "1")
|
||||
assert SessionManager.from_environment().enabled is False
|
||||
|
||||
|
||||
def test_login_rate_limiting():
|
||||
from app.auth import SessionManager
|
||||
manager = SessionManager(make_password_hash("secret"), max_failures=3, lockout_seconds=60)
|
||||
for _ in range(3):
|
||||
assert manager.login("wrong", "1.2.3.4") is None
|
||||
assert manager.is_login_locked("1.2.3.4") is True
|
||||
# 正确密码在锁定期也应被拒
|
||||
assert manager.login("secret", "1.2.3.4") is None
|
||||
|
||||
Reference in New Issue
Block a user