"""GitHub ID 自助修改:提交闸、审批闸、落地顺序,以及那道防回滚闸。 最重要的一条在文件末尾:``test_hr_sync_does_not_revert_a_self_service_change``。 HR 同步对 ``github_id`` 是 write-when-present,所以「Helix 改了、SF 还没改」 这个中间态一旦不设防,下一次同步会把员工刚改的值**静默改回去** —— 没有 通知、没有报错,员工只会某天发现自己改的没了。这一条把那个场景钉住。 """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from app.core.exceptions import AppException, ValidationError from app.domains.github_org import constants as const from app.domains.github_org.handle_change_service import ( GithubIdChangeService, resolve_approver_id, ) from app.identity.person_models import IdentityPerson _async = pytest.mark.asyncio async def _person( db, *, github_id: str | None = None, status: str = "active", hrops: uuid.UUID | None = None, hrbp: uuid.UUID | None = None, employee_no: str | None = None, ) -> IdentityPerson: person = IdentityPerson( id=uuid.uuid4(), work_email=f"p{uuid.uuid4().hex[:8]}@test.com", display_name="Test Person", employment_status=status, source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", employee_no=employee_no or f"E{uuid.uuid4().hex[:6]}", github_id=github_id, hris_hrops_person_id=hrops, hris_hrbp_person_id=hrbp, ) db.add(person) await db.flush() return person def _silence_side_effects(monkeypatch, *, hris_status: str = "ok"): """把三个外部副作用替掉:GitHub 求证、SF 回写、飞书广播。 返回一个 dict,测试用它断言「到底调了什么」——而不是断言「有没有抛 异常」。SF 那一跳尤其重要:它是这条链路里唯一不可回滚的写。 """ calls: dict = {"hris": [], "broadcast": []} async def _fake_verify(_db, login): return True async def _fake_write(_db, *, person, github_id, trace_id=""): calls["hris"].append((str(person.id), github_id)) out = {"status": hris_status} if hris_status != "ok": out["reason"] = "SF said no" return out monkeypatch.setattr( "app.domains.github_org.handle_change_service.verify_handle_on_github", _fake_verify, ) monkeypatch.setattr( "app.core.coordinators.github_handle_change_coordinator" ".write_github_id_to_hris", _fake_write, ) monkeypatch.setattr( GithubIdChangeService, "broadcast_after_commit", staticmethod(lambda pid: calls["broadcast"].append(pid)), ) return calls # ── 审批人解析 ──────────────────────────────────────────────────── @_async async def test_approver_prefers_hr_ops_then_hrbp(db_session): ops = await _person(db_session) bp = await _person(db_session) person = await _person(db_session, hrops=ops.id, hrbp=bp.id) assert await resolve_approver_id(db_session, person) == str(ops.id) only_bp = await _person(db_session, hrbp=bp.id) assert await resolve_approver_id(db_session, only_bp) == str(bp.id) @_async async def test_approver_is_none_when_nobody_is_on_file(db_session): """生产上 417 人里有 4 个既没有 HR Ops 也没有 HRBP。 把这判成提交失败,等于让这 4 个人永远改不了自己的 handle —— 所以 ``None`` 是一个正常返回,由 HR 兜底队列接住。 """ person = await _person(db_session) assert await resolve_approver_id(db_session, person) is None @_async async def test_departed_approver_is_skipped(db_session): """指定的 HR Ops 已经离职 → 回退 HRBP,而不是把单子派给一个走了的人。""" gone = await _person(db_session, status="left") bp = await _person(db_session) person = await _person(db_session, hrops=gone.id, hrbp=bp.id) assert await resolve_approver_id(db_session, person) == str(bp.id) # ── 提交 ────────────────────────────────────────────────────────── @_async async def test_submit_normalises_and_records_old_value(db_session, monkeypatch): _silence_side_effects(monkeypatch) ops = await _person(db_session) person = await _person(db_session, github_id="old-handle", hrops=ops.id) row = await GithubIdChangeService(db_session).submit( requester=person, github_id=" https://github.com/NewHandle/ ", reason="换了个账号", trace_id="t", ) assert row.new_github_id == "NewHandle" assert row.old_github_id == "old-handle" assert row.approver_person_id == str(ops.id) assert row.status == const.HANDLE_CHANGE_STATUS_PENDING @_async async def test_submit_rejects_invalid_handle(db_session, monkeypatch): _silence_side_effects(monkeypatch) person = await _person(db_session) with pytest.raises(ValidationError): await GithubIdChangeService(db_session).submit( requester=person, github_id="-nope-", reason=None, trace_id="t", ) @_async async def test_submit_rejects_a_no_op_change(db_session, monkeypatch): """大小写不敏感 —— GitHub 的用户名就是不区分大小写的。""" _silence_side_effects(monkeypatch) person = await _person(db_session, github_id="octocat") with pytest.raises(AppException) as exc: await GithubIdChangeService(db_session).submit( requester=person, github_id="OctoCat", reason=None, trace_id="t", ) assert exc.value.code == "GITHUB_ID_UNCHANGED" @_async async def test_submit_rejects_a_handle_another_employee_holds( db_session, monkeypatch, ): """重复 handle 会让按 handle 反查人的两处一次拿到两个人。 ``service.resolve_person_by_login`` 和 ``reconcile`` 的返回形状都只容得 下一个人,于是台账会把成员资格接到错的人身上,离职时摘错账号。 """ _silence_side_effects(monkeypatch) await _person(db_session, github_id="taken") person = await _person(db_session) with pytest.raises(AppException) as exc: await GithubIdChangeService(db_session).submit( requester=person, github_id="TAKEN", reason=None, trace_id="t", ) assert exc.value.code == "GITHUB_ID_ALREADY_TAKEN" @_async async def test_a_left_employees_handle_does_not_block_a_new_one( db_session, monkeypatch, ): """占用判定只看在职的人 —— 否则一个离职三年的人会永久占着一个 handle。""" _silence_side_effects(monkeypatch) await _person(db_session, github_id="recycled", status="left") person = await _person(db_session) row = await GithubIdChangeService(db_session).submit( requester=person, github_id="recycled", reason=None, trace_id="t", ) assert row.new_github_id == "recycled" @_async async def test_only_one_pending_request_at_a_time(db_session, monkeypatch): _silence_side_effects(monkeypatch) person = await _person(db_session) svc = GithubIdChangeService(db_session) await svc.submit( requester=person, github_id="first", reason=None, trace_id="t", ) with pytest.raises(AppException) as exc: await svc.submit( requester=person, github_id="second", reason=None, trace_id="t", ) assert exc.value.code == "GITHUB_ID_REQUEST_PENDING" @_async async def test_leaver_cannot_submit(db_session, monkeypatch): _silence_side_effects(monkeypatch) person = await _person(db_session, status="leaver_pending") with pytest.raises(AppException) as exc: await GithubIdChangeService(db_session).submit( requester=person, github_id="octocat", reason=None, trace_id="t", ) assert exc.value.code == "GITHUB_ID_REQUESTER_INELIGIBLE" # ── 确认 / 驳回 ─────────────────────────────────────────────────── @_async async def test_approve_writes_hris_then_helix_then_broadcasts( db_session, monkeypatch, ): calls = _silence_side_effects(monkeypatch) ops = await _person(db_session) person = await _person(db_session, github_id="old", hrops=ops.id) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) decided, requester = await svc.decide( request_id=row.id, actor=ops, role_names=[], decision="approve", note=None, trace_id="t", ) assert decided.status == const.HANDLE_CHANGE_STATUS_APPROVED assert decided.hris_write_status == const.HANDLE_CHANGE_HRIS_OK # SF 收到的是新值,而且只调了一次。 assert calls["hris"] == [(str(person.id), "new")] assert requester.github_id == "new" # 闸在**成功**路径上也要落 —— 「SF 接受了写入」不等于「SF 已经把新值 # 吐回来了」,中间那次同步不能拿旧读把它冲掉。 assert requester.github_id_locked_at is not None @_async async def test_hris_failure_still_lands_the_change_and_is_recorded( db_session, monkeypatch, ): """SF 拒了不能回滚一个已经被确认的变更 —— 但必须留下可追的痕迹。""" calls = _silence_side_effects(monkeypatch, hris_status="failed") ops = await _person(db_session) person = await _person(db_session, github_id="old", hrops=ops.id) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) decided, requester = await svc.decide( request_id=row.id, actor=ops, role_names=[], decision="approve", note=None, trace_id="t", ) assert decided.status == const.HANDLE_CHANGE_STATUS_APPROVED assert decided.hris_write_status == const.HANDLE_CHANGE_HRIS_FAILED assert decided.hris_write_error == "SF said no" assert requester.github_id == "new" assert requester.github_id_locked_at is not None assert calls["hris"] @_async async def test_a_stranger_cannot_decide(db_session, monkeypatch): _silence_side_effects(monkeypatch) ops = await _person(db_session) stranger = await _person(db_session) person = await _person(db_session, hrops=ops.id) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) with pytest.raises(AppException) as exc: await svc.decide( request_id=row.id, actor=stranger, role_names=[], decision="approve", note=None, trace_id="t", ) assert exc.value.status_code == 403 @_async async def test_hr_can_decide_a_request_with_no_named_approver( db_session, monkeypatch, ): """没有 HR Ops / HRBP 的那 4 个人,他们的单子由兜底角色接住。""" _silence_side_effects(monkeypatch) hr = await _person(db_session) person = await _person(db_session) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) assert row.approver_person_id is None decided, _ = await svc.decide( request_id=row.id, actor=hr, role_names=["hr"], decision="approve", note=None, trace_id="t", ) assert decided.status == const.HANDLE_CHANGE_STATUS_APPROVED @_async async def test_nobody_approves_their_own_request(db_session, monkeypatch): """四眼。一个人后来成了自己的 HR Ops(或本来就是 HR)也不行。""" _silence_side_effects(monkeypatch) person = await _person(db_session) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) with pytest.raises(AppException) as exc: await svc.decide( request_id=row.id, actor=person, role_names=["hr"], decision="approve", note=None, trace_id="t", ) assert exc.value.code == "GITHUB_ID_SELF_APPROVAL_FORBIDDEN" @_async async def test_handle_taken_between_submit_and_approval_is_caught( db_session, monkeypatch, ): """提交到审批可能隔了几天 —— 占用要在写 SF **之前**重查一次。""" calls = _silence_side_effects(monkeypatch) ops = await _person(db_session) person = await _person(db_session, hrops=ops.id) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="contested", reason=None, trace_id="t", ) # 另一个人在这期间登记了同一个 handle。 await _person(db_session, github_id="contested") with pytest.raises(AppException) as exc: await svc.decide( request_id=row.id, actor=ops, role_names=[], decision="approve", note=None, trace_id="t", ) assert exc.value.code == "GITHUB_ID_ALREADY_TAKEN" assert calls["hris"] == [] # SF 一次都没被写 @_async async def test_reject_changes_nothing(db_session, monkeypatch): calls = _silence_side_effects(monkeypatch) ops = await _person(db_session) person = await _person(db_session, github_id="old", hrops=ops.id) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) decided, requester = await svc.decide( request_id=row.id, actor=ops, role_names=[], decision="reject", note="不是你的账号", trace_id="t", ) assert decided.status == const.HANDLE_CHANGE_STATUS_REJECTED assert decided.decision_note == "不是你的账号" assert requester.github_id == "old" assert requester.github_id_locked_at is None assert calls["hris"] == [] @_async async def test_decided_request_cannot_be_decided_again( db_session, monkeypatch, ): _silence_side_effects(monkeypatch) ops = await _person(db_session) person = await _person(db_session, hrops=ops.id) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) await svc.decide( request_id=row.id, actor=ops, role_names=[], decision="reject", note=None, trace_id="t", ) with pytest.raises(AppException) as exc: await svc.decide( request_id=row.id, actor=ops, role_names=[], decision="approve", note=None, trace_id="t", ) assert exc.value.code == "GITHUB_ID_REQUEST_NOT_PENDING" @_async async def test_cancel_is_requester_only_and_pending_only( db_session, monkeypatch, ): _silence_side_effects(monkeypatch) other = await _person(db_session) person = await _person(db_session) svc = GithubIdChangeService(db_session) row = await svc.submit( requester=person, github_id="new", reason=None, trace_id="t", ) with pytest.raises(AppException) as exc: await svc.cancel(request_id=row.id, requester=other, trace_id="t") assert exc.value.status_code == 403 await svc.cancel(request_id=row.id, requester=person, trace_id="t") assert row.status == const.HANDLE_CHANGE_STATUS_CANCELLED # 撤回之后可以重提 —— ``open_request_key`` 只在 pending 时非 NULL。 again = await svc.submit( requester=person, github_id="third", reason=None, trace_id="t", ) assert again.status == const.HANDLE_CHANGE_STATUS_PENDING # ── 防回滚闸(整条链路里最容易静默出错的一环) ────────────────── def _apply_hris_github_value(person: IdentityPerson, sf_value: str) -> None: """把 ``hr/service.py::_apply_event`` 里那段 github_id 的判定原样复刻。 刻意复刻而不是跑整个 ``_apply_event``:那个方法要一整套 SF 事件、 部门/主管解析和 master-data 缓存才能跑,而这里要钉住的是**那 6 行的 判定语义**。复刻会漂移,所以下面另有一条测试直接扫源码,确保真实实现 里那三个分支还在。 """ locked = person.github_id_locked_at is not None same = (sf_value or "").strip().lower() == ( person.github_id or "" ).strip().lower() if not locked or same: person.github_id = sf_value if locked: person.github_id_locked_at = None @_async async def test_hr_sync_does_not_revert_a_self_service_change(db_session): """🔴 核心用例。 员工把 handle 改成 ``new``、HR Ops 批了、SF 那一跳还没落地。此时一次 全量同步跑过来,SF 仍然报旧值 ``old``。没有闸的话这里会静默写回 ``old`` —— 员工不会收到任何提示。 """ person = await _person(db_session, github_id="new") person.github_id_locked_at = datetime.now(UTC) _apply_hris_github_value(person, "old") assert person.github_id == "new" assert person.github_id_locked_at is not None @_async async def test_lock_clears_once_hris_reports_the_same_value(db_session): """SF 收敛之后必须**交还**权威 —— 否则 HR 以后在 SF 里改这个字段, Helix 会永远无视它。""" person = await _person(db_session, github_id="new") person.github_id_locked_at = datetime.now(UTC) _apply_hris_github_value(person, "New") # GitHub handle 不区分大小写 assert person.github_id == "New" assert person.github_id_locked_at is None @_async async def test_unlocked_person_keeps_the_original_write_when_present( db_session, ): """没有闸的人一行行为都不能变 —— 存量 263 个有 handle 的人走这条。""" person = await _person(db_session, github_id="old") _apply_hris_github_value(person, "from-sf") assert person.github_id == "from-sf" assert person.github_id_locked_at is None def test_the_real_sync_still_has_all_three_branches(): """上面那三条用的是复刻实现;这条盯住真实实现没被改回去。 只断言那三件事在源码里出现过 —— 断言得更细会变成一条每次重构都要 跟着改的脆测试,而这里要防的是**整段判定被顺手删掉**。 """ from pathlib import Path src = ( Path(__file__).parents[2] / "app" / "domains" / "hr" / "service.py" ).read_text() marker = src.split("github_id_locked_at", 1) assert len(marker) > 1, "hr sync no longer consults the self-service lock" window = src[src.index("gh = self._github_ids.get"):][:1200] assert "person.github_id_locked_at is not None" in window assert "person.github_id_locked_at = None" in window assert "person.github_id = gh" in window