import time import uuid from contextlib import contextmanager import httpx import pytest import urllib3 from fastapi import HTTPException from minio.error import S3Error from sqlalchemy import func, inspect, select from app.core.config import get_settings from app.services.html_filter import filter_html _PUBLIC_KEYS = { "siteName", "logoUrl", "bannerUrls", "icpText", "policeText", "friendLinks", "linkCirea", "linkHousing", } def test_object_key_keeps_safe_extension(monkeypatch): monkeypatch.setenv("MINIO_BASE_URL", "http://127.0.0.1:9000/gsfdc-public/") get_settings.cache_clear() try: from app.services.storage import make_object_key, public_object_url, safe_filename assert safe_filename("../../logo.PNG") == "logo.PNG" key = make_object_key("../../logo.PNG") assert ".." not in key assert key.endswith(".png") url = public_object_url(key) assert url == "http://127.0.0.1:9000/gsfdc-public/" + key assert "//" not in url.replace("://", "") finally: get_settings.cache_clear() def test_html_whitelist_removes_active_content(): html = ( '

正文敏感词

' '链接' "" "" '
前后
' 'x' 'ok' ) cleaned = filter_html(html, ["2026/10/19/abc.png"], "http://127.0.0.1:9000/gsfdc-public") lowered = cleaned.lower() assert " bool: get_settings.cache_clear() return bool(get_settings().database_url.strip()) def _load_routers(): try: from app.api.admin.content import router as admin_router from app.api.site.settings import router as site_router except ModuleNotFoundError: pytest.skip("app.db 尚未就绪") return admin_router, site_router @pytest.fixture def api_client(monkeypatch): monkeypatch.setenv("JWT_SECRET", "test-secret-must-be-32-characters!") monkeypatch.setenv("API_PAYLOAD_ENCRYPT", "false") get_settings.cache_clear() admin_router, site_router = _load_routers() from fastapi.testclient import TestClient from app.main import create_app app = create_app() paths = {getattr(route, "path", "") for route in app.routes} if "/api/admin/files" not in paths: app.include_router(admin_router, prefix="/api/admin") if "/api/site/settings" not in paths: app.include_router(site_router, prefix="/api/site") with TestClient(app) as client: yield client get_settings.cache_clear() def _staff_headers() -> dict[str, str]: from app.core.security import create_access_token token = create_access_token("1", "staff") return {"Authorization": f"Bearer {token}"} @contextmanager def _session(): from app.db import get_db resource = get_db() if hasattr(resource, "__next__"): db = next(resource) try: yield db finally: db.rollback() resource.close() return try: yield resource finally: resource.rollback() resource.close() def _prepare_tables() -> None: from app.db import Base from app.models.content import AdminFile, SensitiveWord, SiteSetting, ensure_site_defaults with _session() as db: Base.metadata.create_all( bind=db.get_bind(), tables=[SiteSetting.__table__, AdminFile.__table__, SensitiveWord.__table__], ) ensure_site_defaults(db) def _table_names() -> set[str]: with _session() as db: return set(inspect(db.get_bind()).get_table_names()) def _count_files() -> int: from app.models.content import AdminFile with _session() as db: return int(db.scalar(select(func.count()).select_from(AdminFile)) or 0) @pytest.fixture def ready_client(api_client): if not _database_configured(): pytest.skip("DATABASE_URL 为空") _prepare_tables() return api_client def test_file_list_matches_filename(ready_client): from app.db import SessionLocal from app.models.content import AdminFile token = uuid.uuid4().hex[:10] names = [f"检索甲-{token}.txt", f"检索乙-{token}.png", f"其他-{token}.txt"] db = SessionLocal() ids: list[int] = [] try: for name in names: row = AdminFile( bucket="public", object_key=f"test/{token}/{name}", filename=name, content_type="text/plain", size=1, ) db.add(row) db.flush() ids.append(row.id) db.commit() finally: db.close() headers = _staff_headers() try: found = ready_client.get( "/api/admin/files", headers=headers, params={"q": f"检索甲-{token}", "size": 20}, ) assert found.status_code == 200, found.text body = found.json() assert [item["filename"] for item in body["records"]] == [f"检索甲-{token}.txt"] assert body["total"] == 1 wildcard = ready_client.get( "/api/admin/files", headers=headers, params={"q": "%", "size": 100}, ) assert wildcard.status_code == 200, wildcard.text assert f"检索甲-{token}.txt" not in [item["filename"] for item in wildcard.json()["records"]] finally: db = SessionLocal() try: for file_id in ids: row = db.get(AdminFile, file_id) if row is not None: db.delete(row) db.commit() finally: db.close() def test_admin_content_requires_login(api_client): checks = [ ("get", "/api/admin/files", None), ("get", "/api/admin/files/1", None), ("get", "/api/admin/site-settings", None), ("get", "/api/admin/sensitive-words", None), ("put", "/api/admin/site-settings", {"site_name": "x"}), ("post", "/api/admin/sensitive-words", {"word": "x"}), ("post", "/api/admin/content/preview-filter", {"html": "

a

"}), ("delete", "/api/admin/sensitive-words/1", None), ("delete", "/api/admin/files/1", None), ] for method, path, payload in checks: kwargs = {"json": payload} if payload is not None else {} response = getattr(api_client, method)(path, **kwargs) assert response.status_code == 401, path uploaded = api_client.post( "/api/admin/files", data={"bucket": "public"}, files={"file": ("a.txt", b"hi", "text/plain")}, ) assert uploaded.status_code == 401 def test_preview_filter_reports_words_without_new_tables(ready_client): headers = _staff_headers() word = "预览" + uuid.uuid4().hex[:12] created = ready_client.post("/api/admin/sensitive-words", json={"word": word}, headers=headers) assert created.status_code == 200, created.text word_id = created.json()["id"] try: before_tables = _table_names() before_count = len(ready_client.get("/api/admin/sensitive-words", headers=headers).json()) html = f'

正文{word}

' response = ready_client.post( "/api/admin/content/preview-filter", json={"html": html}, headers=headers, ) assert response.status_code == 200, response.text body = response.json() assert word in body["hits"] assert body["html"].count(word) == 1 assert " bool: from app.services.storage import ensure_buckets try: ensure_buckets() except (urllib3.exceptions.HTTPError, OSError, TimeoutError): return False except (HTTPException, S3Error): return False return True def test_public_object_and_private_expiry(ready_client): if not _minio_ready(): pytest.skip("MinIO 不可达") from app.models.content import AdminFile from app.services.storage import private_object_url headers = _staff_headers() payload = b"gsfdc-public-png" created_ids: list[int] = [] try: uploaded = ready_client.post( "/api/admin/files", headers=headers, data={"bucket": "public"}, files={"file": ("logo.png", payload, "image/png")}, ) assert uploaded.status_code == 200, uploaded.text public_body = uploaded.json() created_ids.append(public_body["id"]) assert public_body["expiresAt"] is None base = get_settings().minio_base_url.strip().rstrip("/") assert public_body["url"].startswith(base + "/") assert "//" not in public_body["url"].replace("://", "") downloaded = httpx.get(public_body["url"], timeout=5.0, follow_redirects=True) assert downloaded.status_code == 200 assert downloaded.content == payload private = ready_client.post( "/api/admin/files", headers=headers, data={"bucket": "private"}, files={"file": ("secret.bin", b"gsfdc-private-bin", "application/octet-stream")}, ) assert private.status_code == 200, private.text private_body = private.json() created_ids.append(private_body["id"]) assert private_body["expiresAt"] with _session() as db: row = db.get(AdminFile, private_body["id"]) assert row is not None assert row.created_by == 1 object_key = row.object_key fresh, _ = private_object_url(object_key, expires_seconds=60) assert httpx.get(fresh, timeout=5.0, follow_redirects=True).status_code == 200 short, _ = private_object_url(object_key, expires_seconds=1) time.sleep(2) expired = httpx.get(short, timeout=5.0, follow_redirects=True) assert expired.status_code != 200 finally: for file_id in created_ids: ready_client.delete(f"/api/admin/files/{file_id}", headers=headers)