"""逻辑扫描:整套东西的业务不变量,跨模块地钉住。 上面每个文件钉的是某一个模块的行为;这里钉的是**几个模块之间必须同时 成立**的那些性质 —— 那正是加一个消费方时最容易被打破的地方。 """ from __future__ import annotations import pytest from app.domains.github_org import constants as const pytestmark = pytest.mark.asyncio # ── 词表的自洽 ─────────────────────────────────────────────────── async def test_every_state_has_a_default_event(): """状态迁移必须落事件 —— 事件流是访问复核唯一的证据来源。 一个没有默认事件的状态会静默落成 ``reconciled``,让时间线说谎。""" from app.domains.github_org.service import _default_event_for_state fallback = const.EVENT_RECONCILED unmapped = [ s for s in const.MEMBERSHIP_STATES if _default_event_for_state(s) == fallback ] assert unmapped == [], f"这些状态没有自己的事件: {unmapped}" async def test_live_states_are_exactly_the_ones_that_hold_access(): """``MEMBERSHIP_LIVE_STATES`` 是离职回收、对账、自助面共用的那个集合。 多一个 → 回收会去撤一个不存在的成员资格;少一个 → 留一个后门。 """ assert set(const.MEMBERSHIP_LIVE_STATES) == { const.MEMBERSHIP_STATE_PENDING_INVITE, const.MEMBERSHIP_STATE_AWAITING_MANUAL, const.MEMBERSHIP_STATE_ACTIVE, } terminal = set(const.MEMBERSHIP_STATES) - set(const.MEMBERSHIP_LIVE_STATES) assert terminal == { const.MEMBERSHIP_STATE_REMOVED, const.MEMBERSHIP_STATE_DECLINED, const.MEMBERSHIP_STATE_EXPIRED, } async def test_state_values_fit_the_column(): """列宽 24 —— 加一个更长的状态值会 1406 或静默截断。""" assert max(len(s) for s in const.MEMBERSHIP_STATES) <= 24 events = [ v for k, v in vars(const).items() if k.startswith("EVENT_") and isinstance(v, str) ] assert max(len(e) for e in events) <= 32 # ── 跨模块:离职回收的完整性 ───────────────────────────────────── async def test_offboarding_knows_this_resource_type(): """加了扫描却漏登记 severity,会 fail-closed 让**每个**离职者都出现 在 IT 的红队列里。""" from app.domains.offboarding.models import RES_GITHUB_ORG_MEMBERSHIP from app.domains.offboarding.service import ( SEVERITY_ADVISORY, severity_for_resource, ) assert severity_for_resource(RES_GITHUB_ORG_MEMBERSHIP) == SEVERITY_ADVISORY async def test_people_bound_access_is_revoked_at_t0(): from app.domains.offboarding.models import RES_GITHUB_ORG_MEMBERSHIP from app.hr_lifecycle.advance_service import ( IMMEDIATE_REVOKE_RESOURCE_TYPES, ) assert RES_GITHUB_ORG_MEMBERSHIP in IMMEDIATE_REVOKE_RESOURCE_TYPES async def test_scan_and_preview_use_the_same_exemption_rule(): """预览与实际扫描必须逐条对齐,**包括豁免**。少一行,运维在按下 「标记离职」之前看到的就是假的。 这里用源码比对而不是行为比对:行为比对已经在 test_offboarding 里, 这一条钉的是「两段代码没有各自漂移」—— 它们是分开的两处实现。 """ import pathlib src = pathlib.Path("app/domains/offboarding/service.py").read_text() assert src.count("OFFBOARDING_POLICY_EXEMPT") >= 2, ( "扫描或预览里少了豁免判断" ) assert src.count("MEMBERSHIP_LIVE_STATES") >= 2, ( "扫描或预览里少了活跃状态过滤" ) # ── 跨模块:两个 cron 的边界不重叠 ─────────────────────────────── async def test_the_two_reconcilers_do_not_fight_over_pending_invite(): """邀请对账按**时间**推进 pending;成员对账按**快照**对齐名单。 一次成员快照分不出「本人拒绝了」和「过了 7 天」,所以成员对账必须 把 pending 让给邀请对账 —— 否则它会凭一次快照就把一条正常等待中的 邀请判成已移除。 """ import pathlib rec = pathlib.Path("app/domains/github_org/reconcile.py").read_text() assert "MEMBERSHIP_STATE_PENDING_INVITE" in rec assert "MEMBERSHIP_STATE_AWAITING_MANUAL" in rec life = pathlib.Path( "app/domains/github_org/invitation_lifecycle.py" ).read_text() # 邀请对账只查 pending_invite,绝不碰 awaiting_manual。 assert "== const.MEMBERSHIP_STATE_PENDING_INVITE" in life async def test_both_crons_are_registered(): from app.scheduled_jobs.registry import _HANDLERS for key in ( "github_org.invitation_reconcile", "github_org.member_reconcile", ): assert key in _HANDLERS, f"{key} 没注册 —— cron 行会报 unknown handler" async def test_every_cron_trace_id_fits_the_event_column(): """🔴 这个域的 cron 用 ``f"{job_key}:{uuid4()}"`` 当 trace_id,而那个 字符串会**原样**写进 ``github_org_membership_events.trace_id``。 列宽曾经是 64,而 ``github_org.invitation_reconcile:`` 是 68 —— 于是每一次真实的状态推进都在 flush 上被 1406 顶回来,报出来的却是 下游的 PendingRollbackError,真因要挖两层。 按注册表逐个量,而不是写死那两三个 key:下一个 cron 起什么名字, 这条不变量事先并不知道,但它必须一样管得着。 """ import uuid from app.domains.github_org.models import GithubOrgMembershipEvent from app.scheduled_jobs.registry import _HANDLERS width = GithubOrgMembershipEvent.__table__.c.trace_id.type.length keys = [k for k in _HANDLERS if k.startswith("github_org.")] assert keys, "一个 github_org cron 都没注册,这条不变量在空转" for key in keys: produced = f"{key}:{uuid.uuid4()}" assert len(produced) <= width, ( f"{key} 的 trace_id 有 {len(produced)} 个字符,而事件表的 " f"trace_id 只有 {width} —— 落库时会被 1406 拒掉" ) # ── 跨模块:每个 API 面都有闸 ──────────────────────────────────── async def test_every_admin_endpoint_is_gated(): """加一个端点忘了加闸,是这类路由最容易犯的错。""" import pathlib import re src = pathlib.Path("app/domains/github_org/router.py").read_text() blocks = re.split(r"\n@router\.", src)[1:] ungated = [] for b in blocks: head = b.split("\n")[0] body = b.split("\n\n\n")[0] if "_require_manage(user)" not in body and "_require_view(user)" not in body: ungated.append(head) assert ungated == [], f"这些端点没有权限闸: {ungated}" async def test_every_self_service_endpoint_scopes_to_the_caller(): import pathlib import re src = pathlib.Path("app/domains/github_org/self_router.py").read_text() blocks = re.split(r"\n@router\.", src)[1:] unscoped = [] for b in blocks: head = b.split("\n")[0] body = b.split("\n\n\n")[0] if "_person(db, user)" not in body: unscoped.append(head) assert unscoped == [], f"这些自助端点没有按本人收窄: {unscoped}"