"""Self-service H5 endpoints + one-click (token) confirm + HR send.""" from __future__ import annotations import uuid from datetime import UTC, date, datetime import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import create_action_token from app.domains.attendance import constants as c from app.domains.attendance.models import AttendanceCycle, AttendanceReport from app.identity.person_models import IdentityPerson async def _person(db, email, name, *, manager_id=None): p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=name, employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", manager_person_id=manager_id, ) db.add(p) await db.flush() return p async def _report(db, *, person_id, leader_id, status): cycle = AttendanceCycle( id=uuid.uuid4(), cycle_key="2026-06", start_date=date(2026, 5, 21), end_date=date(2026, 6, 20), ) db.add(cycle) await db.flush() r = AttendanceReport( id=uuid.uuid4(), cycle_id=cycle.id, person_id=person_id, leader_person_id=leader_id, status=status, version=1, snapshot_json={"summary": {"abnormal_days": 0}, "days": []}, work_days=20, last_sent_at=datetime.now(UTC), ) db.add(r) await db.flush() return r @pytest.mark.asyncio async def test_intern_confirm_and_list(client, db_session: AsyncSession, employee_user, employee_headers): intern, _ = employee_user leader = await _person(db_session, "leadx@test.com", "Lead X") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_INTERN) await db_session.commit() listed = await client.get("/api/v1/attendance/me/reports", headers=employee_headers) assert listed.status_code == 200 items = listed.json()["data"] assert len(items) == 1 assert items[0]["viewer_role"] == "intern" assert items[0]["can_confirm"] is True resp = await client.post( f"/api/v1/attendance/me/reports/{r.id}/confirm", headers=employee_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["status"] == c.STATUS_PENDING_LEADER @pytest.mark.asyncio async def test_stranger_cannot_view(client, db_session: AsyncSession, admin_headers): intern = await _person(db_session, "soloi@test.com", "Solo") leader = await _person(db_session, "soloL@test.com", "Lead") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_INTERN) await db_session.commit() # admin is neither intern nor leader of this report. resp = await client.get( f"/api/v1/attendance/me/reports/{r.id}", headers=admin_headers, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_intern_return_via_api(client, db_session: AsyncSession, employee_user, employee_headers): intern, _ = employee_user leader = await _person(db_session, "lead-r@test.com", "Lead R") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_INTERN) await db_session.commit() resp = await client.post( f"/api/v1/attendance/me/reports/{r.id}/return", json={"reason": "缺卡没补"}, headers=employee_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["status"] == c.STATUS_RETURNED @pytest.mark.asyncio async def test_undo_via_api(client, db_session: AsyncSession, employee_user, employee_headers): intern, _ = employee_user leader = await _person(db_session, "lead-u@test.com", "Lead U") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_LEADER) r.intern_confirmed_at = datetime.now(UTC) await db_session.commit() resp = await client.post( f"/api/v1/attendance/me/reports/{r.id}/undo", headers=employee_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["status"] == c.STATUS_PENDING_INTERN @pytest.mark.asyncio async def test_hr_send(client, db_session: AsyncSession, hr_headers): intern = await _person(db_session, "hi@test.com", "HI") leader = await _person(db_session, "hl@test.com", "HL") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_GENERATED) await db_session.commit() resp = await client.post( f"/api/v1/attendance/reports/{r.id}/send", headers=hr_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["status"] == c.STATUS_PENDING_INTERN # ── one-click (no login) ──────────────────────────────────────────── @pytest.mark.asyncio async def test_one_click_confirm_intern(client, db_session: AsyncSession): intern = await _person(db_session, "oc-i@test.com", "OC Intern") leader = await _person(db_session, "oc-l@test.com", "OC Lead") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_INTERN) await db_session.commit() token = create_action_token( purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, subject_id=str(intern.id), resource_id=str(r.id), ) # GET shows a confirm form (no mutation). page = await client.get(f"/api/v1/attendance/confirm?token={token}") assert page.status_code == 200 assert "确认" in page.text await db_session.refresh(r) assert r.status == c.STATUS_PENDING_INTERN # GET didn't mutate # POST performs the confirm → routes to leader. done = await client.post("/api/v1/attendance/confirm", data={"token": token}) assert done.status_code == 200 await db_session.refresh(r) assert r.status == c.STATUS_PENDING_LEADER @pytest.mark.asyncio async def test_one_click_leader_completes(client, db_session: AsyncSession): intern = await _person(db_session, "oc2-i@test.com", "OC2 Intern") leader = await _person(db_session, "oc2-l@test.com", "OC2 Lead") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_LEADER) await db_session.commit() token = create_action_token( purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, subject_id=str(leader.id), resource_id=str(r.id), ) done = await client.post("/api/v1/attendance/confirm", data={"token": token}) assert done.status_code == 200 await db_session.refresh(r) assert r.status == c.STATUS_COMPLETED @pytest.mark.asyncio async def test_one_click_invalid_token(client): page = await client.get("/api/v1/attendance/confirm?token=garbage") assert page.status_code == 200 assert "无效" in page.text @pytest.mark.asyncio async def test_one_click_wrong_subject_rejected(client, db_session: AsyncSession): # A validly-signed token whose subject is neither the intern nor the # leader must not reveal the report (status-enumeration defence). intern = await _person(db_session, "ws-i@test.com", "WS Intern") leader = await _person(db_session, "ws-l@test.com", "WS Lead") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_INTERN) await db_session.commit() token = create_action_token( purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, subject_id=str(uuid.uuid4()), resource_id=str(r.id), ) page = await client.get(f"/api/v1/attendance/confirm?token={token}") assert "无效" in page.text # ── #2125 action-token subject lifecycle ──────────────────────────── @pytest.mark.asyncio async def test_one_click_confirm_refused_after_the_subject_leaves( client, db_session: AsyncSession, ): """A leaver's un-clicked link must stop working immediately. The token is signed but carries no lifecycle state, so it kept authorising for its full 14-day life. The report is the payroll basis, the endpoint needs no login / cookie / bearer, and the departing person is holding the link themselves — no leak required. Every other mutating action-token consumer already calls ``enforce_action_token_subject``; attendance was the one that did not (#2125). """ from datetime import timedelta intern = await _person(db_session, "gone-i@test.com", "Gone Intern") leader = await _person(db_session, "gone-l@test.com", "Gone Lead") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_INTERN) await db_session.commit() token = create_action_token( purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, subject_id=str(intern.id), resource_id=str(r.id), ) # The realistic shape: HR booked the last day, the date has passed, # and nobody has run the flip-to-left cron yet. ``leaver_pending`` # past termination_date is exactly what ``is_effectively_departed`` # treats as gone — and it avoids poking employment_status directly, # which LeaverBlockGuard rightly refuses. intern.employment_status = "leaver_pending" intern.termination_date = (datetime.now(UTC) - timedelta(days=3)).date() await db_session.commit() done = await client.post( "/api/v1/attendance/confirm", data={"token": token}, ) # Neutral "link invalid" page — deliberately not "you have departed". assert done.status_code == 200 assert "无效" in done.text await db_session.refresh(r) assert r.status == c.STATUS_PENDING_INTERN, ( "a departed account advanced a payroll input through the " "no-login one-click link" ) page = await client.get(f"/api/v1/attendance/confirm?token={token}") assert "无效" in page.text @pytest.mark.asyncio async def test_one_click_confirm_refused_after_force_logout( client, db_session: AsyncSession, ): """force_logout must sever this credential surface like every other.""" from datetime import timedelta intern = await _person(db_session, "fl-i@test.com", "FL Intern") leader = await _person(db_session, "fl-l@test.com", "FL Lead") r = await _report(db_session, person_id=intern.id, leader_id=leader.id, status=c.STATUS_PENDING_INTERN) await db_session.commit() token = create_action_token( purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, subject_id=str(intern.id), resource_id=str(r.id), ) intern.force_logout_at = datetime.now(UTC) + timedelta(seconds=5) await db_session.commit() done = await client.post( "/api/v1/attendance/confirm", data={"token": token}, ) assert "无效" in done.text await db_session.refresh(r) assert r.status == c.STATUS_PENDING_INTERN