Files
habit-tracker/tests/test_api_journal.py
shinalokandClaude Sonnet 5 00c66f9df8 journal: paste image from clipboard, cap embedded image size, fix media persistence
- Paste-to-embed: pasting an image into the markdown editor uploads it and
  inserts ![](url) at the cursor. Unlike gallery attachments these aren't
  tied to a journal_entry (the entry may not exist yet while composing), so
  they're stored per-user under app/media/journal/{user_id}/pasted/ with no
  DB row, served through an ownership-scoped route, and never cleaned up
  automatically when an entry is deleted -- an accepted tradeoff at this
  app's personal scale.
- The markdown sanitizer was stripping all <img> tags (not on the bleach
  allowlist), which would have silently deleted every pasted image on save;
  added img/src/alt/title while keeping event-handler attributes blocked.
- Cap embedded image width in both the editor pane and the rendered preview
  so a large pasted photo can't overflow its card.
- Fix real data loss risk found while testing this: docker-compose.yml had
  no volume for app/media, so every container recreate during a deploy wiped
  uploaded photos, and deploy_sftp.py was syncing app/media/ (runtime user
  data, not source) into the remote build context. Added the volume mount
  and excluded media/ from the sync script. Recovered and relocated the
  real attachments that had already landed in the wrong place on the NAS
  during earlier deploys this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:27:30 +09:00

156 lines
6.6 KiB
Python

from datetime import date
from io import BytesIO
from PIL import Image
from starlette.datastructures import Headers, UploadFile
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate
from app.services import journal_service
def _make_entry_with_attachment(db_session, user_id, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = journal_service.create_category(db_session, user_id, JournalCategoryCreate(name="일상"))
entry = journal_service.create_entry(
db_session,
user_id,
JournalEntryCreate(category_id=category.id, entry_date=date.today(), content="기록"),
)
buf = BytesIO()
Image.new("RGB", (800, 600), "blue").save(buf, format="PNG")
upload = UploadFile(file=BytesIO(buf.getvalue()), filename="photo.png", headers=Headers({"content-type": "image/png"}))
attachment = journal_service.save_attachment(db_session, entry, upload)
return attachment
def test_get_media_requires_login(client):
response = client.get("/api/journal/media/1")
assert response.status_code == 401
def test_get_media_returns_file_for_owner(auth_client, db_session, test_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}")
assert response.status_code == 200
assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
def test_get_media_thumbnail_variant(auth_client, db_session, test_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}?thumbnail=true")
assert response.status_code == 200
def test_get_media_returns_404_for_missing_attachment(auth_client):
response = auth_client.get("/api/journal/media/9999")
assert response.status_code == 404
def test_get_media_returns_404_for_other_users_attachment(auth_client, db_session, other_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, other_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}")
assert response.status_code == 404
def _png_bytes():
buf = BytesIO()
Image.new("RGB", (400, 300), "red").save(buf, format="PNG")
return buf.getvalue()
def test_paste_image_requires_login(client):
response = client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
assert response.status_code == 401
def test_paste_image_uploads_and_serves(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
response = auth_client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
assert response.status_code == 200
url = response.json()["url"]
assert url.startswith("/api/journal/pasted-media/")
fetched = auth_client.get(url)
assert fetched.status_code == 200
assert fetched.content[:8] == b"\x89PNG\r\n\x1a\n"
def test_paste_image_rejects_unsupported_type(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
response = auth_client.post(
"/api/journal/paste-image", files={"file": ("a.pdf", b"%PDF-1.4", "application/pdf")}
)
assert response.status_code == 400
def test_paste_image_rejects_oversized_file(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
monkeypatch.setattr(journal_service.settings, "journal_max_upload_mb", 0)
response = auth_client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
assert response.status_code == 400
def test_pasted_media_requires_login(client):
response = client.get("/api/journal/pasted-media/anything.png")
assert response.status_code == 401
def test_pasted_media_returns_404_for_missing_file(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
response = auth_client.get("/api/journal/pasted-media/does-not-exist.png")
assert response.status_code == 404
def test_pasted_media_is_scoped_per_user(auth_client, db_session, other_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
filename = journal_service.save_pasted_image(
other_user.id,
UploadFile(file=BytesIO(_png_bytes()), filename="a.png", headers=Headers({"content-type": "image/png"})),
)
# 파일명을 정확히 알아도 다른 유저 소유 폴더 안에 있으면 접근할 수 없어야 한다(디렉터리로 스코핑됨).
response = auth_client.get(f"/api/journal/pasted-media/{filename}")
assert response.status_code == 404
def test_pasted_media_blocks_path_traversal(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
# 요청 자체가 상대경로 컴포넌트를 포함하면 라우팅에서 걸러지지만, 인코딩된 경로 구분자로
# 시도해도 Path(...).name이 디렉터리 구분자를 다 제거해서 상위 폴더로 못 나간다.
response = auth_client.get("/api/journal/pasted-media/..%2f..%2f..%2fetc%2fpasswd")
assert response.status_code == 404
def test_reorder_categories_requires_login(client):
response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]})
assert response.status_code == 401
def test_reorder_categories_updates_sort_order(auth_client, db_session, test_user):
a = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="A"))
b = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="B"))
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [b.id, a.id]})
assert response.status_code == 200
db_session.refresh(a)
db_session.refresh(b)
assert b.sort_order == 0
assert a.sort_order == 1
def test_reorder_categories_ignores_other_users_ids(auth_client, db_session, other_user):
other_category = journal_service.create_category(db_session, other_user.id, JournalCategoryCreate(name="남의 것"))
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [other_category.id]})
assert response.status_code == 200
db_session.refresh(other_category)
assert other_category.sort_order is None