"""催办 (urge) — POST /me/requests/urge + /approval/admin/overview/urge. Asserts: * Anonymous → 401 * Applicant urges own OPEN request → urged=True; repeat within 24h → urged=False reason=rateLimited (Redis-backed window) * Urging a TERMINAL request → urged=False reason=notOpen * Applicant urging someone else's request → 404 (owner-scoped) * Admin urges any open request via the overview endpoint → urged=True """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from app.domains.asset.models import EmployeeRequest def _emp(requester_id, status: str) -> EmployeeRequest: return EmployeeRequest( id=uuid.uuid4(), request_number=f"REQ-{uuid.uuid4().hex[:8]}", request_type="laptop_replacement", status=status, requester_id=requester_id, created_at=datetime.now(UTC), ) async def _give_manager(db_session, person): """Attach an active manager so urge has a recipient to notify.""" from app.identity.person_models import IdentityPerson mgr = IdentityPerson( id=uuid.uuid4(), work_email=f"mgr-{uuid.uuid4().hex[:8]}@test.local", display_name="Manager", employment_status="active", source_system="test", hris_person_id=f"sf-{uuid.uuid4().hex[:6]}", ) db_session.add(mgr) await db_session.flush() person.manager_person_id = mgr.id db_session.add(person) return mgr @pytest.mark.asyncio async def test_urge_requires_auth(client): resp = await client.post("/api/v1/me/requests/urge", json={"id": "employee_request:x"}) assert resp.status_code == 401 @pytest.mark.asyncio async def test_applicant_urge_then_rate_limited(client, employee_user, db_session): me, token = employee_user await _give_manager(db_session, me) req = _emp(me.id, "pending") db_session.add(req) await db_session.commit() db_session.expunge_all() headers = {"Authorization": f"Bearer {token}"} item_id = f"employee_request:{req.id}" r1 = await client.post("/api/v1/me/requests/urge", json={"id": item_id}, headers=headers) assert r1.status_code == 200 assert r1.json()["data"]["urged"] is True # Second urge within 24h is blocked by the Redis window. r2 = await client.post("/api/v1/me/requests/urge", json={"id": item_id}, headers=headers) assert r2.status_code == 200 d2 = r2.json()["data"] assert d2["urged"] is False assert d2["reason"] == "rateLimited" assert d2["last_urged_at"] @pytest.mark.asyncio async def test_urge_rate_limit_not_bypassable_by_uuid_casing( client, employee_user, db_session, ): # SECURITY regression: the 24h window must key on the NORMALISED row id, # not the raw request body. A different UUID encoding (upper-case) of the # SAME request must still be rate-limited — otherwise an owner could spam # managers + IT by re-encoding their own id. me, token = employee_user await _give_manager(db_session, me) req = _emp(me.id, "pending") db_session.add(req) await db_session.commit() db_session.expunge_all() headers = {"Authorization": f"Bearer {token}"} r1 = await client.post( "/api/v1/me/requests/urge", json={"id": f"employee_request:{req.id}"}, headers=headers, ) assert r1.json()["data"]["urged"] is True # Same request, UPPER-CASE uuid → resolves to the same row → must be # rate-limited, not a fresh notification. r2 = await client.post( "/api/v1/me/requests/urge", json={"id": f"employee_request:{str(req.id).upper()}"}, headers=headers, ) assert r2.json()["data"]["urged"] is False assert r2.json()["data"]["reason"] == "rateLimited" @pytest.mark.asyncio async def test_urge_terminal_request_rejected(client, employee_user, db_session): me, token = employee_user req = _emp(me.id, "cancelled") db_session.add(req) await db_session.commit() db_session.expunge_all() resp = await client.post( "/api/v1/me/requests/urge", json={"id": f"employee_request:{req.id}"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 d = resp.json()["data"] assert d["urged"] is False assert d["reason"] == "notOpen" @pytest.mark.asyncio async def test_applicant_cannot_urge_others_request( client, employee_user, admin_user, db_session, ): _, token = employee_user admin, _ = admin_user req = _emp(admin.id, "pending") # belongs to someone else db_session.add(req) await db_session.commit() db_session.expunge_all() resp = await client.post( "/api/v1/me/requests/urge", json={"id": f"employee_request:{req.id}"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_urge_no_recipient_does_not_burn_window( client, employee_user, db_session, ): # Employee with no manager and (in this isolated test) no IT admins → # nobody to notify. Urge must report noRecipient WITHOUT burning the # 24h window, so a retry once an approver exists still works. me, token = employee_user req = _emp(me.id, "pending") db_session.add(req) await db_session.commit() db_session.expunge_all() headers = {"Authorization": f"Bearer {token}"} item_id = f"employee_request:{req.id}" for _ in range(2): r = await client.post("/api/v1/me/requests/urge", json={"id": item_id}, headers=headers) assert r.status_code == 200 d = r.json()["data"] assert d["urged"] is False # Not rate-limited — window was never claimed. assert d["reason"] == "noRecipient" @pytest.mark.asyncio async def test_admin_urge_unsupported_source(client, admin_headers): # leaver_override has no unified-detail builder → graceful 'unsupported', # not a 404. resp = await client.post( "/api/v1/approval/admin/overview/urge", json={"id": "leaver_override:00000000-0000-0000-0000-000000000000"}, headers=admin_headers, ) assert resp.status_code == 200 d = resp.json()["data"] assert d["urged"] is False assert d["reason"] == "unsupported" @pytest.mark.asyncio async def test_feishu_request_urges_only_current_approver( client, employee_user, admin_user, db_session, monkeypatch, ): """Regression: a 催办 for a request that's in a live Feishu approval must notify ONLY the approver(s) whose task is currently pending — never the whole chain (manager + every IT admin). It must also drop the misleading Helix button (those requests are decided in Feishu审批, not on a Helix page) and reword the body to point at Feishu. """ import app.domains.me_universe.urge as urge_mod from app.approval.feishu_client import FeishuApprovalClient from app.domains.cmdb.models import CmdbDnsRecordRequest from app.identity.person_models import IdentityPerson from app.integrations.models import Integration from app.notifications.models import NotificationOutbox me, token = employee_user admin, _ = admin_user # an IT admin exists — must NOT be notified # The one person the Feishu approval is actually waiting on. approver = IdentityPerson( id=uuid.uuid4(), work_email=f"approver-{uuid.uuid4().hex[:8]}@test.local", display_name="Pending Approver", employment_status="active", source_system="test", hris_person_id=f"sf-{uuid.uuid4().hex[:6]}", ) integ = Integration( id=uuid.uuid4(), kind="chat_approval", provider="feishu", name="feishu-test", slug=f"feishu-{uuid.uuid4().hex[:6]}", ) db_session.add_all([approver, integ]) await db_session.flush() req = CmdbDnsRecordRequest( id=uuid.uuid4(), requester_person_id=str(me.id), action="create", integration_id=str(integ.id), zone_name="example.com", name="api", record_type="CNAME", value="target.example.com", purpose="test", status="pending", feishu_instance_code=f"INST-{uuid.uuid4().hex[:10]}", ) db_session.add(req) await db_session.commit() approver_id = approver.id admin_id = admin.id db_session.expunge_all() async def _fake_cfg(*, session=None): return {"app_id": "x", "app_secret": "y"} async def _fake_pending(self, instance_code): return [approver.work_email.lower()] monkeypatch.setattr(urge_mod, "get_feishu_config", _fake_cfg) monkeypatch.setattr( FeishuApprovalClient, "list_pending_approver_emails", _fake_pending, ) resp = await client.post( "/api/v1/me/requests/urge", json={"id": f"dns:{req.id}"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 d = resp.json()["data"] assert d["urged"] is True # Assert on the persisted outbox rows (the source of truth the inbox # reads + the worker delivers), not the live ``notified`` counter — in # the test env the synchronous Feishu push can't reach open.feishu.cn, # so delivery is deferred regardless of who we target. from sqlalchemy import select rows = ( await db_session.execute( select(NotificationOutbox).where( NotificationOutbox.related_resource_type == "request_urge", ) ) ).scalars().all() recipients = {r.recipient_person_id for r in rows} # Exactly the pending approver — not manager + all IT. assert recipients == {approver_id} assert admin_id not in recipients # IT admin chain was NOT pinged assert me.id not in recipients # applicant never nudges self # Feishu-decided → no misleading Helix button, body points to Feishu审批. assert all(r.url is None for r in rows) assert all("飞书" in r.body_text for r in rows) @pytest.mark.asyncio async def test_employee_request_urge_targets_feishu_approver_not_manager( client, employee_user, admin_user, db_session, monkeypatch, ): """Regression for the reported 催办 bug: an employee_request (software seat / API-key apply / …) is approved in a Feishu instance recorded at ``detail.feishu.instance_code``. A 催办 on it must notify ONLY the current Feishu pending approver — NOT the requester's manager + every IT admin (the old fallback that pinged the wrong people), and must drop the misleading ``/my/requests`` self-page link in favour of Feishu审批. """ from sqlalchemy import select import app.domains.me_universe.urge as urge_mod from app.approval.feishu_client import FeishuApprovalClient from app.identity.person_models import IdentityPerson from app.notifications.models import NotificationOutbox me, token = employee_user admin, _ = admin_user # an IT admin exists — must NOT be notified manager = await _give_manager(db_session, me) # nor the manager approver = IdentityPerson( id=uuid.uuid4(), work_email=f"approver-{uuid.uuid4().hex[:8]}@test.local", display_name="Seat Approver", employment_status="active", source_system="test", hris_person_id=f"sf-{uuid.uuid4().hex[:6]}", ) db_session.add(approver) await db_session.flush() req = _emp(me.id, "pending") req.request_type = "software_seat" req.detail = {"feishu": {"instance_code": f"INST-{uuid.uuid4().hex[:10]}"}} db_session.add(req) await db_session.commit() approver_id, admin_id, manager_id = approver.id, admin.id, manager.id db_session.expunge_all() async def _fake_cfg(*, session=None): return {"app_id": "x", "app_secret": "y"} async def _fake_pending(self, instance_code): return [approver.work_email.lower()] monkeypatch.setattr(urge_mod, "get_feishu_config", _fake_cfg) monkeypatch.setattr( FeishuApprovalClient, "list_pending_approver_emails", _fake_pending, ) resp = await client.post( "/api/v1/me/requests/urge", json={"id": f"employee_request:{req.id}"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.json()["data"]["urged"] is True rows = ( await db_session.execute( select(NotificationOutbox).where( NotificationOutbox.related_resource_type == "request_urge", ) ) ).scalars().all() recipients = {r.recipient_person_id for r in rows} assert recipients == {approver_id} # only the Feishu approver assert manager_id not in recipients # NOT the manager assert admin_id not in recipients # NOT the IT admin chain # Feishu-decided → no /my/requests self-page link; body points to飞书. assert all(r.url is None for r in rows) assert all("飞书" in r.body_text for r in rows) @pytest.mark.asyncio async def test_admin_urge_any_request(client, employee_user, admin_headers, db_session): me, _ = employee_user await _give_manager(db_session, me) # ensure a recipient exists req = _emp(me.id, "pending") # employee's request db_session.add(req) await db_session.commit() db_session.expunge_all() # Admin urges it via the overview endpoint (no ownership needed). resp = await client.post( "/api/v1/approval/admin/overview/urge", json={"id": f"employee_request:{req.id}"}, headers=admin_headers, ) assert resp.status_code == 200 assert resp.json()["data"]["urged"] is True