Files
laser-data-hub/tests/test_images.py
T
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

325 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import pytest
from fastapi import HTTPException
from app.images import MAX_IMAGES_PER_RECORD, inspect_image, sniff
from conftest import jpeg_bytes, png_bytes, record_payload
def upload(client, headers, data=None, name="shot.png", content_type="image/png", thumbnail=True):
files = {"file": (name, data if data is not None else png_bytes(), content_type)}
if thumbnail:
files["thumbnail"] = ("thumb.png", png_bytes(8, 6), "image/png")
return client.post("/api/uploads/images", headers=headers, files=files)
def test_headers_identify_supported_formats():
assert sniff(png_bytes(40, 25)) == ("image/png", 40, 25)
assert sniff(jpeg_bytes(640, 480)) == ("image/jpeg", 640, 480)
@pytest.mark.parametrize("blob", [
b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
b"<!doctype html><h1>not an image</h1>",
b"GIF89a\x01\x00\x01\x00\x00\x00\x00", # GIF 不在白名单内
png_bytes()[:12], # 截断文件
b"\xff\xd8\xff" + b"\x00" * 40, # 伪造 JPEG 头但没有 SOF
])
def test_non_image_payloads_are_rejected(blob):
with pytest.raises(HTTPException) as error:
inspect_image(blob)
assert error.value.status_code in (400, 413, 415)
def test_oversized_payload_is_rejected():
with pytest.raises(HTTPException) as error:
inspect_image(png_bytes(), limit=10)
assert error.value.status_code == 413
def test_absurd_dimensions_are_rejected():
with pytest.raises(HTTPException) as error:
inspect_image(jpeg_bytes(30000, 30000))
assert error.value.status_code == 413
def test_upload_requires_authentication(client):
client.cookies.clear()
assert client.post("/api/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 401
def test_upload_requires_csrf_token(admin):
client, _ = admin
assert client.post("/api/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 403
def test_upload_returns_metadata_and_token(admin):
client, headers = admin
body = upload(client, headers, png_bytes(64, 48)).json()
assert body["ok"] and body["data"]["width"] == 64 and body["data"]["height"] == 48
assert body["data"]["content_type"] == "image/png" and body["data"]["token"]
def test_disguised_html_upload_is_rejected(admin):
client, headers = admin
response = upload(client, headers, b"<script>alert(1)</script>", name="evil.png")
assert response.status_code == 415
def test_pending_image_is_private_until_attached(admin):
client, headers = admin
image_id = upload(client, headers).json()["data"]["id"]
assert client.get(f"/api/images/{image_id}").status_code == 200
client.cookies.clear()
assert client.get(f"/api/images/{image_id}").status_code == 404
def test_attached_image_is_public_and_cacheable(admin):
client, headers = admin
token = upload(client, headers, png_bytes(50, 40)).json()["data"]["token"]
created = client.post("/api/records", headers=headers, json=record_payload("LAS-PUB-001", image_tokens=[token]))
assert created.status_code == 200, created.text
record_id = created.json()["data"]["id"]
images = client.get(f"/api/public/records/{record_id}").json()["data"]["images"]
assert len(images) == 1
client.cookies.clear()
response = client.get(images[0]["url"])
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.headers["x-content-type-options"] == "nosniff"
assert "immutable" in response.headers["cache-control"]
assert client.get(response.url, headers={"If-None-Match": response.headers["etag"]}).status_code == 304
def test_record_detail_never_leaks_binary_payload(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-BLOB-001", image_tokens=[token])).json()["data"]["id"]
data = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]
assert "data" not in data and "thumbnail" not in data
assert data["images"][0]["url"].startswith("/api/images/")
def test_more_than_six_images_are_refused(admin):
client, headers = admin
tokens = [upload(client, headers).json()["data"]["token"] for _ in range(MAX_IMAGES_PER_RECORD + 1)]
response = client.post("/api/records", headers=headers, json=record_payload("LAS-MANY-001", image_tokens=tokens))
assert response.status_code == 422
assert "6" in response.json()["message"]
def test_unknown_or_reused_token_is_refused(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
assert client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-001", image_tokens=[token])).status_code == 200
reused = client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-002", image_tokens=[token]))
assert reused.status_code == 404
assert client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-003", image_tokens=["nope"])).status_code == 404
def test_editing_drops_images_left_out_of_the_keep_list(admin):
client, headers = admin
tokens = [upload(client, headers).json()["data"]["token"] for _ in range(2)]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-EDIT-001", image_tokens=tokens)).json()["data"]["id"]
images = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]
assert len(images) == 2
kept = images[1]["id"]
updated = client.put(f"/api/records/{record_id}", headers=headers, json=record_payload("LAS-EDIT-001", image_ids=[kept]))
assert updated.status_code == 200, updated.text
remaining = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]
assert [item["id"] for item in remaining] == [kept]
assert client.get(f"/api/images/{images[0]['id']}").status_code == 404
def test_images_of_another_record_cannot_be_claimed(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
first = client.post("/api/records", headers=headers, json=record_payload("LAS-OWN-001", image_tokens=[token])).json()["data"]["id"]
image_id = client.get(f"/api/records/{first}", headers=headers).json()["data"]["images"][0]["id"]
second = client.post("/api/records", headers=headers, json=record_payload("LAS-OWN-002")).json()["data"]["id"]
response = client.put(f"/api/records/{second}", headers=headers, json=record_payload("LAS-OWN-002", image_ids=[image_id]))
assert response.status_code == 404
def test_deleting_a_record_removes_its_images(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-DEL-001", image_tokens=[token])).json()["data"]["id"]
image_id = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]["id"]
assert client.delete(f"/api/records/{record_id}", headers=headers).status_code == 200
assert client.get(f"/api/images/{image_id}").status_code == 404
def test_expired_pending_images_are_purged(admin):
from datetime import datetime, timedelta
from app.db import SessionLocal
from app.models import RecordImage
client, headers = admin
stale_token = upload(client, headers).json()["data"]["token"]
with SessionLocal() as db:
stale = db.query(RecordImage).filter_by(token=stale_token).one()
stale.created_at = datetime.now().astimezone() - timedelta(hours=5)
db.commit()
assert upload(client, headers).status_code == 200 # 任何一次新上传都会顺手清理孤儿
with SessionLocal() as db: # 按 token 判断:SQLite 会复用被删除行的 rowid,按 id 判断会误判
assert db.query(RecordImage).filter_by(token=stale_token).count() == 0
def test_viewer_role_cannot_upload(admin):
client, headers = admin
created = client.post("/api/users", headers=headers, json={"username": "readonly1", "display_name": "只读", "password": "ViewerPass123", "role": "viewer"})
assert created.status_code in (200, 409)
client.cookies.clear()
client.post("/api/auth/login", data={"username": "readonly1", "password": "ViewerPass123"})
import hashlib
import hmac
from app.config import settings
from app.main import SESSION_COOKIE
csrf = hmac.new(settings.secret.encode(), client.cookies.get(SESSION_COOKIE).encode(), hashlib.sha256).hexdigest()
assert upload(client, {"X-CSRF-Token": csrf}).status_code == 403
def test_filename_injection_characters_are_stripped(admin):
client, headers = admin
files = {"file": ('x" onload="alert(1)".png', png_bytes(), "image/png")}
body = client.post("/api/uploads/images", headers=headers, files=files).json()
stored = body["data"]["original_name"]
assert '"' not in stored and "<" not in stored and ">" not in stored and "'" not in stored
def test_payload_appended_after_image_end_marker_is_discarded(admin):
client, headers = admin
smuggled = png_bytes() + b"<html><script>alert(1)</script></html>" * 20
body = client.post("/api/uploads/images", headers=headers, files={"file": ("ok.png", smuggled, "image/png")}).json()
assert body["data"]["size_bytes"] == len(png_bytes())
token = body["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-TRIM-001", image_tokens=[token])).json()["data"]["id"]
url = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]["url"]
assert b"<script>" not in client.get(url).content
def test_webp_is_no_longer_accepted(admin):
"""WebP 的 RIFF 头几乎不校验内容,16 字节垃圾就能冒充一张图,索性不收。"""
import struct
client, headers = admin
fake = b"RIFF" + struct.pack("<I", 0) + b"WEBPVP8X" + b"\x00" * 20
assert client.post("/api/uploads/images", headers=headers, files={"file": ("a.webp", fake, "image/webp")}).status_code == 415
def test_update_without_image_ids_keeps_existing_images(admin):
"""省略 image_ids 必须是"不动图片",否则任何不了解该字段的旧页面或脚本都会静默删光图。"""
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-KEEP-001", image_tokens=[token])).json()["data"]["id"]
payload = record_payload("LAS-KEEP-001")
assert "image_ids" not in payload
assert client.put(f"/api/records/{record_id}", headers=headers, json=payload).status_code == 200
assert len(client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]) == 1
def test_explicit_empty_image_ids_clears_images(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-CLEAR-001", image_tokens=[token])).json()["data"]["id"]
client.put(f"/api/records/{record_id}", headers=headers, json=record_payload("LAS-CLEAR-001", image_ids=[]))
assert client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"] == []
def test_upload_without_content_length_is_refused(admin):
"""没有 Content-Length 就无法预判体积,而 multipart 在鉴权之前就已落盘,必须直接拒绝。"""
client, headers = admin
response = client.post("/api/uploads/images", headers={**headers, "Content-Type": "multipart/form-data; boundary=B", "Transfer-Encoding": "chunked"}, content=iter([b"--B--\r\n"]))
assert response.status_code == 411
def test_detail_endpoints_do_not_load_image_binaries(admin):
"""图片二进制是 deferred 列:公开详情是匿名接口,一次请求把几 MB 图片读进内存会被用来打内存。"""
from sqlalchemy import event
from app.db import engine
client, headers = admin
token = upload(client, headers, png_bytes(120, 90)).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-DEFER-001", image_tokens=[token])).json()["data"]["id"]
statements = []
listener = lambda conn, cursor, statement, *rest: statements.append(statement)
event.listen(engine, "before_cursor_execute", listener)
try:
client.get(f"/api/public/records/{record_id}")
finally:
event.remove(engine, "before_cursor_execute", listener)
selected = " ".join(statements).lower()
assert "record_images.data" not in selected and "record_images.thumbnail\n" not in selected
def test_public_detail_hides_original_filename(admin):
"""原始文件名可能带姓名、车间、项目代号,公开看板不返回。"""
client, headers = admin
token = upload(client, headers, name="张三-三车间-复检.png").json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-PRIV-001", image_tokens=[token])).json()["data"]["id"]
public_image = client.get(f"/api/public/records/{record_id}").json()["data"]["images"][0]
assert "original_name" not in public_image and "size_bytes" not in public_image
assert set(public_image) == {"id", "width", "height", "url", "thumb_url"}
assert "original_name" in client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]
def test_broken_thumbnail_does_not_fail_the_upload(admin):
"""缩略图只是加速手段,坏了就回落到原图,不能把整次上传拖垮。"""
client, headers = admin
files = {"file": ("ok.png", png_bytes(), "image/png"), "thumbnail": ("bad.png", b"not an image at all", "image/png")}
body = client.post("/api/uploads/images", headers=headers, files=files)
assert body.status_code == 200
assert body.json()["data"]["thumb_url"] == body.json()["data"]["url"]
def test_pending_image_can_be_discarded_to_free_its_slot(admin):
from app.db import SessionLocal
from app.models import RecordImage
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 200
with SessionLocal() as db:
assert db.query(RecordImage).filter_by(token=token).count() == 0
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 404
def test_attached_image_cannot_be_discarded_through_the_upload_endpoint(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
client.post("/api/records", headers=headers, json=record_payload("LAS-DISC-001", image_tokens=[token]))
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 404
def test_backup_size_column_holds_more_than_two_gigabytes():
"""图片入库后备份很容易超过 2 GiBPostgreSQL 的 integer 到那里就溢出了。"""
from sqlalchemy import BigInteger
from app.models import BackupRecord
assert isinstance(BackupRecord.__table__.c.size_bytes.type, BigInteger)
def test_unauthenticated_upload_never_buffers_the_request_body(client):
"""鉴权必须发生在 multipart 缓冲之前,否则未登录请求也能驱动服务器往磁盘写临时文件。"""
consumed = []
class Probe:
def __iter__(self):
consumed.append(True)
yield b'--B\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n' + b"x" * 200_000 + b"\r\n--B--\r\n"
client.cookies.clear()
response = client.post("/api/uploads/images", headers={"Content-Type": "multipart/form-data; boundary=B", "Content-Length": "200200"}, content=Probe())
assert response.status_code == 401
assert not consumed, "未登录请求的 body 不应被读取"