Files
Calendarr/backend/tests/test_attachments.py
T
ScarriffleandClaude Opus 5 b885e731ee feat(anhaenge): attachment_count im Termin-JSON
Die Terminliste traegt nur die Anzahl, nicht die Anhaenge selbst. Der
Merge-Read laeuft pro Termin durch eine Schleife -- eine Lazy-Relationship
waere dort eine Abfrage je Termin ueber eine ganze Monatsansicht, und ein
wiederkehrender Termin wuerde dieselbe Liste an jeder Instanz wiederholen.
Stattdessen je Kalender eine gruppierte Abfrage; die Liste holen die
Clients beim Oeffnen der Detailansicht.

mask_busy_event setzt die Zahl ausdruecklich auf 0: dass ein als
"Beschaeftigt" maskierter Termin drei Dateien haette, ist auch eine
Information.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:11:30 +02:00

519 lines
20 KiB
Python

"""Event attachments: content validation, storage and cleanup."""
import io
import os
import shutil
import time
import pytest
from fastapi import HTTPException
from PIL import Image
import attachments_store
import models
from conftest import auth, create_user, register_admin
from database import SessionLocal
RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"}
@pytest.fixture(autouse=True)
def clean_attachment_dir():
"""conftest wipes the tables but not the files — without this the orphan
sweep test would trip over leftovers from earlier tests."""
for child in attachments_store.ATTACH_DIR.iterdir():
shutil.rmtree(child, ignore_errors=True) if child.is_dir() else child.unlink()
yield
# ── Fixtures for file bytes ───────────────────────────────
def png_bytes(size=(8, 8)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, "red").save(buf, "PNG")
return buf.getvalue()
PDF_BYTES = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n"
TXT_BYTES = "Zugangsdaten: siehe Umschlag\n".encode("utf-8")
# ── Helpers ───────────────────────────────────────────────
def _make_calendar(client, token, name="Privat"):
r = client.post("/api/local/calendars", headers=auth(token),
json={"name": name, "color": "#4285f4"})
assert r.status_code == 200, r.text
return r.json()["id"]
def _make_event(client, token, cal_id, title="Termin"):
r = client.post("/api/local/events", headers=auth(token), json={
"calendar_id": cal_id, "title": title,
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
})
assert r.status_code == 200, r.text
return r.json()
def _attach_row(event_uid, event_id, raw=PDF_BYTES, content_type="application/pdf",
ext=".pdf", filename="Rechnung.pdf"):
"""Insert an attachment row and write its bytes, bypassing the API.
Used by the cleanup tests, which must work before the upload endpoint
exists and must be able to build states the API would refuse to create.
"""
stored = attachments_store.new_stored_name(ext)
attachments_store.write_file(stored, raw)
has_thumb = attachments_store.make_thumb(raw, content_type, stored)
db = SessionLocal()
try:
row = models.EventAttachment(
source="local", event_uid=event_uid, event_id=event_id,
filename=filename, stored_name=stored, content_type=content_type,
size_bytes=len(raw), has_thumb=has_thumb,
token=attachments_store.new_token(),
)
db.add(row)
db.commit()
return stored
finally:
db.close()
def _row_count() -> int:
db = SessionLocal()
try:
return db.query(models.EventAttachment).count()
finally:
db.close()
def _exists(stored_name) -> bool:
return attachments_store.path_for(stored_name).exists()
# ── Content sniffing ──────────────────────────────────────
@pytest.mark.parametrize("raw,name,expected", [
(PDF_BYTES, "x.pdf", "application/pdf"),
(png_bytes(), "x.png", "image/png"),
(TXT_BYTES, "notiz.txt", "text/plain"),
(b"a,b\n1,2", "liste.csv", "text/csv"),
(b"# Titel", "doku.md", "text/markdown"),
])
def test_sniff_accepts_allowed_types(raw, name, expected):
content_type, _ = attachments_store.sniff(raw, name)
assert content_type == expected
@pytest.mark.parametrize("raw,name", [
(b'<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>', "x.svg"),
(b"<!doctype html><html><script>alert(1)</script></html>", "x.html"),
(b"print('hi')", "x.py"),
(bytes(range(256)), "x.bin"),
(b"plain text but no known extension", "readme"),
])
def test_sniff_rejects_everything_else(raw, name):
"""SVG and HTML matter most here: both execute script in the app's origin,
where the session token lives in localStorage."""
with pytest.raises(HTTPException) as e:
attachments_store.sniff(raw, name)
assert e.value.status_code == 400
def test_sniff_ignores_the_uploaded_name_for_binary_types():
"""A PNG named .pdf is a PNG. The extension never overrides the bytes."""
content_type, ext = attachments_store.sniff(png_bytes(), "getarnt.pdf")
assert (content_type, ext) == ("image/png", ".png")
def test_stored_name_never_contains_path_separators():
"""The uploaded name is attacker-controlled, and on the CalDAV path so is
the event UID — neither may reach the filesystem."""
_, ext = attachments_store.sniff(PDF_BYTES, "../../../etc/passwd.pdf")
stored = attachments_store.new_stored_name(ext)
assert "/" not in stored and "\\" not in stored and ".." not in stored
path = attachments_store.path_for(stored)
assert attachments_store.ATTACH_DIR in path.parents
def test_safe_filename_strips_directories_and_quotes():
assert attachments_store.safe_filename("../../etc/passwd", ".txt") == "passwd"
assert '"' not in attachments_store.safe_filename('a"b.pdf', ".pdf")
assert attachments_store.safe_filename("", ".pdf") == "anhang.pdf"
def test_thumbnail_only_for_images():
stored = attachments_store.new_stored_name(".png")
attachments_store.write_file(stored, png_bytes((600, 400)))
assert attachments_store.make_thumb(png_bytes((600, 400)), "image/png", stored) is True
assert attachments_store.thumb_path_for(stored).exists()
stored_pdf = attachments_store.new_stored_name(".pdf")
assert attachments_store.make_thumb(PDF_BYTES, "application/pdf", stored_pdf) is False
# ── Cleanup on every delete path ──────────────────────────
def test_deleting_an_event_removes_rows_and_files(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
db = SessionLocal()
ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
db.close()
stored = _attach_row(ev["id"], ev_id)
assert _row_count() == 1 and _exists(stored)
r = client.delete(f"/api/local/events/{ev['id']}", headers=auth(token))
assert r.status_code == 200, r.text
assert _row_count() == 0
assert not _exists(stored)
def test_deleting_a_calendar_removes_rows_and_files(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
db = SessionLocal()
ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
db.close()
stored = _attach_row(ev["id"], ev_id)
r = client.delete(f"/api/local/calendars/{cal_id}", headers=auth(token))
assert r.status_code == 200, r.text
assert _row_count() == 0
assert not _exists(stored)
def test_deleting_a_user_removes_rows_and_files(client):
"""The longest cascade: user -> calendars -> events -> attachments."""
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, bob, "Bobs Kalender")
ev = _make_event(client, bob, cal_id)
db = SessionLocal()
ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
db.close()
stored = _attach_row(ev["id"], ev_id)
r = client.delete(f"/api/users/{bob_id}", headers=auth(admin))
assert r.status_code == 200, r.text
assert _row_count() == 0
assert not _exists(stored)
# ── The background sweep ──────────────────────────────────
def test_sweep_removes_rows_whose_event_is_gone(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
db = SessionLocal()
ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
db.close()
stored = _attach_row(ev["id"], ev_id)
# Delete the event behind the API's back, as a cascade that skipped the
# purge helper would.
db = SessionLocal()
db.query(models.LocalEvent).filter(models.LocalEvent.id == ev_id).delete()
db.commit()
db.close()
db = SessionLocal()
rows, files = attachments_store.sweep(db)
db.close()
assert rows == 1
assert not _exists(stored)
def test_sweep_removes_files_with_no_row_but_respects_the_grace_period():
stored = attachments_store.new_stored_name(".pdf")
path = attachments_store.write_file(stored, PDF_BYTES)
# Fresh file, no row: must survive — an upload in flight writes the bytes
# before it commits the row.
db = SessionLocal()
rows, files = attachments_store.sweep(db)
db.close()
assert files == 0 and path.exists()
# Same file, aged past the grace window: now it is an orphan.
old = time.time() - attachments_store.SWEEP_GRACE_SECONDS - 60
os.utime(path, (old, old))
db = SessionLocal()
rows, files = attachments_store.sweep(db)
db.close()
assert files == 1 and not path.exists()
def test_sweep_leaves_live_attachments_alone(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
db = SessionLocal()
ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
db.close()
stored = _attach_row(ev["id"], ev_id)
old = time.time() - attachments_store.SWEEP_GRACE_SECONDS - 60
for p in (attachments_store.path_for(stored),):
os.utime(p, (old, old))
db = SessionLocal()
rows, files = attachments_store.sweep(db)
db.close()
assert (rows, files) == (0, 0)
assert _row_count() == 1 and _exists(stored)
# ── Upload / download through the API ─────────────────────
def _upload(client, token, uid, raw=PDF_BYTES, name="Rechnung.pdf", mime="application/pdf"):
return client.post(
f"/api/local/events/{uid}/attachments",
headers=auth(token),
files={"file": (name, raw, mime)},
)
def test_upload_list_and_download_roundtrip(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
r = _upload(client, token, ev["id"])
assert r.status_code == 201, r.text
body = r.json()
assert body["filename"] == "Rechnung.pdf"
assert body["content_type"] == "application/pdf"
assert body["has_thumb"] is False
assert body["uploaded_by"]["display_name"] == "admin"
listing = client.get(f"/api/local/events/{ev['id']}/attachments", headers=auth(token))
assert listing.status_code == 200
assert [a["id"] for a in listing.json()] == [body["id"]]
dl = client.get(f"/api/local/attachments/{body['id']}", headers=auth(token))
assert dl.status_code == 200
assert dl.content == PDF_BYTES
assert dl.headers["content-disposition"].startswith("attachment;")
assert dl.headers["x-content-type-options"] == "nosniff"
def test_image_upload_gets_a_thumbnail(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
r = _upload(client, token, ev["id"], png_bytes((640, 480)), "foto.png", "image/png")
assert r.status_code == 201, r.text
assert r.json()["has_thumb"] is True
thumb = client.get(f"/api/local/attachments/{r.json()['id']}/thumb", headers=auth(token))
assert thumb.status_code == 200
assert thumb.headers["content-type"] == "image/jpeg"
def test_declared_mime_is_ignored_in_favour_of_the_bytes(client):
"""A PNG announced as application/pdf is stored as a PNG."""
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
r = _upload(client, token, ev["id"], png_bytes(), "getarnt.pdf", "application/pdf")
assert r.status_code == 201, r.text
assert r.json()["content_type"] == "image/png"
def test_rejects_disallowed_type_and_writes_nothing(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
r = _upload(client, token, ev["id"], b"<svg/>", "x.svg", "image/svg+xml")
assert r.status_code == 400
assert _row_count() == 0
assert not any(p.is_file() for p in attachments_store.ATTACH_DIR.rglob("*"))
def test_rejects_oversized_file(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
big = b"%PDF-1.4\n" + b"x" * attachments_store.MAX_ATTACHMENT_BYTES
r = _upload(client, token, ev["id"], big, "gross.pdf")
assert r.status_code == 413
assert _row_count() == 0
def test_enforces_the_per_event_limit(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
for i in range(attachments_store.MAX_ATTACHMENTS_PER_EVENT):
assert _upload(client, token, ev["id"], name=f"a{i}.pdf").status_code == 201
r = _upload(client, token, ev["id"], name="zuviel.pdf")
assert r.status_code == 409
assert _row_count() == attachments_store.MAX_ATTACHMENTS_PER_EVENT
def test_deleting_an_attachment_removes_its_file(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
att_id = _upload(client, token, ev["id"]).json()["id"]
db = SessionLocal()
stored = db.query(models.EventAttachment).filter(
models.EventAttachment.id == att_id).first().stored_name
db.close()
r = client.delete(f"/api/local/attachments/{att_id}", headers=auth(token))
assert r.status_code == 200, r.text
assert _row_count() == 0
assert not _exists(stored)
# ── Permission matrix ─────────────────────────────────────
def _share(client, owner_token, cal_id, user_id, permission):
r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(owner_token),
json={"user_id": user_id, "permission": permission})
assert r.status_code == 200, r.text
def test_read_share_may_view_but_not_upload_or_delete(client):
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
ev = _make_event(client, admin, cal_id)
att_id = _upload(client, admin, ev["id"]).json()["id"]
_share(client, admin, cal_id, bob_id, "read")
assert client.get(f"/api/local/events/{ev['id']}/attachments",
headers=auth(bob)).status_code == 200
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(bob)).status_code == 200
assert _upload(client, bob, ev["id"], name="nope.pdf").status_code == 403
assert client.delete(f"/api/local/attachments/{att_id}",
headers=auth(bob)).status_code == 403
def test_read_write_share_may_upload_and_delete(client):
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
ev = _make_event(client, admin, cal_id)
att_id = _upload(client, admin, ev["id"]).json()["id"]
_share(client, admin, cal_id, bob_id, "read_write")
assert _upload(client, bob, ev["id"], name="bob.pdf").status_code == 201
# The stated rule: whoever may edit the event may remove anyone's attachment.
assert client.delete(f"/api/local/attachments/{att_id}",
headers=auth(bob)).status_code == 200
def test_unrelated_user_gets_404_not_403(client):
"""404 everywhere, so a stranger cannot even learn the event exists."""
admin = register_admin(client)
_, dave = create_user(client, admin, "dave")
ev = _make_event(client, admin, _make_calendar(client, admin))
att_id = _upload(client, admin, ev["id"]).json()["id"]
assert client.get(f"/api/local/events/{ev['id']}/attachments",
headers=auth(dave)).status_code == 404
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(dave)).status_code == 404
assert client.delete(f"/api/local/attachments/{att_id}",
headers=auth(dave)).status_code == 404
def test_private_event_hides_its_attachments_from_a_share_recipient(client):
"""The leak this feature could most easily introduce: the merge read masks
a foreign private event, so its attachments must be unreachable too."""
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
r = client.post("/api/local/events", headers=auth(admin), json={
"calendar_id": cal_id, "title": "Vertraulich", "private": True,
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
})
ev = r.json()
att_id = _upload(client, admin, ev["id"]).json()["id"]
_share(client, admin, cal_id, bob_id, "read_write")
assert client.get(f"/api/local/events/{ev['id']}/attachments",
headers=auth(bob)).status_code == 404
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(bob)).status_code == 404
# The owner still reaches their own private event's attachments.
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(admin)).status_code == 200
# ── Capability URL ────────────────────────────────────────
def test_capability_url_serves_without_authentication(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
att_id = _upload(client, token, ev["id"]).json()["id"]
db = SessionLocal()
cap = db.query(models.EventAttachment).filter(
models.EventAttachment.id == att_id).first().token
db.close()
r = client.get(f"/api/attach/{cap}/Rechnung.pdf") # no Authorization header
assert r.status_code == 200
assert r.content == PDF_BYTES
assert r.headers["content-disposition"].startswith("attachment;")
assert r.headers["x-content-type-options"] == "nosniff"
assert client.get("/api/attach/voellig-erfunden").status_code == 404
client.delete(f"/api/local/attachments/{att_id}", headers=auth(token))
assert client.get(f"/api/attach/{cap}").status_code == 404
# ── attachment_count in the merged event read ─────────────
def test_attachment_count_appears_on_the_merged_read(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
def count_for(uid):
events = client.get("/api/caldav/events", headers=auth(token),
params=RANGE).json()["events"]
return next(e["attachment_count"] for e in events if e["id"] == uid)
assert count_for(ev["id"]) == 0
_upload(client, token, ev["id"], name="a.pdf")
_upload(client, token, ev["id"], name="b.pdf")
assert count_for(ev["id"]) == 2
def test_recurring_event_carries_the_count_on_every_occurrence(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
r = client.post("/api/local/events", headers=auth(token), json={
"calendar_id": cal_id, "title": "Taeglich", "rrule": "FREQ=DAILY;COUNT=5",
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
})
ev = r.json()
_upload(client, token, ev["id"], name="a.pdf")
events = client.get("/api/caldav/events", headers=auth(token),
params=RANGE).json()["events"]
occurrences = [e for e in events if e["id"] == ev["id"]]
assert len(occurrences) == 5
assert all(e["attachment_count"] == 1 for e in occurrences)
def test_busy_masked_event_reports_no_attachments(client):
"""A count is metadata too: "this busy block has 3 files" would leak."""
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
r = client.post("/api/local/events", headers=auth(admin), json={
"calendar_id": cal_id, "title": "Vertraulich", "private": True,
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
})
ev = r.json()
_upload(client, admin, ev["id"], name="geheim.pdf")
_share(client, admin, cal_id, bob_id, "read")
events = client.get("/api/caldav/events", headers=auth(bob),
params=RANGE).json()["events"]
masked = next(e for e in events if e["id"] == ev["id"])
assert masked["title"] == "Beschäftigt"
assert masked["attachment_count"] == 0