"""Material CRUD + lifecycle. The rules worth pinning here are the ones that protect the *evidence*: a material that has been shown cannot be deleted, publishing something unrenderable is refused, and a content edit bumps the version while an operational edit does not. """ from __future__ import annotations import uuid import pytest from sqlalchemy import select from app.domains.security_awareness.models import SecawImpression, SecawMaterial from tests.test_security_awareness.conftest import ( ADMIN_BASE, SELF_BASE, article_payload, awareness_key, comic_payload, ) pytestmark = pytest.mark.asyncio async def test_create_defaults_to_draft_and_is_invisible_to_employees( client, db_session, security_officer, reader, ): """"Saved" must not mean "the whole company can see it". The editor has a preview; publishing is a separate, deliberate click. """ officer, oh = security_officer _person, rh = reader r = await client.post( f"{ADMIN_BASE}/materials", json=article_payload(publish=False), headers=oh, ) assert r.status_code == 201, r.text mid = r.json()["data"]["id"] row = await db_session.get(SecawMaterial, uuid.UUID(mid)) assert row.status == "draft" assert row.published_at is None lib = await client.get(f"{SELF_BASE}/materials", headers=rh) assert lib.status_code == 200 assert [m["id"] for m in lib.json()["data"]] == [] # And the employee detail route 404s rather than 403s — a draft is # not "forbidden", it is not part of the library, and a stale # bookmark should not read as an access-control decision. one = await client.get(f"{SELF_BASE}/materials/{mid}", headers=rh) assert one.status_code == 404 async def test_publish_stamps_once_and_survives_a_republish_cycle( client, db_session, security_officer, ): officer, oh = security_officer mid = (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(publish=True), headers=oh, )).json()["data"]["id"] row = await db_session.get(SecawMaterial, uuid.UUID(mid)) await db_session.refresh(row) first = row.published_at assert first is not None for target in ("draft", "published"): r = await client.post( f"{ADMIN_BASE}/materials/{mid}/status", json={"status": target}, headers=oh, ) assert r.status_code == 200, r.text await db_session.refresh(row) assert row.published_at == first, ( "'when did the workforce first get this' has ONE answer; an " "unpublish/republish cycle belongs in the audit log, not in this " "column" ) async def test_archiving_clears_the_library_but_keeps_the_row( client, db_session, security_officer, reader, ): officer, oh = security_officer _p, rh = reader mid = (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=oh, )).json()["data"]["id"] assert len((await client.get( f"{SELF_BASE}/materials", headers=rh, )).json()["data"]) == 1 r = await client.post( f"{ADMIN_BASE}/materials/{mid}/status", json={"status": "archived"}, headers=oh, ) assert r.status_code == 200 assert (await client.get( f"{SELF_BASE}/materials", headers=rh, )).json()["data"] == [] assert await db_session.get(SecawMaterial, uuid.UUID(mid)) is not None async def test_a_shown_material_cannot_be_deleted( client, db_session, security_officer, reader, ): """The impression ledger is the reason this domain exists. Deleting a material that reached somebody would orphan the ledger and silently shrink a coverage number that may already have been reported. Archive is offered instead — and the error says so. """ officer, oh = security_officer _p, rh = reader mid = (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=oh, )).json()["data"]["id"] # Before anyone has seen it, deletion is fine. other = (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=oh, )).json()["data"]["id"] assert (await client.delete( f"{ADMIN_BASE}/materials/{other}", headers=oh, )).status_code == 204 # An employee opens it → one impression. assert (await client.get( f"{SELF_BASE}/materials/{mid}", headers=rh, )).status_code == 200 shown = (await db_session.execute( select(SecawImpression).where( SecawImpression.material_id == uuid.UUID(mid), ) )).scalars().all() assert len(shown) == 1 r = await client.delete(f"{ADMIN_BASE}/materials/{mid}", headers=oh) assert r.status_code == 409 assert r.json()["errors"][0]["code"] == "SECAW_MATERIAL_HAS_IMPRESSIONS" assert await db_session.get(SecawMaterial, uuid.UUID(mid)) is not None async def test_content_edit_bumps_version_but_pinning_does_not( client, db_session, security_officer, ): """A campaign records the version it went out with, so "the article said something else when I confirmed" has an answer. Somebody re-pinning a card must not muddy that.""" officer, oh = security_officer mid = (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=oh, )).json()["data"]["id"] row = await db_session.get(SecawMaterial, uuid.UUID(mid)) assert row.version == 1 r = await client.put( f"{ADMIN_BASE}/materials/{mid}", json={"localized": {"zh": {"title": "口令怎么设", "body": "改过了"}}}, headers=oh, ) assert r.status_code == 200 assert r.json()["data"]["version"] == 2 r = await client.put( f"{ADMIN_BASE}/materials/{mid}", json={"is_pinned": True}, headers=oh, ) assert r.json()["data"]["version"] == 2 await db_session.refresh(row) assert row.is_pinned is True async def test_switching_kind_drops_the_content_the_new_kind_cannot_use( client, db_session, security_officer, ): """An orphan body left behind by an article→link switch is content a future reader could render by accident.""" officer, oh = security_officer mid = (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=oh, )).json()["data"]["id"] r = await client.put( f"{ADMIN_BASE}/materials/{mid}", json={ "kind": "link", "localized": { "zh": { "title": "口令怎么设", "body": "这段不该留下", "link_url": "https://example.invalid/pw", }, }, }, headers=oh, ) assert r.status_code == 200, r.text row = await db_session.get(SecawMaterial, uuid.UUID(mid)) await db_session.refresh(row) assert row.kind == "link" assert "body" not in row.localized["zh"] assert row.localized["zh"]["link_url"] == "https://example.invalid/pw" async def test_kind_and_content_are_validated_together( client, security_officer, ): officer, oh = security_officer # A comic with no images renders as a title and nothing else. r = await client.post( f"{ADMIN_BASE}/materials", json={ "kind": "comic", "category": "phishing", "localized": {"zh": {"title": "空漫画"}}, }, headers=oh, ) assert r.status_code == 422 # Switching an existing article to comic without adding images is the # same mistake arriving through the update path — the merge has to # re-check both halves, not just the one that changed. mid = (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=oh, )).json()["data"]["id"] r = await client.put( f"{ADMIN_BASE}/materials/{mid}", json={"kind": "comic"}, headers=oh, ) assert r.status_code == 422 async def test_zh_block_is_required(client, security_officer): """The language fallback chain terminates at zh. Without it, at least one reader gets an empty card.""" officer, oh = security_officer r = await client.post( f"{ADMIN_BASE}/materials", json={ "kind": "article", "category": "other", "localized": {"en": {"title": "EN only", "body": "x"}}, }, headers=oh, ) assert r.status_code == 422 async def test_foreign_object_keys_are_refused(client, security_officer): """A material is a presigning surface. Accepting an arbitrary key would turn it into a read oracle for every other purpose's prefix — inventory evidence photos, disposal receipts, feed images.""" officer, oh = security_officer pid = officer.id payload = comic_payload(pid) payload["localized"]["zh"]["image_keys"] = [ f"inventory_evidence/2026/09/03/{pid}/someone_elses.png", ] r = await client.post(f"{ADMIN_BASE}/materials", json=payload, headers=oh) assert r.status_code == 400 assert r.json()["errors"][0]["code"] == "SECAW_INVALID_OBJECT_KEY" # A malformed key (right purpose, wrong shape) is refused too — the # gate re-uses the router's parser, so shape and ACL cannot drift. payload["localized"]["zh"]["image_keys"] = ["awareness_material/nope.png"] r = await client.post(f"{ADMIN_BASE}/materials", json=payload, headers=oh) assert r.status_code == 400 async def test_duplicate_makes_an_independent_draft( client, db_session, security_officer, ): """Last year's comic must stay exactly as it was shown — its impressions point at it — so "refresh it for this year" is a copy.""" officer, oh = security_officer src = (await client.post( f"{ADMIN_BASE}/materials", json=comic_payload(officer.id), headers=oh, )).json()["data"]["id"] r = await client.post( f"{ADMIN_BASE}/materials/{src}/duplicate", headers=oh, ) assert r.status_code == 200 copy_id = r.json()["data"]["id"] assert copy_id != src copy = await db_session.get(SecawMaterial, uuid.UUID(copy_id)) assert copy.status == "draft" assert copy.version == 1 assert copy.is_pinned is False assert copy.localized["zh"]["title"] == "识破钓鱼邮件" # Editing the copy leaves the original untouched. await client.put( f"{ADMIN_BASE}/materials/{copy_id}", json={"localized": {"zh": { "title": "2027 版", "image_keys": [awareness_key(officer.id, "new.png")], }}}, headers=oh, ) original = await db_session.get(SecawMaterial, uuid.UUID(src)) await db_session.refresh(original) assert original.localized["zh"]["title"] == "识破钓鱼邮件" async def test_reorder_puts_unmentioned_rows_at_the_end( client, db_session, security_officer, ): """An admin who added a row in another tab must not collide on 0.""" officer, oh = security_officer ids = [ (await client.post( f"{ADMIN_BASE}/materials", json=article_payload(), headers=oh, )).json()["data"]["id"] for _ in range(3) ] r = await client.post( f"{ADMIN_BASE}/materials/reorder", json={"ordered_ids": [ids[2], ids[0]]}, headers=oh, ) assert r.status_code == 200 orders = {} for mid in ids: row = await db_session.get(SecawMaterial, uuid.UUID(mid)) await db_session.refresh(row) orders[mid] = row.display_order assert orders[ids[2]] == 0 assert orders[ids[0]] == 1 assert orders[ids[1]] == 2