Files
info-privacy/tests/test_integration_real.py
qiurui cbfe4a23dc feat: info-privacy PII detection service with frame analysis support
- Pipeline: analyze(), redact(), analyze_image() methods
- API: /analyze, /redact, /analyze/frame, /analyze/frame/base64 endpoints
- Detectors: regex, NER, face (RKNN NPU)
- Privacy frame route added for KVM-Privacy Hub integration
2026-02-28 17:33:11 +08:00

437 lines
16 KiB
Python

# tests/test_integration_real.py
"""真实数据集成测试:生成含 PII 的真实文档,验证检测 + 遮罩全链路。
格式覆盖:
PDF(文字层)/ DOCX / XLSX / PNG 图像遮罩
保密文件拦截 / 多类型混合检测
"""
from __future__ import annotations
import io
import os
import sys
import textwrap
from pathlib import Path
import cv2
import docx as _docx
import numpy as np
import openpyxl
import pytest
from pdfminer.high_level import extract_text
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from info_privacy.pipeline import PrivacyPipeline
from info_privacy.models import EntityType
# ─── 字体注册 ─────────────────────────────────────────────────────────────────
_FONT_PATH = "/usr/share/fonts/truetype/arphic/uming.ttc"
_FONT_NAME = "UMing"
if os.path.exists(_FONT_PATH):
pdfmetrics.registerFont(TTFont(_FONT_NAME, _FONT_PATH, subfontIndex=0))
_HAS_FONT = True
else:
_HAS_FONT = False
_needs_font = pytest.mark.skipif(not _HAS_FONT, reason="uming.ttc 字体不存在")
# ─── 测试数据常量 ─────────────────────────────────────────────────────────────
ID_CARD = "110101199001011234"
PHONE = "13812345678"
BANK = "6222021234567890123"
EMAIL = "zhang.wei@example.com"
PLATE = "京A12345"
NAME_CTX = "申请人:张伟,"
ADDR_CTX = "地址:北京市朝阳区建国路88号"
PII_PARAGRAPH = (
f"申请人:张伟,身份证号:{ID_CARD}\n"
f"联系电话:{PHONE},邮箱:{EMAIL}\n"
f"银行卡:{BANK},车牌:{PLATE}\n"
f"{ADDR_CTX}"
)
# ─── 工具函数 ──────────────────────────────────────────────────────────────────
def _make_pdf(path: str, lines: list[str]) -> None:
"""用 reportlab 生成含中文文字层的 PDF。"""
c = canvas.Canvas(path, pagesize=A4)
c.setFont(_FONT_NAME, 14)
y = 750
for line in lines:
c.drawString(50, y, line)
y -= 30
c.save()
def _pdf_text(path: str) -> str:
return extract_text(path)
def _docx_text(path: str) -> str:
doc = _docx.Document(path)
return "\n".join(p.text for p in doc.paragraphs)
def _xlsx_text(path: str) -> str:
wb = openpyxl.load_workbook(path, data_only=True)
parts = []
for ws in wb.worksheets:
for row in ws.iter_rows(values_only=True):
parts.extend(str(c) for c in row if c is not None)
return " ".join(parts)
@pytest.fixture(scope="module")
def pipeline():
return PrivacyPipeline()
# ─── PDF(文字层)测试 ────────────────────────────────────────────────────────
@_needs_font
def test_pdf_analyze_detects_id_card(tmp_path, pipeline):
pdf = str(tmp_path / "pii.pdf")
_make_pdf(pdf, [
f"申请人:张伟",
f"身份证号:{ID_CARD}",
f"联系电话:{PHONE}",
f"邮箱:{EMAIL}",
])
report = pipeline.analyze(pdf)
assert report.blocked is False
types = {e.type for e in report.entities}
assert EntityType.ID_CARD in types, f"未检测到身份证,实体:{types}"
@_needs_font
def test_pdf_analyze_detects_phone_and_email(tmp_path, pipeline):
pdf = str(tmp_path / "pii.pdf")
_make_pdf(pdf, [f"电话:{PHONE}", f"邮箱:{EMAIL}"])
report = pipeline.analyze(pdf)
types = {e.type for e in report.entities}
assert EntityType.PHONE in types, f"未检测到手机号,实体:{types}"
assert EntityType.EMAIL in types, f"未检测到邮箱,实体:{types}"
@_needs_font
def test_pdf_analyze_detects_bank_and_plate(tmp_path, pipeline):
pdf = str(tmp_path / "pii.pdf")
_make_pdf(pdf, [f"银行卡:{BANK}", f"车牌:{PLATE}"])
report = pipeline.analyze(pdf)
types = {e.type for e in report.entities}
assert EntityType.BANK_CARD in types, f"未检测到银行卡,实体:{types}"
assert EntityType.LICENSE_PLATE in types, f"未检测到车牌,实体:{types}"
@_needs_font
def test_pdf_redact_removes_id_card(tmp_path, pipeline):
pdf = str(tmp_path / "pii.pdf")
out = str(tmp_path / "redacted.pdf")
_make_pdf(pdf, [f"身份证号:{ID_CARD}", f"电话:{PHONE}"])
pipeline.redact(pdf, redact_types=["id_card"], out_path=out)
result_text = _pdf_text(out)
assert ID_CARD not in result_text, "身份证号未被遮罩"
assert "电话" in result_text, "无关内容被误删"
@_needs_font
def test_pdf_redact_multi_type(tmp_path, pipeline):
pdf = str(tmp_path / "pii.pdf")
out = str(tmp_path / "redacted.pdf")
_make_pdf(pdf, [f"身份证:{ID_CARD}", f"电话:{PHONE}", f"邮箱:{EMAIL}"])
pipeline.redact(pdf, redact_types=["id_card", "phone", "email"], out_path=out)
result_text = _pdf_text(out)
assert ID_CARD not in result_text
assert PHONE not in result_text
assert EMAIL not in result_text
@_needs_font
def test_pdf_classified_blocked(tmp_path, pipeline):
pdf = str(tmp_path / "secret.pdf")
_make_pdf(pdf, ["【机密】本文件仅供内部使用", "禁止外传"])
report = pipeline.analyze(pdf)
assert report.blocked is True, "保密文件未被拦截"
assert report.classification.value == "classified"
@_needs_font
def test_pdf_normal_no_false_positive(tmp_path, pipeline):
pdf = str(tmp_path / "normal.pdf")
_make_pdf(pdf, ["第一季度销售报告", "本季度营业额同比增长12%"])
report = pipeline.analyze(pdf)
assert report.blocked is False
# 不应有高置信度 PII
high_risk = [e for e in report.entities if e.security_level == "high"]
assert len(high_risk) == 0, f"正常文档误报高风险实体:{[e.type for e in high_risk]}"
# ─── DOCX 测试 ────────────────────────────────────────────────────────────────
@pytest.fixture
def pii_docx(tmp_path):
doc = _docx.Document()
doc.add_paragraph(f"申请人:张伟 身份证:{ID_CARD}")
doc.add_paragraph(f"联系电话:{PHONE} 邮箱:{EMAIL}")
doc.add_paragraph(f"银行卡号:{BANK}")
doc.add_paragraph(f"住址:北京市朝阳区建国路88号")
p = str(tmp_path / "pii.docx")
doc.save(p)
return p
def test_docx_analyze_detects_id_card(pii_docx, pipeline):
report = pipeline.analyze(pii_docx)
types = {e.type for e in report.entities}
assert EntityType.ID_CARD in types, f"未检测到身份证,实体:{types}"
def test_docx_analyze_detects_phone(pii_docx, pipeline):
report = pipeline.analyze(pii_docx)
types = {e.type for e in report.entities}
assert EntityType.PHONE in types, f"未检测到手机号,实体:{types}"
def test_docx_analyze_detects_name(pii_docx, pipeline):
report = pipeline.analyze(pii_docx)
types = {e.type for e in report.entities}
assert EntityType.NAME in types, f"未检测到姓名,实体:{types}"
def test_docx_analyze_detects_address(pii_docx, pipeline):
report = pipeline.analyze(pii_docx)
types = {e.type for e in report.entities}
assert EntityType.ADDRESS in types, f"未检测到地址,实体:{types}"
def test_docx_summary_counts(pii_docx, pipeline):
report = pipeline.analyze(pii_docx)
assert report.summary.get("id_card", 0) >= 1
assert report.summary.get("phone", 0) >= 1
def test_docx_redact_id_card(pii_docx, tmp_path, pipeline):
out = str(tmp_path / "out.docx")
pipeline.redact(pii_docx, redact_types=["id_card"], out_path=out)
text = _docx_text(out)
assert ID_CARD not in text, "身份证号未被遮罩"
assert "张伟" in text, "姓名被误删"
assert PHONE in text, "手机号被误删"
def test_docx_redact_multi_type(pii_docx, tmp_path, pipeline):
out = str(tmp_path / "out.docx")
pipeline.redact(pii_docx, redact_types=["id_card", "phone", "bank_card"], out_path=out)
text = _docx_text(out)
assert ID_CARD not in text
assert PHONE not in text
assert BANK not in text
def test_docx_redact_preserves_unspecified(pii_docx, tmp_path, pipeline):
"""未指定的类型不应被遮罩。"""
out = str(tmp_path / "out.docx")
pipeline.redact(pii_docx, redact_types=["id_card"], out_path=out)
text = _docx_text(out)
assert EMAIL in text, "邮箱不应被遮罩"
assert BANK in text, "银行卡不应被遮罩"
def test_docx_classified_blocked(tmp_path, pipeline):
doc = _docx.Document()
doc.add_paragraph("【绝密】本文件属于国家机密")
p = str(tmp_path / "secret.docx")
doc.save(p)
report = pipeline.analyze(p)
assert report.blocked is True
assert len(report.entities) == 0, "拦截文件不应返回实体列表"
def test_docx_meta_fields(pii_docx, pipeline):
report = pipeline.analyze(pii_docx)
assert report.doc_meta["format"] == "docx"
assert report.doc_meta["has_text_layer"] is True
assert report.doc_meta["redact_strategy"] == "text_replace"
# ─── XLSX 测试 ────────────────────────────────────────────────────────────────
@pytest.fixture
def pii_xlsx(tmp_path):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "人员信息"
ws.append(["姓名", "身份证号", "手机号", "邮箱", "银行卡"])
ws.append(["张伟", ID_CARD, PHONE, EMAIL, BANK])
ws.append(["李娜", "310101199505051234", "13900001111", "li@test.com", "6222021111111111111"])
p = str(tmp_path / "pii.xlsx")
wb.save(p)
return p
def test_xlsx_analyze_detects_pii(pii_xlsx, pipeline):
report = pipeline.analyze(pii_xlsx)
types = {e.type for e in report.entities}
assert EntityType.ID_CARD in types
assert EntityType.PHONE in types
def test_xlsx_analyze_two_id_cards(pii_xlsx, pipeline):
report = pipeline.analyze(pii_xlsx)
id_count = sum(1 for e in report.entities if e.type == EntityType.ID_CARD)
assert id_count >= 2, f"应检测到至少 2 个身份证,实际:{id_count}"
def test_xlsx_redact_removes_id_cards(pii_xlsx, tmp_path, pipeline):
out = str(tmp_path / "out.xlsx")
pipeline.redact(pii_xlsx, redact_types=["id_card"], out_path=out)
text = _xlsx_text(out)
assert ID_CARD not in text, "身份证号未被遮罩"
assert "310101199505051234" not in text, "第二个身份证号未被遮罩"
def test_xlsx_redact_preserves_headers(pii_xlsx, tmp_path, pipeline):
out = str(tmp_path / "out.xlsx")
pipeline.redact(pii_xlsx, redact_types=["id_card"], out_path=out)
wb = openpyxl.load_workbook(out, data_only=True)
ws = wb.active
headers = [cell.value for cell in ws[1]]
assert "姓名" in headers, "列头被误删"
assert "身份证号" in headers, "列头被误删"
def test_xlsx_meta_fields(pii_xlsx, pipeline):
report = pipeline.analyze(pii_xlsx)
assert report.doc_meta["format"] == "xlsx"
assert report.doc_meta["redact_strategy"] == "text_replace"
# ─── PNG 图像遮罩测试(不依赖 RKNN,直接测试 ImageRedactor)────────────────────
@pytest.fixture
def pii_png(tmp_path):
"""生成包含文字的 PNG(用于测试图像遮罩,OCR 阶段跳过)。"""
img = np.ones((400, 800, 3), dtype=np.uint8) * 240
cv2.putText(img, f"ID: {ID_CARD}", (20, 80),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
cv2.putText(img, f"TEL: {PHONE}", (20, 160),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
cv2.rectangle(img, (200, 220), (400, 320), (180, 200, 220), -1) # 模拟人脸区域
p = str(tmp_path / "pii.png")
cv2.imwrite(p, img)
return p
def test_png_image_redactor_black_box(pii_png, tmp_path):
"""ImageRedactor 对指定 bbox 覆盖纯黑矩形。"""
from info_privacy.redactors.image_redactor import ImageRedactor
from info_privacy.models import Entity, EntityType
out = str(tmp_path / "out.png")
entity = Entity(
id="f1", type=EntityType.FACE, value=None,
page=1, bbox=[200.0, 220.0, 400.0, 320.0],
layer="image", security_level="high",
)
ImageRedactor().redact(pii_png, [entity], out)
result = cv2.imread(out)
region = result[220:320, 200:400]
assert region.max() == 0, "遮罩区域应为纯黑"
def test_png_image_redactor_preserves_size(pii_png, tmp_path):
"""遮罩后图像尺寸不变。"""
from info_privacy.redactors.image_redactor import ImageRedactor
from info_privacy.models import Entity, EntityType
out = str(tmp_path / "out.png")
entity = Entity(
id="f2", type=EntityType.FACE, value=None,
page=1, bbox=[20.0, 40.0, 300.0, 100.0],
layer="image", security_level="high",
)
ImageRedactor().redact(pii_png, [entity], out)
orig = cv2.imread(pii_png)
result = cv2.imread(out)
assert orig.shape == result.shape
def test_png_image_redactor_outside_bbox_intact(pii_png, tmp_path):
"""遮罩区域外的像素不应被修改。"""
from info_privacy.redactors.image_redactor import ImageRedactor
from info_privacy.models import Entity, EntityType
out = str(tmp_path / "out.png")
# 只遮罩右下角
entity = Entity(
id="f3", type=EntityType.FACE, value=None,
page=1, bbox=[600.0, 300.0, 780.0, 380.0],
layer="image", security_level="high",
)
ImageRedactor().redact(pii_png, [entity], out)
orig = cv2.imread(pii_png)
result = cv2.imread(out)
# 左上角应与原图一致
np.testing.assert_array_equal(
orig[0:50, 0:50], result[0:50, 0:50],
err_msg="非遮罩区域像素被修改",
)
def test_png_image_redactor_multiple_entities(pii_png, tmp_path):
"""多个 entity 都被遮罩。"""
from info_privacy.redactors.image_redactor import ImageRedactor
from info_privacy.models import Entity, EntityType
out = str(tmp_path / "out.png")
entities = [
Entity(id="e1", type=EntityType.FACE, value=None,
page=1, bbox=[20.0, 40.0, 400.0, 110.0], layer="image", security_level="high"),
Entity(id="e2", type=EntityType.FACE, value=None,
page=1, bbox=[200.0, 220.0, 400.0, 320.0], layer="image", security_level="high"),
]
ImageRedactor().redact(pii_png, entities, out)
result = cv2.imread(out)
r1 = result[40:110, 20:400]
r2 = result[220:320, 200:400]
assert r1.max() == 0, "第一个遮罩区域未被覆盖"
assert r2.max() == 0, "第二个遮罩区域未被覆盖"
# ─── 综合 summary / warning 测试 ─────────────────────────────────────────────
def test_sensitive_partial_warning_many_entities(tmp_path, pipeline):
"""大量高危实体触发 sensitive_partial 警告。"""
doc = _docx.Document()
for i in range(7):
n = int(ID_CARD[:17]) + i
doc.add_paragraph(f"身份证:{n}X")
p = str(tmp_path / "many.docx")
doc.save(p)
report = pipeline.analyze(p)
if report.warning:
# 有警告时,分类应为 sensitive_partial
assert report.classification.value == "sensitive_partial"
def test_redact_returns_security_report(tmp_path, pipeline):
"""redact() 返回字典包含 redacted 和 strategy_used 字段。"""
doc = _docx.Document()
doc.add_paragraph(f"身份证:{ID_CARD}")
p = str(tmp_path / "t.docx")
doc.save(p)
out = str(tmp_path / "out.docx")
report = pipeline.redact(p, redact_types=["id_card"], out_path=out)
assert "redacted" in report
assert "strategy_used" in report
assert report["strategy_used"] == "text_replace"
assert report["security_guarantee"] == "byte_level"