| """``AuthService`` 的单元测试(任务 6)。 |
| |
| 覆盖需求: |
| - 5.4:加盐哈希可校验;存储格式为 ``salt$hash``,非无盐 SHA-256;同一密码两次 |
| 哈希不同(随机盐);兼容旧无盐记录校验。 |
| - 5.1:未设置 ``ADMIN_INIT_PASSWORD`` 时不创建默认账户,且日志无明文密码; |
| 已设置时以加盐哈希创建管理员,且日志 / 哈希均不泄露明文密码。 |
| - 15.5:角色隔离 ``can_view_audit`` / ``visible_skills`` 按 admin / researcher / |
| auditor 区分。 |
| |
| 测试自包含:用注入的假存储(admin_exists / create_admin)完全替换数据库依赖, |
| 不触网、不写盘,可在 CI 与离线环境稳定运行。导入路径由 tests/conftest.py 设置, |
| 底座以顶层包 ``services`` 导入(与 test_llm_service.py 一致)。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import logging |
|
|
| import pytest |
|
|
| from services.auth_service import ( |
| AuthService, |
| hash_password, |
| verify_password, |
| is_legacy_hash, |
| ROLE_ADMIN, |
| ROLE_RESEARCHER, |
| ROLE_AUDITOR, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def test_hash_then_verify_roundtrip(): |
| """正确密码校验通过,错误密码校验失败。""" |
| stored = hash_password("S3cret-pw") |
| assert verify_password("S3cret-pw", stored) is True |
| assert verify_password("wrong-pw", stored) is False |
|
|
|
|
| def test_stored_format_is_salt_dollar_hash(): |
| """存储格式为 ``salt$hash``:含单个 ``$`` 分隔,盐与哈希均为十六进制。""" |
| stored = hash_password("pw") |
| assert stored.count("$") == 1 |
| salt_hex, hash_hex = stored.split("$") |
| assert salt_hex and hash_hex |
| |
| int(salt_hex, 16) |
| int(hash_hex, 16) |
|
|
|
|
| def test_stored_is_not_bare_unsalted_sha256(): |
| """存储值不得是无盐 SHA-256(64 位十六进制、无 ``$``)。""" |
| stored = hash_password("pw") |
| assert "$" in stored |
| bare = hashlib.sha256("pw".encode("utf-8")).hexdigest() |
| assert stored != bare |
| assert not is_legacy_hash(stored) |
|
|
|
|
| def test_same_password_hashes_differ_due_to_random_salt(): |
| """同一密码两次哈希结果不同(随机盐),但都能各自校验通过。""" |
| h1 = hash_password("same-pw") |
| h2 = hash_password("same-pw") |
| assert h1 != h2 |
| assert verify_password("same-pw", h1) is True |
| assert verify_password("same-pw", h2) is True |
|
|
|
|
| def test_explicit_salt_is_deterministic(): |
| """显式传盐时哈希确定(便于测试),不同盐得到不同哈希。""" |
| h1 = hash_password("pw", salt="00" * 16) |
| h2 = hash_password("pw", salt="00" * 16) |
| h3 = hash_password("pw", salt="11" * 16) |
| assert h1 == h2 |
| assert h1 != h3 |
| assert verify_password("pw", h1) is True |
|
|
|
|
| |
| |
| |
|
|
| def test_legacy_unsalted_sha256_still_verifies(): |
| """旧无盐 SHA-256 哈希在新校验逻辑下仍可通过 / 拒绝。""" |
| legacy = hashlib.sha256("legacy-pw".encode("utf-8")).hexdigest() |
| assert is_legacy_hash(legacy) is True |
| assert verify_password("legacy-pw", legacy) is True |
| assert verify_password("nope", legacy) is False |
|
|
|
|
| def test_verify_rejects_empty_and_malformed(): |
| """空值与畸形存储值安全拒绝,不抛异常。""" |
| assert verify_password("pw", "") is False |
| assert verify_password("pw", None) is False |
| assert verify_password("pw", "$abc") is False |
| assert verify_password("pw", "nothex$nothex") is False |
|
|
|
|
| |
| |
| |
|
|
| def test_can_view_audit_by_role(): |
| """审计区可见性:管理员与审计员可见,研发人员不可见。""" |
| svc = AuthService() |
| assert svc.can_view_audit(ROLE_ADMIN) is True |
| assert svc.can_view_audit(ROLE_AUDITOR) is True |
| assert svc.can_view_audit(ROLE_RESEARCHER) is False |
|
|
|
|
| def test_can_run_analysis_by_role(): |
| """分析执行权限:管理员与研发人员可执行,审计员只读不可执行。""" |
| svc = AuthService() |
| assert svc.can_run_analysis(ROLE_ADMIN) is True |
| assert svc.can_run_analysis(ROLE_RESEARCHER) is True |
| assert svc.can_run_analysis(ROLE_AUDITOR) is False |
|
|
|
|
| def test_visible_skills_differ_by_role(): |
| """可见 Skill 因角色而异:admin/researcher 见全部分析 Skill,auditor 不见。""" |
| svc = AuthService(all_skill_ids=["stability", "compatibility", "general_qa"]) |
| admin_skills = svc.visible_skills(ROLE_ADMIN) |
| researcher_skills = svc.visible_skills(ROLE_RESEARCHER) |
| auditor_skills = svc.visible_skills(ROLE_AUDITOR) |
|
|
| assert admin_skills == {"stability", "compatibility", "general_qa"} |
| assert researcher_skills == {"stability", "compatibility", "general_qa"} |
| assert auditor_skills == set() |
| |
| assert auditor_skills != researcher_skills |
|
|
|
|
| def test_legacy_user_role_treated_as_researcher(): |
| """历史 ``user`` 角色与未知角色按最小权限归一化为研发人员。""" |
| svc = AuthService() |
| assert svc.normalize_role("user") == ROLE_RESEARCHER |
| assert svc.normalize_role(None) == ROLE_RESEARCHER |
| assert svc.normalize_role("unknown") == ROLE_RESEARCHER |
| assert svc.can_view_audit("user") is False |
| assert svc.is_admin("user") is False |
|
|
|
|
| |
| |
| |
|
|
| def test_init_admin_skips_when_password_unset(caplog): |
| """未设置 ADMIN_INIT_PASSWORD 时不创建账户,日志提示「管理员未初始化」。""" |
| svc = AuthService() |
| created = [] |
|
|
| def admin_exists(): |
| return False |
|
|
| def create_admin(email, password_hash): |
| created.append((email, password_hash)) |
|
|
| with caplog.at_level(logging.DEBUG, logger="services.auth_service"): |
| result = svc.init_admin( |
| admin_exists=admin_exists, |
| create_admin=create_admin, |
| env={}, |
| ) |
|
|
| assert result is False |
| assert created == [] |
| assert any("管理员未初始化" in r.getMessage() for r in caplog.records) |
|
|
|
|
| def test_init_admin_creates_salted_hash_when_password_set(caplog): |
| """设置 ADMIN_INIT_PASSWORD 时以加盐哈希创建管理员,日志不含明文密码。""" |
| secret = "Env-Admin-Pw-12345" |
| svc = AuthService(admin_email="admin") |
| created = [] |
|
|
| def admin_exists(): |
| return False |
|
|
| def create_admin(email, password_hash): |
| created.append((email, password_hash)) |
|
|
| with caplog.at_level(logging.DEBUG, logger="services.auth_service"): |
| result = svc.init_admin( |
| admin_exists=admin_exists, |
| create_admin=create_admin, |
| env={"ADMIN_INIT_PASSWORD": secret}, |
| ) |
|
|
| assert result is True |
| assert len(created) == 1 |
| email, stored = created[0] |
| assert email == "admin" |
| |
| assert "$" in stored and not is_legacy_hash(stored) |
| assert verify_password(secret, stored) is True |
| |
| for record in caplog.records: |
| assert secret not in record.getMessage() |
| assert secret not in stored |
|
|
|
|
| def test_init_admin_skips_when_admin_exists(caplog): |
| """管理员已存在时跳过创建(即便设置了环境变量)。""" |
| svc = AuthService() |
| created = [] |
|
|
| with caplog.at_level(logging.DEBUG, logger="services.auth_service"): |
| result = svc.init_admin( |
| admin_exists=lambda: True, |
| create_admin=lambda e, h: created.append((e, h)), |
| env={"ADMIN_INIT_PASSWORD": "whatever"}, |
| ) |
|
|
| assert result is False |
| assert created == [] |
|
|
|
|
| def test_no_password_leaks_in_any_init_log(caplog): |
| """综合断言:初始化全流程日志中不出现任何明文密码(需求 5.2)。""" |
| secret = "Sensitive-PLAINTEXT-PW" |
| svc = AuthService() |
| with caplog.at_level(logging.DEBUG, logger="services.auth_service"): |
| svc.init_admin( |
| admin_exists=lambda: False, |
| create_admin=lambda e, h: None, |
| env={"ADMIN_INIT_PASSWORD": secret}, |
| ) |
| for record in caplog.records: |
| assert secret not in record.getMessage() |
|
|
|
|
| if __name__ == "__main__": |
| import sys |
| sys.exit(pytest.main([__file__, "-v"])) |
|
|