POSA_Copyrighter/tests/operator_gui/test_browser_smoke.py
유창욱 3f7b3a9cf2 chore: initial commit of copyrighter (rights_filter)
Image rights / copyright detection system: SQLite store, HTTP app,
search integrations (Naver, Google Custom Search, Google Cloud Vision
web detection), image analysis (fingerprints, face/person detection,
evidence enrichment, risk scoring), an admin/review layer, governance
and retention policies, batch jobs, and a browser-based operator GUI.

This baseline incorporates a full code-review remediation pass
(46 fixes; 358 tests passing). Highlights:

CRITICAL
- Prevent evidence cascade-delete during the schema-constraint
  migration by disabling FK enforcement around the table rebuild.

Security
- Sandbox served media (neutralize stored XSS from uploaded/collected
  SVGs) via CSP + nosniff on the untrusted media routes.
- Strip embedded EXIF/GPS from external image derivatives before they
  are sent to third-party APIs.
- Return a clean 404 (not an uncaught StopIteration) for PATCH on an
  unknown provider.

Correctness
- LLM-summary failures no longer add +30 to the risk score.
- Decode only explicit JS escapes so Korean image URLs are not mangled.
- Consume search quota only after a successful request.
- Naver/Google adapters map responses inside the failure boundary, so a
  malformed response degrades to evidence instead of crashing enrichment.
- Domain-aware provider attribution; face-box IoU de-duplication; count
  searches (not result items); per-box crop isolation; clamp evidence
  confidence and Google CSE num; real submittedEpoch; and more.

Robustness
- Offline LLM connect fast-fails (short connect timeout) so seed/reload
  requests are not stalled; full read timeout preserved for generation.
- Malformed numeric env vars fall back to defaults instead of crashing
  startup.

Performance
- Per-submission evidence reads (no full-table scan per rescore),
  audit-log LIMIT, lazy active-store lookup, hoisted timestamps.

Tests
- ~24 regression tests added pinning the above fixes.

Runtime data (data/, outputs/, *.sqlite3, *.log), secrets (.env), and
node_modules are gitignored.
2026-06-09 09:50:31 +09:00

