import json import uuid from io import BytesIO from pathlib import Path import numpy as np import pydicom from fastapi.testclient import TestClient from PIL import Image from pydicom.dataset import Dataset, FileMetaDataset from pydicom.uid import CTImageStorage, ExplicitVRLittleEndian, generate_uid from sqlalchemy import select from sqlalchemy.orm import Session from app.auth.passwords import hash_password from app.claimguard import audit from app.models import AuditEvent, Claim, ClaimState, Document, Role, User from app.services import storage from tests.conftest import DEMO_PASSWORD, login def _make_claim( session: Session, claimant: User, state: ClaimState = ClaimState.SUBMITTED ) -> Claim: claim = Claim( claim_ref=f"CLM-{uuid.uuid4().hex[:8].upper()}", claimant_id=claimant.id, claim_type="imaging", state=state, ) session.add(claim) session.commit() return claim def _make_claimant(session: Session, email: str) -> User: user = User( email=email, password_hash=hash_password(DEMO_PASSWORD), role=Role.CLAIMANT, full_name="Other Claimant", member_id="MBR-9999", ) session.add(user) session.commit() return user def _png_bytes() -> bytes: buf = BytesIO() Image.new("L", (1, 1), 128).save(buf, format="PNG") return buf.getvalue() def _dicom_bytes(tmp_path: Path) -> bytes: file_meta = FileMetaDataset() file_meta.MediaStorageSOPClassUID = CTImageStorage file_meta.MediaStorageSOPInstanceUID = generate_uid() file_meta.TransferSyntaxUID = ExplicitVRLittleEndian ds = Dataset() ds.file_meta = file_meta ds.SOPClassUID = CTImageStorage ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID ds.PatientName = "Doe^Jane" ds.PatientID = "PHI-12345" ds.Modality = "CT" ds.StudyDate = "20240115" ds.Manufacturer = "TestScan" ds.Rows = 4 ds.Columns = 4 ds.SamplesPerPixel = 1 ds.PhotometricInterpretation = "MONOCHROME2" ds.BitsAllocated = 16 ds.BitsStored = 16 ds.HighBit = 15 ds.PixelRepresentation = 0 ds.PixelData = np.arange(16, dtype=np.uint16).reshape(4, 4).tobytes() out = tmp_path / "source.dcm" ds.save_as(out, enforce_file_format=True) return out.read_bytes() def _upload( client: TestClient, claim_id: int, content: bytes, filename: str, mime: str, *, kind: str = "imaging", modality: str | None = None, ): data = {"kind": kind} if modality is not None: data["modality"] = modality return client.post( f"/api/documents/upload/{claim_id}", files={"file": (filename, content, mime)}, data=data, ) def test_dicom_upload_deidentifies_and_renders_preview( as_claimant: TestClient, session: Session, users: dict[str, User], tmp_path: Path ) -> None: claim = _make_claim(session, users["claimant"]) resp = _upload( as_claimant, claim.id, _dicom_bytes(tmp_path), "study.dcm", "application/dicom", modality="ct", ) assert resp.status_code == 200, resp.text body = resp.json() assert body["kind"] == "imaging" assert body["modality"] == "ct" assert body["has_preview"] is True document = session.scalar(select(Document).where(Document.id == body["id"])) assert document is not None # PHI blanked on disk — the stored study is de-identified at rest. ds = pydicom.dcmread(document.storage_path) assert str(ds.PatientName) == "" assert str(ds.PatientID) == "" meta = json.loads(document.dicom_meta_json) assert meta["Modality"] == "CT" assert meta["StudyDate"] == "2024" # year only assert document.preview_path is not None assert Path(document.preview_path).exists() preview = as_claimant.get(f"/api/documents/{body['id']}/preview") assert preview.status_code == 200 assert preview.headers["content-type"] == "image/png" assert preview.content.startswith(b"\x89PNG") def test_png_upload_keeps_declared_modality( as_claimant: TestClient, session: Session, users: dict[str, User] ) -> None: claim = _make_claim(session, users["claimant"]) resp = _upload(as_claimant, claim.id, _png_bytes(), "scan.png", "image/png", modality="xray") assert resp.status_code == 200, resp.text body = resp.json() assert body["kind"] == "imaging" assert body["modality"] == "xray" assert body["has_preview"] is False def test_unsupported_file_rejected( as_claimant: TestClient, session: Session, users: dict[str, User] ) -> None: claim = _make_claim(session, users["claimant"]) # Lying extension + mime: magic-byte sniffing must still reject it. resp = _upload( as_claimant, claim.id, b"just some text", "fake.dcm", "application/dicom", kind="other" ) assert resp.status_code == 400 assert "unsupported" in resp.json()["detail"] def test_oversize_upload_rejected( as_claimant: TestClient, session: Session, users: dict[str, User], monkeypatch ) -> None: monkeypatch.setattr(storage, "MAX_UPLOAD_BYTES", 16) claim = _make_claim(session, users["claimant"]) resp = _upload(as_claimant, claim.id, _png_bytes(), "scan.png", "image/png") assert resp.status_code == 413 def test_upload_rejected_in_non_upload_state( as_claimant: TestClient, session: Session, users: dict[str, User] ) -> None: claim = _make_claim(session, users["claimant"], state=ClaimState.IMAGING_REVIEW) resp = _upload(as_claimant, claim.id, _png_bytes(), "scan.png", "image/png") assert resp.status_code == 409 def test_non_owner_upload_rejected( as_claimant: TestClient, session: Session, users: dict[str, User] ) -> None: other = _make_claimant(session, "other@demo.ca") claim = _make_claim(session, other) resp = _upload(as_claimant, claim.id, _png_bytes(), "scan.png", "image/png") assert resp.status_code == 404 def test_file_access_owner_staff_and_other_claimant( as_claimant: TestClient, session: Session, users: dict[str, User] ) -> None: claim = _make_claim(session, users["claimant"]) resp = _upload(as_claimant, claim.id, _png_bytes(), "scan.png", "image/png", modality="xray") assert resp.status_code == 200, resp.text doc_id = resp.json()["id"] owner = as_claimant.get(f"/api/documents/{doc_id}/file") assert owner.status_code == 200 assert owner.headers["content-disposition"].startswith("attachment") assert "scan.png" not in owner.headers["content-disposition"] # server-side name login(as_claimant, "imaging@demo.ca") assert as_claimant.get(f"/api/documents/{doc_id}/file").status_code == 200 login(as_claimant, "specialist@demo.ca") assert as_claimant.get(f"/api/documents/{doc_id}/file").status_code == 200 login(as_claimant, "agent@demo.ca") assert as_claimant.get(f"/api/documents/{doc_id}/file").status_code == 200 _make_claimant(session, "other@demo.ca") login(as_claimant, "other@demo.ca") assert as_claimant.get(f"/api/documents/{doc_id}/file").status_code == 404 assert as_claimant.get(f"/api/documents/{doc_id}/preview").status_code == 404 assert as_claimant.get("/api/documents/999999/file").status_code == 404 def test_upload_audited_and_chain_valid( as_claimant: TestClient, session: Session, users: dict[str, User] ) -> None: claim = _make_claim(session, users["claimant"]) resp = _upload(as_claimant, claim.id, _png_bytes(), "scan.png", "image/png") assert resp.status_code == 200, resp.text body = resp.json() event = session.scalar(select(AuditEvent).where(AuditEvent.event_type == "document.upload")) assert event is not None assert event.claim_id == claim.id assert event.actor_user_id == users["claimant"].id assert event.actor_role == "claimant" payload = json.loads(event.payload_json) assert payload["filename"] == "scan.png" assert payload["kind"] == "imaging" assert payload["sha256"] == body["sha256"] ok, checked = audit.verify_chain(session) assert ok is True assert checked >= 2 # at least login + upload