公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
325 lines
16 KiB
Python
325 lines
16 KiB
Python
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 GiB,PostgreSQL 的 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 不应被读取"
|