from __future__ import annotations
from dataclasses import replace
from io import BytesIO
from zipfile import ZIP_DEFLATED, ZipFile
from fastapi.testclient import TestClient
from PIL import Image
from app.main import create_app
from app.media.ocr import OCRLine
CONTENT_TYPES = b"""
"""
ROOT_RELS = b"""
"""
def _docx(document_xml: bytes, extras: dict[str, bytes] | None = None) -> bytes:
output = BytesIO()
with ZipFile(output, "w", ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", CONTENT_TYPES)
archive.writestr("_rels/.rels", ROOT_RELS)
archive.writestr("word/document.xml", document_xml)
for name, content in (extras or {}).items():
archive.writestr(name, content)
return output.getvalue()
def _simple_document(text: str = "Hello") -> bytes:
return (
''
''
f"{text}"
).encode()
class NoTextOCR:
def extract(self, image: Image.Image) -> list[OCRLine]:
return []
class PersistentSensitiveOCR:
def extract(self, image: Image.Image) -> list[OCRLine]:
return [OCRLine((5, 5, 80, 25), "still.private@example.com", 0.99)]
class OneFaceDetector:
def __init__(self) -> None:
self.calls = 0
def detect(self, image: Image.Image) -> list[tuple[int, int, int, int]]:
self.calls += 1
return [(10, 10, 60, 60)] if self.calls == 1 else []
def test_docx_redacts_split_runs_attributes_links_and_metadata(settings) -> None:
document = b"""
split.user@
example.com
"""
relationships = b"""
"""
app_properties = b"""
Private CompanyPrivate Manager
"""
client = TestClient(create_app(settings, llm_client=object()))
response = client.post(
"/v1/privacy/files/anonymize",
files={
"file": (
"details.docx",
_docx(
document,
{
"word/_rels/document.xml.rels": relationships,
"word/styles.xml": b"""
""",
"docProps/app.xml": app_properties,
},
),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
},
)
assert response.status_code == 200
with ZipFile(BytesIO(response.content)) as archive:
story = archive.read("word/document.xml").decode()
rels = archive.read("word/_rels/document.xml.rels").decode()
metadata = archive.read("docProps/app.xml").decode()
styles = archive.read("word/styles.xml").decode()
assert "split.user@example.com" not in story
assert "tag.user@example.com" not in story
assert "private.example.com" not in rels
assert "link.user@example.com" not in rels
assert "Private Company" not in metadata
assert "Private Manager" not in metadata
assert "style.user@example.com" not in styles
def test_docx_rejects_unsafe_zip_paths(settings) -> None:
client = TestClient(create_app(settings, llm_client=object()))
payload = _docx(_simple_document(), {"../outside.txt": b"not extracted"})
response = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("unsafe.docx", payload, "application/octet-stream")},
)
assert response.status_code == 400
assert response.json()["error"]["type"] == "unsafe_docx"
def test_docx_rejects_embedded_content_that_cannot_be_verified(settings) -> None:
client = TestClient(create_app(settings, llm_client=object()))
payload = _docx(_simple_document(), {"word/embeddings/object.bin": b"opaque"})
response = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("embedded.docx", payload, "application/octet-stream")},
)
assert response.status_code == 415
assert response.json()["error"]["type"] == "unsupported_docx_content"
def test_docx_rejects_unknown_opaque_package_parts(settings) -> None:
source = BytesIO()
with ZipFile(source, "w", ZIP_DEFLATED) as archive:
archive.writestr(
"[Content_Types].xml",
'',
)
archive.writestr(
"word/document.xml",
'',
)
archive.writestr("attachments/private.bin", b"alice@example.com")
client = TestClient(create_app(settings, llm_client=object()))
response = client.post(
"/v1/privacy/files/anonymize",
files={
"file": (
"unsafe.docx",
source.getvalue(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
},
)
assert response.status_code == 415
assert response.json()["error"]["type"] == "unsupported_docx_content"
def test_media_policy_block_is_not_silently_downgraded_to_redaction(settings) -> None:
client = TestClient(create_app(settings, llm_client=object()))
payload = _docx(_simple_document("sk-test-abcdefghijklmnop"))
response = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("secret.docx", payload, "application/octet-stream")},
)
assert response.status_code == 400
assert response.json()["error"]["type"] == "policy_block"
assert response.json()["error"]["entities"] == ["API_KEY"]
def test_media_upload_uses_its_own_body_limit(settings) -> None:
source = BytesIO()
Image.new("RGB", (96, 96), "white").save(source, format="BMP")
media_settings = replace(
settings,
max_request_body_bytes=1_024,
max_media_file_bytes=64 * 1_024,
)
client = TestClient(create_app(media_settings, llm_client=object(), ocr_adapter=NoTextOCR()))
response = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("large.bmp", source.getvalue(), "image/bmp")},
)
assert len(source.getvalue()) > media_settings.max_request_body_bytes
assert response.status_code == 200
def test_image_redaction_fails_closed_when_verification_still_detects_pii(settings) -> None:
source = BytesIO()
Image.new("RGB", (100, 40), "white").save(source, format="PNG")
client = TestClient(
create_app(
settings,
llm_client=object(),
ocr_adapter=PersistentSensitiveOCR(),
)
)
response = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("persistent.png", source.getvalue(), "image/png")},
)
assert response.status_code == 422
assert response.json()["error"]["type"] == "redaction_verification_failed"
def test_image_anonymization_burns_detected_faces_into_pixels(settings) -> None:
source = BytesIO()
Image.new("RGB", (100, 80), "white").save(source, format="PNG")
client = TestClient(
create_app(
settings,
llm_client=object(),
ocr_adapter=NoTextOCR(),
face_detector=OneFaceDetector(),
)
)
response = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("портрет.png", source.getvalue(), "image/png")},
)
assert response.status_code == 200
assert response.headers["x-maskgate-redactions"] == "1"
assert response.headers["x-maskgate-entity-types"] == "FACE"
assert response.headers["content-disposition"] == 'attachment; filename="image.masked.png"'
with Image.open(BytesIO(response.content)) as sanitized:
assert sanitized.getpixel((20, 20)) == (0, 0, 0)
def test_media_endpoint_rejects_unknown_and_malformed_files(settings) -> None:
client = TestClient(create_app(settings, llm_client=object(), ocr_adapter=NoTextOCR()))
unknown = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("notes.txt", b"private.user@example.com", "text/plain")},
)
invalid_pdf = client.post(
"/v1/privacy/files/anonymize",
files={"file": ("broken.pdf", b"%PDF-not-really-a-pdf", "application/pdf")},
)
assert unknown.status_code == 415
assert unknown.json()["error"]["type"] == "unsupported_media_type"
assert invalid_pdf.status_code == 422
assert invalid_pdf.json()["error"]["type"] == "invalid_pdf"