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:
@@ -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