191 lines
8 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
import sys
from threading import Thread
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "src"))
APP_DIR = ROOT / "web" / "operator-gui"
from rights_filter.server.http_app import build_server
from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server.sqlite_store import CopyrighterStore
def _start(server):
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
return thread
def _browser_or_skip(playwright):
try:
return playwright.chromium.launch(headless=True)
except Exception as exc:
pytest.skip(f"Playwright Chromium is not available: {exc}")
def _bootstrap_payload():
return {
"submissions": [
{
"id": "SUB-SMOKE1",
"title": "Smoke sample",
"asset": "/assets/case-portrait.svg",
"riskScore": 42,
"riskBand": "medium",
"submittedAt": "2026-06-03 10:00",
"submittedEpoch": 1780452000,
"lastAnalysis": "2026-06-03 10:01",
"applicantStatus": "검토 중",
"decisionStatus": "unreviewed",
"reasons": ["Naver search returned no results"],
"providerState": {"internal": "ok", "naver": "empty", "google": "disabled", "llm": "pending"},
"fileFacts": {"size": "320 x 240", "format": "SVG", "submitted": "2026-06-03 10:00", "analysis": "v1"},
"derivativeNote": "브라우저 smoke test submission",
"recommendation": {"label": "운영자 검토 필요", "detail": "검색 근거가 부족합니다."},
"derivedPreview": {"automatic": False, "entryName": "Smoke sample", "effect": "보강 검색 필요"},
"queryHistory": [
{
"provider": "naver",
"query": "Smoke sample official",
"status": "empty",
"timestamp": "2026-06-03 10:01",
"count": 0,
}
],
"similar": [{"asset": "/assets/case-portrait.svg", "label": "local submission"}],
"evidence": [
{
"id": "ev-smoke-empty",
"group": "naver",
"source": "naver",
"title": "Naver search returned no results",
"confidence": 0,
"query": "Smoke sample official",
"domain": "naver",
"url": "",
"imageUrl": "",
"thumbnailUrl": "",
"pageTitle": "",
"matchType": "empty",
"rank": "",
"providerScore": 0,
"retrievedAt": "2026-06-03 10:01",
"contributed": False,
"sourceEvidenceIds": [],
"status": "active",
}
],
}
],
"submissionQueue": {"label": "smoke", "folderPath": "smoke", "isActive": True},
"providers": [{"id": "naver", "name": "Naver", "enabled": True, "quota": 100, "usage": 0, "status": "ok"}],
"knowledgeEntries": [],
"collectionCandidates": [],
"corrections": [],
"auditEvents": [],
"coverageThresholds": {"coverageGoodRate": 70, "coverageWarnRate": 40, "queryGoodRate": 70, "queryWarnRate": 40},
"searchCoverage": {
"submissions": {"total": 1, "coverageSubmissions": 0},
"queries": {"failed": 0},
"providers": [{"id": "naver", "name": "Naver", "queryEntries": 1, "evidenceSubmissions": 0}],
},
}
def test_browser_smoke_suggested_query_fills_manual_query_without_running_search(tmp_path: Path):
playwright = pytest.importorskip("playwright.sync_api")
image_root = tmp_path / "submissions"
image_root.mkdir()
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
store.initialize()
server = build_server(
host="127.0.0.1",
port=0,
store=store,
image_store=LocalSubmissionImageStore(image_root),
static_dir=APP_DIR,
)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
try:
with playwright.sync_playwright() as pw:
browser = _browser_or_skip(pw)
page = browser.new_page(viewport={"width": 1280, "height": 900})
browser_errors = []
page.on("console", lambda message: browser_errors.append(f"console:{message.type}:{message.text}"))
page.on("pageerror", lambda error: browser_errors.append(f"pageerror:{error}"))
page.route(
"**/api/bootstrap",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(_bootstrap_payload(), ensure_ascii=False),
),
)
page.goto(base + "/")
try:
page.wait_for_selector('#queue-body [data-select-case="SUB-SMOKE1"]', timeout=5000)
except Exception as exc:
pytest.fail(f"{exc}\nerrors={browser_errors}\nbody={page.locator('body').inner_text()[:1000]}")
page.get_by_role("button", name="SUB-SMOKE1").click()
page.get_by_role("button", name="Smoke sample 저작권").click()
assert page.locator('[data-workbench-panel="queries"]').is_visible()
assert page.locator("#manual-query").input_value() == "Smoke sample 저작권"
assert page.locator("#manual-query-status").inner_text() == "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요.", browser_errors
browser.close()
finally:
server.shutdown()
def test_browser_uploads_image_and_selects_new_submission(tmp_path: Path):
playwright = pytest.importorskip("playwright.sync_api")
image_root = tmp_path / "submissions"
image_root.mkdir()
upload_file = tmp_path / "smoke upload.svg"
upload_file.write_text("<svg xmlns='http://www.w3.org/2000/svg' width='80' height='60'></svg>", encoding="utf-8")
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
store.initialize()
server = build_server(
host="127.0.0.1",
port=0,
store=store,
image_store=LocalSubmissionImageStore(image_root),
static_dir=APP_DIR,
)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
try:
with playwright.sync_playwright() as pw:
browser = _browser_or_skip(pw)
page = browser.new_page(viewport={"width": 1280, "height": 900})
browser_errors = []
page.on("console", lambda message: browser_errors.append(f"console:{message.type}:{message.text}"))
page.on("pageerror", lambda error: browser_errors.append(f"pageerror:{error}"))
page.goto(base + "/")
page.set_input_files("#submission-image", str(upload_file))
assert page.locator("#submission-image-name").inner_text() == "smoke upload.svg"
page.get_by_role("button", name="사진 넣기").click()
page.wait_for_selector('#queue-body [data-select-case="smoke-upload"]', state="attached", timeout=10000)
page.wait_for_selector("#workbench-view", state="visible", timeout=10000)
assert (image_root / "images" / "smoke-upload.svg").exists()
assert "smoke-upload 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다." in page.locator("#submission-import-status").inner_text()
assert page.locator("#case-title").inner_text() == "smoke-upload · smoke-upload"
assert not browser_errors
browser.close()
finally:
server.shutdown()