"""Tests for the Feishu software driver's new user_suspend mode. Complements the existing group_member coverage (implicitly tested via test_provisioning_binding.py). Mocks the inner FeishuApprovalClient calls so we exercise the driver's dispatch logic without real HTTP. """ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest from app.core.exceptions import AppException, NotFoundError from app.integrations.driver_base import ProvisioningContext from app.integrations.drivers.feishu_software import ( FEISHU_TAG_HARD_DELETE_REFUSED, FeishuSoftwareDriver, ) _CONFIG = {"app_id": "cli_fake", "app_secret": "secret-fake"} def _driver() -> FeishuSoftwareDriver: return FeishuSoftwareDriver(integration_id="feishu-test", config=_CONFIG) def _patch_client(resolve_return=None, resolve_raise=None, request_return=None, request_raise=None): """Return an AsyncMock FeishuApprovalClient instance with configured behaviour on resolve_user_id_by_email and _request. """ client_mock = AsyncMock() if resolve_raise is not None: client_mock.resolve_user_id_by_email.side_effect = resolve_raise else: client_mock.resolve_user_id_by_email.return_value = resolve_return if request_raise is not None: client_mock._request.side_effect = request_raise else: client_mock._request.return_value = request_return return client_mock @pytest.mark.asyncio async def test_metadata_has_user_suspend_option() -> None: meta = FeishuSoftwareDriver.metadata() op_field = next(f for f in meta.binding_fields if f.key == "operation") option_values = {o["value"] for o in op_field.options} assert "group_member" in option_values assert "user_suspend" in option_values @pytest.mark.asyncio async def test_operation_default_is_user_suspend() -> None: """Default for new bindings is real freeze, NOT read-only mirror. Regression for the 2026-05-30 prod incident where pan.liu's revoke task hard-errored because the binding had been created on the historical default ``tenant_users`` (which intentionally hard-errors on grant/revoke to surface read-only misconfiguration). All four operations must remain selectable — ``tenant_users`` is still a valid opt-in for billing-mirror-only deployments where another channel (Feishu HR / third-party IDP) owns account lifecycle. """ meta = FeishuSoftwareDriver.metadata() op_field = next(f for f in meta.binding_fields if f.key == "operation") assert op_field.default == "user_suspend", ( "operation default must be user_suspend so new Feishu bindings " "do real PATCH freeze on offboarding revoke; tenant_users is " "read-only and would hard-error any revoke attempt." ) option_values = {o["value"] for o in op_field.options} assert option_values == { "user_suspend", "group_member", "tenant_users", "user_create", }, ( "All four operation modes must stay selectable — dropping " "tenant_users would break billing-mirror-only deployments." ) # Order pins the recommended mode to position 0 — Antd Select # surfaces the first option's label as the placeholder hint, and # the field's ``default`` only fills in if the form is opened # programmatically. Either way "user_suspend" needs to be the # first thing a human sees on a fresh binding. assert op_field.options[0]["value"] == "user_suspend" @pytest.mark.asyncio async def test_missing_operation_fails_loud() -> None: """Production-grade strictness: a binding row that bypasses the admin UI (MCP / direct DB / legacy migration) and omits the required ``operation`` field must FAIL LOUD, not silently fall back to a hard-coded default. Both the previous (``tenant_users``) and the new (``user_suspend``) defaults would be misleading here: the operator has not made a choice, and the driver must say so. """ ctx = ProvisioningContext(user_email="x@example.com", extra={}) drv = _driver() grant = await drv.grant_seat(ctx) revoke = await drv.revoke_seat(ctx) for r, label in ((grant, "grant"), (revoke, "revoke")): assert r.ok is False, f"{label} must fail when operation is missing" assert r.error_message is not None assert "missing required 'operation'" in r.error_message, ( f"{label} error must name the missing field explicitly so " f"operators can fix the binding; got: {r.error_message!r}" ) # Error must list every valid value the driver currently accepts. # Loop the live ``_VALID_OPERATIONS`` set rather than a hardcoded # tuple so a future PR that adds a fifth mode (and forgets to # update the error template) trips this assertion. for v in FeishuSoftwareDriver._VALID_OPERATIONS: assert v in r.error_message, ( f"{label} error should list every valid operation; " f"missing {v!r} in: {r.error_message!r}" ) @pytest.mark.asyncio async def test_unknown_operation_fails_loud() -> None: """Strictness mirror: an explicitly wrong value (typo, stale legacy enum, MCP client error) still surfaces a clear error naming every valid value — never silently degrades.""" ctx = ProvisioningContext( user_email="x@example.com", extra={"operation": "user_freeze"}, # plausible typo of user_suspend ) drv = _driver() for fn in (drv.grant_seat, drv.revoke_seat): r = await fn(ctx) assert r.ok is False assert "unknown 'operation'" in (r.error_message or "") assert "'user_freeze'" in (r.error_message or "") assert "user_suspend" in (r.error_message or "") # ── Dispatch: grant ────────────────────────────────────────────── @pytest.mark.asyncio async def test_grant_group_member_still_works() -> None: """Regression: adding user_suspend mode didn't break group_member.""" client = _patch_client(resolve_return="ou_fake_user", request_return=None) ctx = ProvisioningContext( user_email="alice@example.com", extra={"operation": "group_member", "group_chat_id": "oc_abc"}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is True assert "oc_abc:alice@example.com" in result.external_ref # Verify the /group/.../member/add path was called called_path = client._request.await_args.args[1] assert "/group/oc_abc/member/add" in called_path @pytest.mark.asyncio async def test_grant_user_suspend_reactivates() -> None: client = _patch_client(resolve_return="ou_bob", request_return=None) ctx = ProvisioningContext( user_email="bob@example.com", extra={"operation": "user_suspend"}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is True assert result.raw_response == {"reactivated": True} # PATCH /contact/v3/users/{id} method, path = ( client._request.await_args.args[0], client._request.await_args.args[1], ) assert method == "PATCH" assert "/contact/v3/users/ou_bob" in path @pytest.mark.asyncio async def test_grant_user_suspend_no_feishu_record_is_noop_success() -> None: """User not present in Feishu → no-op success (HRIS creates on rehire).""" client = _patch_client( resolve_raise=NotFoundError("user", "email"), ) ctx = ProvisioningContext( user_email="rehire@example.com", extra={"operation": "user_suspend"}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is True assert result.raw_response == {"no_feishu_record": True} # ── Dispatch: revoke ───────────────────────────────────────────── @pytest.mark.asyncio async def test_revoke_user_suspend_freezes_user_by_default() -> None: """Default revoke for user_suspend mode now PATCHes is_frozen=true (preserves account, files, chat, retention window). Hard DELETE is opt-in via ``hard_delete_on_offboard=True`` per DECISIONS 2026-05-05. """ client = _patch_client(resolve_return="ou_dead", request_return=None) ctx = ProvisioningContext( user_email="gone@example.com", extra={"operation": "user_suspend"}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is True assert result.raw_response == {"frozen": True} method, path = ( client._request.await_args.args[0], client._request.await_args.args[1], ) assert method == "PATCH" assert "/contact/v3/users/ou_dead" in path # Body must set is_frozen=true. json_body = client._request.await_args.kwargs.get("json_body") or {} assert json_body == {"status": {"is_frozen": True}} @pytest.mark.asyncio async def test_revoke_user_suspend_deletes_when_hard_delete_opted_in() -> None: """Compliance hard-delete path: when the binding sets ``hard_delete_on_offboard=True``, revoke uses DELETE /contact/v3/users (irreversible).""" client = _patch_client(resolve_return="ou_dead", request_return=None) ctx = ProvisioningContext( user_email="gone@example.com", extra={"operation": "user_suspend", "hard_delete_on_offboard": True}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is True assert result.raw_response == {"deleted": True} method, path = ( client._request.await_args.args[0], client._request.await_args.args[1], ) assert method == "DELETE" assert "/contact/v3/users/ou_dead" in path @pytest.mark.asyncio async def test_revoke_user_suspend_already_absent_on_resolve() -> None: """Feishu can't find the user → already_absent idempotent success.""" client = _patch_client(resolve_raise=NotFoundError("user", "email")) ctx = ProvisioningContext( user_email="ghost@example.com", extra={"operation": "user_suspend"}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is True # raw_response also carries ``code`` for forensic logging now; # tolerate the extra field rather than assert exact equality. assert result.raw_response.get("already_absent") is True # We short-circuited before calling _request assert client._request.await_count == 0 @pytest.mark.asyncio async def test_revoke_user_suspend_not_found_code_on_delete() -> None: """DELETE returns 404 — also collapses to already_absent success.""" client = _patch_client( resolve_return="ou_x", request_raise=AppException( code="NOT_FOUND", message="user not found", status_code=404, ), ) ctx = ProvisioningContext( user_email="x@example.com", extra={"operation": "user_suspend"}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is True # raw_response also carries ``code`` for forensic logging now; # tolerate the extra field rather than assert exact equality. assert result.raw_response.get("already_absent") is True # ── Round-1 offboarding hardening: hard-delete → freeze fallback ─ @pytest.mark.asyncio async def test_hard_delete_refused_falls_back_to_freeze() -> None: """CRITICAL Round-1 #1: when Feishu refuses the DELETE for a non-idempotent business reason (file ownership unresolved, dept freeze cascade), the driver MUST fall back to a reversible PATCH freeze so the offboarding completes (seat released, login disabled) instead of dead-lettering the task. The success result carries ``hard_delete_fallback=True`` so audit + compliance review can re-attempt the hard delete manually once the blocking condition is cleared. """ # First _request call (DELETE) raises with a non-idempotent code; # second _request call (PATCH freeze) succeeds. client = AsyncMock() client.resolve_user_id_by_email.return_value = "ou_blocked" delete_error = AppException( code="FEISHU_API_ERROR", message="Feishu API error 230006: user has unresolved file ownership", status_code=400, ) async def _request_side_effect(method, *args, **kwargs): if method == "DELETE": raise delete_error # PATCH freeze succeeds (returns whatever — we only care about # not-raising). return {"code": 0, "msg": "ok"} client._request.side_effect = _request_side_effect ctx = ProvisioningContext( user_email="blocked@example.com", extra={ "operation": "user_suspend", "hard_delete_on_offboard": True, }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is True assert (result.raw_response or {}).get("hard_delete_fallback") is True assert "230006" in ( (result.raw_response or {}).get("original_delete_error") or "" ) @pytest.mark.asyncio async def test_hard_delete_transient_does_not_fall_back_to_freeze() -> None: """The fallback MUST only fire on tagged business refusals — a transient (token revoked, transport blip, generic 500) should bubble as ok=False so the worker retries the original DELETE instead of silently switching to freeze (which would mask a real bug).""" client = AsyncMock() client.resolve_user_id_by_email.return_value = "ou_x" # Transient AppException without the FEISHU_HARD_DELETE_REFUSED # tag prefix — code is NOT in already-gone / not-found sets. transient_error = AppException( code="FEISHU_API_TRANSPORT", message="upstream gateway timeout", status_code=502, ) call_log: list[str] = [] async def _request_side_effect(method, *args, **kwargs): call_log.append(method) if method == "DELETE": raise transient_error return {"code": 0, "msg": "ok"} client._request.side_effect = _request_side_effect ctx = ProvisioningContext( user_email="x@example.com", extra={ "operation": "user_suspend", "hard_delete_on_offboard": True, }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) # The DELETE result should bubble up unchanged (transient error # message survives). No PATCH must fire. assert result.ok is False assert "PATCH" not in call_log assert call_log == ["DELETE"] @pytest.mark.asyncio async def test_hard_delete_emits_tag_when_refused() -> None: """Verify the inner ``_user_delete`` emits the FEISHU_HARD_DELETE_REFUSED tag on a business refusal — this is what the fallback wrapper detects to decide whether to fall back.""" client = AsyncMock() client.resolve_user_id_by_email.return_value = "ou_x" refusal = AppException( code="FEISHU_API_ERROR", message="Feishu API error 230007: dept freeze cascade required", status_code=400, ) client._request.side_effect = refusal ctx = ProvisioningContext(user_email="x@example.com") with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver()._user_delete(ctx) assert result.ok is False assert result.error_message.startswith( f"[{FEISHU_TAG_HARD_DELETE_REFUSED}]" ) assert ( (result.raw_response or {}).get("error_tag") == FEISHU_TAG_HARD_DELETE_REFUSED ) @pytest.mark.asyncio async def test_revoke_invalid_operation_is_error() -> None: client = _patch_client() ctx = ProvisioningContext( user_email="y@example.com", extra={"operation": "exterminate"}, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is False assert "unknown 'operation'" in result.error_message