"""Coverage, the GRC control, and the chase ladder. Two clusters: * **Coverage arithmetic**, where the failure mode is a number that is *plausible and wrong* — 0/0 reported as 100%, a rounded-up 89.6, a denominator recomputed against today's headcount, voluntary notices padding the total. * **The ladder**, where the failure mode is volume: this platform has measured what happens without one (16.7 pushes per person per month), so the assertions are mostly about how much does NOT get sent. """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import select, update from app.domains.security_awareness.models import ( SecawCampaign, SecawCampaignTarget, ) from app.notifications.models import NotificationNagState from tests.test_security_awareness.conftest import ( ADMIN_BASE, SELF_BASE, article_payload, fanout_double, headers_for, make_person, send_now, ) pytestmark = pytest.mark.asyncio CAMPAIGNS = f"{ADMIN_BASE}/campaigns" COVERAGE = f"{ADMIN_BASE}/coverage" MY = f"{SELF_BASE}/my/campaigns" def _fanout_patch(**_over): """Patch the fan-out with a double that HONOURS its ``on_enqueued`` contract, wrapped in an ``AsyncMock`` so ``await_count`` still works for the tests that assert "the reminder pushed nothing". A bare ``AsyncMock(return_value={...})`` skips the hook, and the hook is where the dispatcher stamps each target's ``queued_at`` — so the counts would look right while every ledger row stayed unstamped. """ return patch( "app.notifications.fanout.enqueue_then_drain", new=AsyncMock(side_effect=fanout_double()), ) async def _publish(client, headers) -> str: r = await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=headers, ) assert r.status_code == 201, r.text return r.json()["data"]["id"] async def _send(client, db, headers, material, **over) -> str: body = { "material_id": material, "name": over.pop("name", "必修宣贯"), "audience_config": {}, "channels": ["feishu"], "mandatory": True, "completion_mode": "ack", **over, } cid = (await client.post( CAMPAIGNS, json=body, headers=headers, )).json()["data"]["id"] with _fanout_patch(): # ``force``: several of these tests deliberately send the same # material twice (必修 + 运营, big + small audience) inside the # duplicate-send window, which is exactly the shape the guard # refuses by default. await send_now(client, db, cid, headers, force=True) return cid async def _age_campaign(db, campaign_id: str, *, days: int) -> None: await db.execute( update(SecawCampaign) .where(SecawCampaign.id == uuid.UUID(campaign_id)) .values(sent_at=datetime.now(UTC) - timedelta(days=days)) ) await db.commit() # ── Coverage arithmetic ───────────────────────────────────────────── async def test_coverage_counts_confirmations_over_who_it_was_sent_to( client, db_session, security_officer, reader, ): officer, oh = security_officer _person, rh = reader material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) data = (await client.get(COVERAGE, headers=oh)).json()["data"] assert data["rollup"]["targeted"] >= 2 assert data["rollup"]["acknowledged"] == 0 assert data["rollup"]["coverage_pct"] == 0 await client.post(f"{MY}/{campaign}/acknowledge", headers=rh) data = (await client.get(COVERAGE, headers=oh)).json()["data"] assert data["rollup"]["acknowledged"] == 1 async def test_voluntary_notices_do_not_pad_the_number( client, db_session, security_officer, ): """A notice asks nothing of anybody. Letting a comic nobody had to read carry the coverage number would make it meaningless in the one direction that matters.""" officer, oh = security_officer material = await _publish(client, oh) await _send(client, db_session, oh, material, name="必修", mandatory=True) await _send(client, db_session, oh, material, name="运营", mandatory=False) data = (await client.get(COVERAGE, headers=oh)).json()["data"] assert [c["name"] for c in data["campaigns"]] == ["必修"] async def test_the_rollup_is_people_weighted_not_an_average_of_percentages( client, db_session, security_officer, reader, ): """Averaging percentages lets a campaign sent to 3 people move the number as much as one sent to 480 — exactly backwards.""" officer, oh = security_officer person, rh = reader material = await _publish(client, oh) # Small campaign, fully acknowledged. small = await _send(client, db_session, oh, material, name="小", audience_config={"person_ids": [str(person.id)]}, ) await client.post(f"{MY}/{small}/acknowledge", headers=rh) # Bigger campaign, nobody acknowledged. for n in range(6): await make_person(db_session, f"w{n}@test.com", role="employee") await db_session.commit() await _send(client, db_session, oh, material, name="大") data = (await client.get(COVERAGE, headers=oh)).json()["data"] roll = data["rollup"] # 1 of (1 + 8) — nowhere near the 50% an average-of-percentages # (100% and 0%) would have produced. assert roll["acknowledged"] == 1 assert roll["coverage_pct"] < 50 async def test_coverage_floors_rather_than_rounding_up( client, db_session, security_officer, ): """A report that rounds 89.6 up to 90 against a 90 threshold is the rounding nobody wants to discover afterwards.""" from app.domains.security_awareness.compliance import campaign_coverage officer, oh = security_officer material = await _publish(client, oh) # 9 people, 8 acknowledge → 88.8% people = [ await make_person(db_session, f"f{n}@test.com", role="employee") for n in range(9) ] await db_session.commit() campaign = await _send(client, db_session, oh, material, audience_config={"person_ids": [str(p.id) for p in people]}, ) for p in people[:8]: await client.post( f"{MY}/{campaign}/acknowledge", headers=headers_for(p, ["employee"]), ) stats = await campaign_coverage(db_session, uuid.UUID(campaign)) assert stats["targeted"] == 9 assert stats["acknowledged"] == 8 assert stats["coverage_pct"] == 88 async def test_the_denominator_does_not_move_when_somebody_joins( client, db_session, security_officer, ): """A campaign sent in March to the people who worked here then is a March fact.""" officer, oh = security_officer material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) before = (await client.get(COVERAGE, headers=oh)).json()["data"] await make_person(db_session, "joined-later@test.com", role="employee") await db_session.commit() after = (await client.get(COVERAGE, headers=oh)).json()["data"] assert after["rollup"]["targeted"] == before["rollup"]["targeted"] assert campaign in [c["campaign_id"] for c in after["campaigns"]] # ── The GRC control ───────────────────────────────────────────────── async def test_the_control_is_not_configured_before_any_required_campaign( client, db_session, security_officer, ): from app.domains.security_awareness.compliance import ( evaluate_awareness_control, ) officer, oh = security_officer material = await _publish(client, oh) await _send(client, db_session, oh, material, mandatory=False) ev = await evaluate_awareness_control(db_session) assert ev["status"] == "not_configured" async def test_the_control_fails_below_the_threshold_and_passes_above( client, db_session, security_officer, reader, ): from app.domains.security_awareness.compliance import ( evaluate_awareness_control, ) officer, oh = security_officer person, rh = reader material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material, audience_config={"person_ids": [str(person.id), str(officer.id)]}, ) ev = await evaluate_awareness_control(db_session) assert ev["status"] == "fail" assert ev["score"] == 0 await client.post(f"{MY}/{campaign}/acknowledge", headers=rh) await client.post(f"{MY}/{campaign}/acknowledge", headers=oh) ev = await evaluate_awareness_control(db_session) assert ev["status"] == "pass" assert ev["score"] == 100 async def test_an_empty_audience_is_a_fail_not_a_vacuous_pass( client, db_session, security_officer, ): """Reporting 0/0 as 100% would be the single most misleading thing this module could do.""" from app.domains.security_awareness.compliance import ( evaluate_awareness_control, ) officer, oh = security_officer material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) # Strip the targets to simulate a campaign that reached nobody. await db_session.execute( SecawCampaignTarget.__table__.delete().where( SecawCampaignTarget.campaign_id == uuid.UUID(campaign), ) ) await db_session.commit() ev = await evaluate_awareness_control(db_session) assert ev["status"] == "fail" assert ev["score"] == 0 async def test_an_old_campaign_reads_stale_not_pass( client, db_session, security_officer, reader, ): from app.domains.security_awareness.compliance import ( evaluate_awareness_control, ) officer, oh = security_officer person, rh = reader material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material, audience_config={"person_ids": [str(person.id)]}, ) await client.post(f"{MY}/{campaign}/acknowledge", headers=rh) assert (await evaluate_awareness_control(db_session))["status"] == "pass" await _age_campaign(db_session, campaign, days=400) ev = await evaluate_awareness_control(db_session) assert ev["status"] == "stale" async def test_the_control_is_wired_into_the_compliance_catalog(): """A control that exists in code but is not in the catalog never appears on the compliance page.""" from app.domains.auditor import compliance_catalog as cat assert "security_awareness_training" in cat.all_control_keys() crosswalk = cat.MAPPINGS["security_awareness_training"] assert crosswalk["soc2"] == ["CC2.2"] assert crosswalk["iso27001"] == ["A.6.3"] refs = {r["ref"] for r in cat.REQUIREMENTS["soc2"]} assert "CC2.2" in refs, "the crosswalk points at a requirement that exists" iso_refs = {r["ref"] for r in cat.REQUIREMENTS["iso27001"]} assert "A.6.3" in iso_refs # ── Evidence export ───────────────────────────────────────────────── async def test_the_evidence_csv_carries_the_snapshot_and_the_timestamps( client, db_session, security_officer, reader, ): officer, oh = security_officer _person, rh = reader material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) await client.post(f"{MY}/{campaign}/acknowledge", headers=rh) r = await client.get( f"{CAMPAIGNS}/{campaign}/evidence.csv", headers=oh, ) assert r.status_code == 200 assert "text/csv" in r.headers["content-type"] body = r.text # BOM so Excel opens the UTF-8 names correctly. assert body.startswith("") assert "acknowledged_at" in body assert "reader@test.com" in body # Name and email are separate columns, never joined (#3417). assert "reader · reader@test.com" not in body async def test_the_evidence_csv_defuses_spreadsheet_formulas( client, db_session, security_officer, ): """An evidence file is the LAST thing that should be able to run something when somebody opens it.""" officer, oh = security_officer material = await _publish(client, oh) nasty = await make_person(db_session, "calc@test.com", role="employee") nasty.display_name = "=cmd|'/c calc'!A1" await db_session.commit() campaign = await _send(client, db_session, oh, material, audience_config={"person_ids": [str(nasty.id)]}, ) body = (await client.get( f"{CAMPAIGNS}/{campaign}/evidence.csv", headers=oh, )).text assert "\"'=cmd" in body or "'=cmd" in body assert "\n=cmd" not in body async def test_employees_cannot_read_coverage_or_evidence( client, db_session, security_officer, reader, ): officer, oh = security_officer _person, rh = reader material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) assert (await client.get(COVERAGE, headers=rh)).status_code == 403 assert (await client.get( f"{CAMPAIGNS}/{campaign}/evidence.csv", headers=rh, )).status_code == 403 # ── The chase ladder ──────────────────────────────────────────────── async def test_nobody_is_chased_in_the_first_week( client, db_session, security_officer, ): """The material takes minutes and most people do it in the first week; chasing before that spends the channel on people who were going to comply anyway.""" from app.domains.security_awareness.reminders import ReminderService officer, oh = security_officer material = await _publish(client, oh) await _send(client, db_session, oh, material) with _fanout_patch() as spy: summary = await ReminderService(db_session).run() assert summary["outstanding"] == 0 assert spy.await_count == 0 async def test_the_first_chase_lands_after_a_week_then_the_ladder_stretches( client, db_session, security_officer, reader, ): from app.domains.security_awareness.reminders import ( TEMPLATE_REMINDER, ReminderService, ) officer, oh = security_officer material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) await _age_campaign(db_session, campaign, days=8) with _fanout_patch() as spy: summary = await ReminderService(db_session).run() # TWO real people, chased once each. The number used to come from # ``AsyncMock(return_value={"enqueued": 2})`` while only the officer # existed — i.e. the assertion was reading the double's own constant # back out, and would have held at any audience size including zero. assert summary["outstanding"] == 2 assert summary["employee_pushes"] == 2 assert spy.await_count == 1 # A second tick the same day sends nothing — the ladder's first gap # is seven days, not one. with _fanout_patch() as spy: again = await ReminderService(db_session).run() assert again["employee_pushes"] == 0 assert spy.await_count == 0 states = (await db_session.execute( select(NotificationNagState).where( NotificationNagState.template_key == TEMPLATE_REMINDER, ) )).scalars().all() assert states and all(s.sent_count == 1 for s in states) async def test_our_ladder_is_longer_than_the_platform_default(): """Longer only. Daily chasing on required reading is how the un-mutable security category gets tuned out — and then the flows that genuinely cannot be muted stop being read too.""" from app.domains.security_awareness.reminders import TEMPLATE_REMINDER from app.notifications import cadence for sent_count in range(0, 6): ours = cadence.required_gap_days(TEMPLATE_REMINDER, sent_count) theirs = cadence.required_gap_days("\x00nothing", sent_count) assert ours >= theirs, f"ladder tightened at n={sent_count}" async def test_acknowledging_settles_the_nag_account( client, db_session, security_officer, reader, ): """Without this, the person's NEXT ask of the same kind inherits the counter and starts on the two-week rung — a "noise reduction" that turns a fresh problem into near-silence.""" from app.domains.security_awareness.reminders import ( TEMPLATE_REMINDER, ReminderService, ) officer, oh = security_officer person, rh = reader material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) await _age_campaign(db_session, campaign, days=8) with _fanout_patch(): await ReminderService(db_session).run() assert (await db_session.execute( select(NotificationNagState).where( NotificationNagState.recipient_person_id == person.id, NotificationNagState.template_key == TEMPLATE_REMINDER, ) )).scalar_one_or_none() is not None await client.post(f"{MY}/{campaign}/acknowledge", headers=rh) assert (await db_session.execute( select(NotificationNagState).where( NotificationNagState.recipient_person_id == person.id, NotificationNagState.template_key == TEMPLATE_REMINDER, ) )).scalar_one_or_none() is None async def test_a_closed_campaign_stops_being_chased( client, db_session, security_officer, ): """Closing is the act that stops the asking. Chasing its stragglers afterwards would contradict it.""" from app.domains.security_awareness.reminders import ReminderService officer, oh = security_officer material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material) await _age_campaign(db_session, campaign, days=30) await client.post(f"{CAMPAIGNS}/{campaign}/close", headers=oh) with _fanout_patch() as spy: summary = await ReminderService(db_session).run() assert summary["outstanding"] == 0 assert spy.await_count == 0 async def test_managers_get_one_digest_not_one_card_per_report( client, db_session, security_officer, ): """Handing ninety people's todos to five admins "produced a chase queue and moved no decisions" — this platform has already made that finding once.""" from app.domains.security_awareness.reminders import ( TEMPLATE_MANAGER, ReminderService, ) officer, oh = security_officer manager = await make_person(db_session, "boss4@test.com", role="employee") reports = [ await make_person(db_session, f"rep{n}@test.com", role="employee") for n in range(4) ] for r in reports: r.manager_person_id = manager.id await db_session.commit() material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material, audience_config={"person_ids": [str(r.id) for r in reports]}, ) await _age_campaign(db_session, campaign, days=25) with _fanout_patch() as spy: summary = await ReminderService(db_session).run() assert summary["manager_pushes"] == 1, "one digest, not one per report" manager_calls = [ c for c in spy.await_args_list if any( e.template_key == TEMPLATE_MANAGER for e in c.args[1] ) ] assert len(manager_calls) == 1 envelopes = manager_calls[0].args[1] assert len(envelopes) == 1 assert envelopes[0].recipient.id == manager.id assert envelopes[0].context["pending_count"] == 4 async def test_a_manager_hears_nothing_in_the_first_three_weeks( client, db_session, security_officer, ): from app.domains.security_awareness.reminders import ReminderService officer, oh = security_officer manager = await make_person(db_session, "boss5@test.com", role="employee") report = await make_person(db_session, "rep9@test.com", role="employee") report.manager_person_id = manager.id await db_session.commit() material = await _publish(client, oh) campaign = await _send(client, db_session, oh, material, audience_config={"person_ids": [str(report.id)]}, ) await _age_campaign(db_session, campaign, days=10) with _fanout_patch(): summary = await ReminderService(db_session).run() assert summary["employee_pushes"] == 1 assert summary["manager_pushes"] == 0 async def test_the_reminder_push_is_one_an_employee_cannot_mute( client, security_officer, ): """If the original required push is un-mutable and drives the red dot but its chase is not, an employee can silence exactly the messages telling them they still owe required reading.""" from app.domains.security_awareness.reminders import ( TEMPLATE_MANAGER, TEMPLATE_REMINDER, ) from app.notifications.categories import classify chase = classify(TEMPLATE_REMINDER) assert chase.key == "security" assert chase.mutable is False assert chase.bucket == "action" # The manager digest is the opposite: it is FYI about somebody # else's work, so it IS mutable and does not drive a red dot. digest = classify(TEMPLATE_MANAGER) assert digest.mutable is True assert digest.bucket == "info"