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'',
b"
not an image
",
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"", 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"" * 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"