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