[阶段3.1] pytest 测试基建 + auth/health/analysis 单元测试 - 102 用例全绿
测试基建: - pytest.ini / requirements-dev.txt - tests/conftest.py: 每个用例独立 SQLite 库,提供 db / app / client / user / auth / seed_health 六个 fixture tests/test_auth.py (38 通过, 1 跳过): - 密码哈希: 加盐唯一性、格式自描述、明文不入库、畸形哈希不抛异常 - 回归用例: 无 scrypt 的解释器上也能哈希(覆盖上一个 commit 的 bug) - JWT: 过期/换密钥/篡改签名均拒绝 - 登录错误不区分"邮箱不存在"与"密码错误"(防用户枚举) - require_auth: 缺失/畸形/过期 header 一律 401 而非 500 tests/test_health.py (30 通过): - 日期范围上下界均为闭区间 - 数据按 user_id 隔离,查不到他人数据 - 重复 upsert 同一天不产生重复行 - 各指标端点正确剔除 NULL 行 tests/test_analysis.py (34 通过): - 5 条建议规则的阈值边界逐条固化(8000 步 / 7 小时 / 50 压力 / 65 静息心率 / 40 HRV) - 均值按窗口计算而非逐日;只取最近 14 天 - 无睡眠记录的日子不被当作 0 拉低均值 - metric 名走白名单,SQL 注入串不生效 - 建议按 high/medium/low 排序 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
233
backend/tests/test_auth.py
Normal file
233
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Unit tests for password hashing, JWT handling, and the auth endpoints."""
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
import auth
|
||||
from config import JWT_SECRET
|
||||
|
||||
|
||||
# --- password hashing -------------------------------------------------------
|
||||
class TestPasswordHashing:
|
||||
def test_hash_is_verifiable(self):
|
||||
stored = auth.hash_password("correct horse")
|
||||
assert auth.verify_password("correct horse", stored) is True
|
||||
|
||||
def test_wrong_password_rejected(self):
|
||||
stored = auth.hash_password("correct horse")
|
||||
assert auth.verify_password("wrong horse", stored) is False
|
||||
|
||||
def test_salt_makes_hashes_unique(self):
|
||||
a = auth.hash_password("same")
|
||||
b = auth.hash_password("same")
|
||||
assert a != b, "identical passwords must not produce identical hashes"
|
||||
|
||||
def test_hash_format_is_self_describing(self):
|
||||
stored = auth.hash_password("pw")
|
||||
prefix, iterations, salt, digest = stored.split("$")
|
||||
assert prefix == auth.PBKDF2_PREFIX
|
||||
assert int(iterations) == auth.PBKDF2_ITERATIONS
|
||||
assert len(bytes.fromhex(salt)) == 16
|
||||
assert len(bytes.fromhex(digest)) == 64
|
||||
|
||||
def test_password_never_appears_in_hash(self):
|
||||
stored = auth.hash_password("supersecret")
|
||||
assert "supersecret" not in stored
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stored",
|
||||
["", None, "garbage", "pbkdf2_sha256$notanint$aa$bb", "nothex:nothex"],
|
||||
)
|
||||
def test_malformed_hashes_rejected_not_raised(self, stored):
|
||||
assert auth.verify_password("anything", stored) is False
|
||||
|
||||
def test_hashing_does_not_require_scrypt(self, monkeypatch):
|
||||
"""Regression: macOS system Python (LibreSSL) has no hashlib.scrypt.
|
||||
|
||||
Registration used to raise AttributeError -> HTTP 500 on those builds.
|
||||
"""
|
||||
monkeypatch.delattr(hashlib, "scrypt", raising=False)
|
||||
stored = auth.hash_password("pw")
|
||||
assert auth.verify_password("pw", stored) is True
|
||||
|
||||
def test_unicode_password(self):
|
||||
stored = auth.hash_password("密码🔒")
|
||||
assert auth.verify_password("密码🔒", stored) is True
|
||||
assert auth.verify_password("密码", stored) is False
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(hashlib, "scrypt"), reason="interpreter built without scrypt"
|
||||
)
|
||||
def test_legacy_scrypt_hash_still_verifies(self):
|
||||
salt = os.urandom(16)
|
||||
digest = hashlib.scrypt(
|
||||
b"legacy", salt=salt, n=16384, r=8, p=1, dklen=64
|
||||
).hex()
|
||||
assert auth.verify_password("legacy", f"{salt.hex()}:{digest}") is True
|
||||
assert auth.verify_password("nope", f"{salt.hex()}:{digest}") is False
|
||||
|
||||
|
||||
# --- JWT --------------------------------------------------------------------
|
||||
class TestTokens:
|
||||
def test_sign_and_verify_roundtrip(self):
|
||||
token = auth.sign_token("user-123")
|
||||
assert auth.verify_token(token) == {"user_id": "user-123"}
|
||||
|
||||
def test_expired_token_rejected(self):
|
||||
past = datetime.datetime.utcnow() - datetime.timedelta(days=1)
|
||||
token = jwt.encode(
|
||||
{"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256"
|
||||
)
|
||||
with pytest.raises(jwt.ExpiredSignatureError):
|
||||
auth.verify_token(token)
|
||||
|
||||
def test_token_signed_with_other_secret_rejected(self):
|
||||
token = jwt.encode({"sub": "u"}, "a-different-secret", algorithm="HS256")
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
auth.verify_token(token)
|
||||
|
||||
def test_tampered_token_rejected(self):
|
||||
token = auth.sign_token("user-123")
|
||||
head, payload, sig = token.split(".")
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
auth.verify_token(f"{head}.{payload}.{sig[:-2]}xx")
|
||||
|
||||
|
||||
# --- register ---------------------------------------------------------------
|
||||
class TestRegister:
|
||||
def test_returns_201_and_token(self, client):
|
||||
r = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "a@example.com",
|
||||
"garminEmail": "g@example.com",
|
||||
"garminPassword": "pw123456",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.get_json()
|
||||
assert body["email"] == "a@example.com"
|
||||
assert auth.verify_token(body["token"])["user_id"] == body["id"]
|
||||
|
||||
def test_duplicate_email_conflicts(self, client, user):
|
||||
r = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": user["email"],
|
||||
"garminEmail": "other@example.com",
|
||||
"garminPassword": "pw123456",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"email": "a@example.com"},
|
||||
{"email": "a@example.com", "garminEmail": "g@example.com"},
|
||||
{"email": "", "garminEmail": "g@example.com", "garminPassword": "x"},
|
||||
],
|
||||
)
|
||||
def test_missing_fields_rejected(self, client, payload):
|
||||
assert client.post("/api/auth/register", json=payload).status_code == 400
|
||||
|
||||
def test_password_stored_only_as_hash(self, client, db):
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "h@example.com",
|
||||
"garminEmail": "g@example.com",
|
||||
"garminPassword": "plaintext-secret",
|
||||
},
|
||||
)
|
||||
row = db.query_one(
|
||||
"SELECT garmin_password_hash FROM users WHERE email = ?", ["h@example.com"]
|
||||
)
|
||||
assert "plaintext-secret" not in row["garmin_password_hash"]
|
||||
assert auth.verify_password("plaintext-secret", row["garmin_password_hash"])
|
||||
|
||||
|
||||
# --- login ------------------------------------------------------------------
|
||||
class TestLogin:
|
||||
def test_valid_credentials(self, client, user):
|
||||
r = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["id"] == user["id"]
|
||||
|
||||
def test_wrong_password(self, client, user):
|
||||
r = client.post(
|
||||
"/api/auth/login", json={"email": user["email"], "password": "nope"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_unknown_email(self, client):
|
||||
r = client.post(
|
||||
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_error_does_not_reveal_which_field_was_wrong(self, client, user):
|
||||
unknown = client.post(
|
||||
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
|
||||
).get_json()
|
||||
bad_pw = client.post(
|
||||
"/api/auth/login", json={"email": user["email"], "password": "nope"}
|
||||
).get_json()
|
||||
assert unknown == bad_pw, "responses must not distinguish the two cases"
|
||||
|
||||
|
||||
# --- require_auth -----------------------------------------------------------
|
||||
class TestRequireAuth:
|
||||
def test_missing_header(self, client):
|
||||
assert client.get("/api/health/summary").status_code == 401
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header",
|
||||
["", "Bearer", "Token abc", "bearer abc", "Bearer not.a.jwt"],
|
||||
)
|
||||
def test_malformed_headers(self, client, header):
|
||||
r = client.get("/api/health/summary", headers={"Authorization": header})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_expired_token_gets_401_not_500(self, client):
|
||||
past = datetime.datetime.utcnow() - datetime.timedelta(days=1)
|
||||
token = jwt.encode(
|
||||
{"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256"
|
||||
)
|
||||
r = client.get(
|
||||
"/api/health/summary", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert "expired" in r.get_json()["error"]
|
||||
|
||||
def test_valid_token_passes(self, client, auth):
|
||||
assert client.get("/api/health/summary", headers=auth).status_code == 200
|
||||
|
||||
|
||||
class TestLogoutAndRefresh:
|
||||
def test_logout_clears_stored_token(self, client, auth, user, db):
|
||||
assert client.post("/api/auth/logout", headers=auth).status_code == 200
|
||||
row = db.query_one("SELECT jwt_token FROM users WHERE id = ?", [user["id"]])
|
||||
assert row["jwt_token"] is None
|
||||
|
||||
def test_refresh_returns_usable_token(self, client, auth):
|
||||
r = client.post("/api/auth/refresh", headers=auth)
|
||||
assert r.status_code == 200
|
||||
new_token = r.get_json()["token"]
|
||||
assert (
|
||||
client.get(
|
||||
"/api/health/summary",
|
||||
headers={"Authorization": f"Bearer {new_token}"},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_refresh_requires_auth(self, client):
|
||||
assert client.post("/api/auth/refresh").status_code == 401
|
||||
Reference in New Issue
Block a user