"""Regression test for the software_seat cancel cascade. Bug (reported 2026-05-06): an employee whose Feishu seat-request was cancelled (employee_request.status='cancelled', approval_request.status= 'cancelled') still couldn't re-apply for the same plan because the linked ``software_seat_allocations`` row sat at ``status='pending'`` forever — ``check_no_active_seat_request`` saw it and returned 409 ``DUPLICATE_SEAT_REQUEST`` ("You already have a pending seat for X"). Fix: ``cancel_employee_request`` now mirrors the Feishu-callback ``_revoke_pending_seat_on_rejection`` cascade — when ``request_type == 'software_seat'`` it flips the linked allocation to ``status='revoked'`` with ``revoked_at = allocated_at`` (so the row's billable window collapses to zero — the seat was never actually live). """ from __future__ import annotations import uuid from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.approval.models import ApprovalRequest from app.domains.asset.models import ( AssetEmployeeRequest, AssetSoftwareCatalog, SoftwareLicensePlan, SoftwareSeatAllocation, ) _FAKE_FEISHU_CFG = { "app_id": "cli_test", "app_secret": "secret_test", "event_verification_token": "test-feishu-token", } @pytest.fixture(autouse=True) def _patch_feishu_config(monkeypatch): """Stub Feishu config + token so cancel_instance round-trips locally.""" async def _fake_cfg(**_kwargs): return dict(_FAKE_FEISHU_CFG) monkeypatch.setattr("app.integrations.compat.get_feishu_config", _fake_cfg) monkeypatch.setattr("app.domains.asset.service.get_feishu_config", _fake_cfg) async def _fake_token(self): return "fake-tenant-token" monkeypatch.setattr( "app.approval.feishu_client.FeishuApprovalClient.get_tenant_access_token", _fake_token, ) async def _seed_plan(db: AsyncSession) -> SoftwareLicensePlan: sw = AssetSoftwareCatalog( id=uuid.uuid4(), name=f"sw-{uuid.uuid4().hex[:6]}", asset_type="SaaS", status="active", ) db.add(sw) await db.flush() plan = SoftwareLicensePlan( id=uuid.uuid4(), software_id=sw.id, name="Premium", price=Decimal("10"), billing_cycle="monthly", status="active", ) db.add(plan) await db.flush() return plan @pytest.mark.asyncio async def test_cancel_employee_request_revokes_pending_seat_allocation( client: AsyncClient, db_session: AsyncSession, employee_user, employee_headers, ): """Cancelling a Feishu-backed software_seat employee_request must flip the linked SoftwareSeatAllocation from pending → revoked, so a follow-up request for the same plan is no longer blocked by the per-(user, plan) dedup guard. """ employee, _ = employee_user plan = await _seed_plan(db_session) allocated_at = datetime.now(UTC) seat = SoftwareSeatAllocation( plan_id=plan.id, user_id=employee.id, status="pending", allocated_at=allocated_at, reason="Need premium for Q3 work", ) db_session.add(seat) await db_session.flush() employee_request = AssetEmployeeRequest( request_number="REQ-TEST-SEAT-CANCEL", request_type="software_seat", status="pending", requester_id=employee.id, reason="Need premium for Q3 work", ) db_session.add(employee_request) await db_session.flush() db_session.add( ApprovalRequest( external_id="feishu_seat_cancel_test", approval_type="software_seat", applicant_id=employee.id, status="pending", source_payload={ "employee_request_id": str(employee_request.id), "seat_allocation_id": str(seat.id), "approval_code": "FAKE-SOFTWARE-SEAT-CODE", }, ) ) await db_session.commit() # User clicks "Cancel" on /my requests → Feishu cancel API is best- # effort; mock it to a no-op so the local cascade is exercised # cleanly. with ( patch( "app.approval.feishu_client.FeishuApprovalClient.cancel_instance", new=AsyncMock(return_value=None), ), patch( "app.approval.feishu_client.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="fake-feishu-user"), ), ): resp = await client.post( f"/api/v1/assets/employee-requests/{employee_request.id}/cancel", headers=employee_headers, ) assert resp.status_code == 200, resp.text await db_session.refresh(employee_request) await db_session.refresh(seat) assert employee_request.status == "cancelled" # Critical: the seat row must have moved out of pending. assert seat.status == "revoked", ( "Seat allocation left in pending — the dedup guard " "(check_no_active_seat_request) will block re-requests." ) # And revoked_at must equal allocated_at so the row's billable # window collapses to zero (mirror of _revoke_pending_seat_on_rejection). assert seat.revoked_at == seat.allocated_at @pytest.mark.asyncio async def test_cancel_does_not_touch_seats_owned_by_other_users( client: AsyncClient, db_session: AsyncSession, employee_user, employee_headers, ): """Defence-in-depth: if (somehow) a malicious source_payload pointed at another user's seat, the cancel cascade must refuse to revoke it. Mirrors the ownership check in _revoke_pending_seat_on_rejection. """ from app.identity.person_models import IdentityPerson employee, _ = employee_user plan = await _seed_plan(db_session) other = IdentityPerson( id=uuid.uuid4(), work_email=f"other-{uuid.uuid4().hex[:6]}@x.com", display_name="Other", employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:6]}", ) db_session.add(other) await db_session.flush() other_seat = SoftwareSeatAllocation( plan_id=plan.id, user_id=other.id, # NOT the canceller status="pending", allocated_at=datetime.now(UTC), ) db_session.add(other_seat) await db_session.flush() employee_request = AssetEmployeeRequest( request_number="REQ-TEST-SEAT-XUSER", request_type="software_seat", status="pending", requester_id=employee.id, reason="probe", ) db_session.add(employee_request) await db_session.flush() db_session.add( ApprovalRequest( external_id="feishu_seat_xuser_test", approval_type="software_seat", applicant_id=employee.id, status="pending", source_payload={ "employee_request_id": str(employee_request.id), "seat_allocation_id": str(other_seat.id), # mismatch "approval_code": "FAKE", }, ) ) await db_session.commit() with ( patch( "app.approval.feishu_client.FeishuApprovalClient.cancel_instance", new=AsyncMock(return_value=None), ), patch( "app.approval.feishu_client.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="fake-feishu-user"), ), ): resp = await client.post( f"/api/v1/assets/employee-requests/{employee_request.id}/cancel", headers=employee_headers, ) assert resp.status_code == 200, resp.text await db_session.refresh(other_seat) # Other user's seat must NOT have been revoked. assert other_seat.status == "pending" assert other_seat.revoked_at is None