journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for markdown authoring: syntax highlighting, smart list continuation, and a custom text-based toolbar (built-in EasyMDE toolbar icons require Font Awesome from a CDN, which this app doesn't use). unorderedListStyle is set to "-" to match the app's own template convention. - Add a preview/edit toggle button that swaps the editor for the exact same server-rendered markdown (via /journal/preview) shown after saving, instead of always showing both. - Fix create/edit entry routes to verify the submitted category_id actually belongs to the current user before inserting -- every other write path in this app already checked ownership; this one didn't (found while manually testing the new editor with a typo'd category id that happened to belong to someone else's category, which surfaced as an IntegrityError 500 instead of a clean 404-equivalent). - Fix the default "일상" category template: bare "-" bullet lines don't parse as list items in the markdown renderer (they need a trailing space), and the content_template validator was silently stripping that trailing space off on every save. Backfill migration updates any category still holding the old, broken template text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,13 +41,30 @@ def _upload(filename: str, content_type: str, data: bytes) -> UploadFile:
|
||||
def test_ensure_default_category_creates_once(db_session, test_user):
|
||||
first = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
assert first.name == journal_service.DEFAULT_CATEGORY_NAME
|
||||
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE.strip()
|
||||
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE
|
||||
|
||||
second = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
assert second.id == first.id
|
||||
assert len(journal_service.list_categories(db_session, test_user.id)) == 1
|
||||
|
||||
|
||||
def test_default_category_template_renders_as_headers_and_lists_not_bare_dashes(db_session, test_user):
|
||||
from app.markdown_utils import render_markdown
|
||||
|
||||
category = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
html = render_markdown(category.content_template)
|
||||
|
||||
# "-"에 뒤 공백이 없으면 markdown이 목록으로 안 잡고 그냥 "<p>-</p>"로 렌더링해버리는
|
||||
# 회귀가 있었다 — 다섯 항목 전부 실제 <ul><li> 목록이어야 한다.
|
||||
assert html.count("<ul>") == 5
|
||||
assert html.count("<li>") == 5
|
||||
# 제목 줄 바로 다음에 "-"가 오면(빈 줄 없이) markdown이 그걸 제목 밑줄로 오인해서
|
||||
# 굵은 글씨가 아니라 <h1>/<h2> 제목으로 바뀌어버리는 회귀도 있었다.
|
||||
assert "<h1" not in html
|
||||
assert "<h2" not in html
|
||||
assert html.count("<strong>") == 6
|
||||
|
||||
|
||||
def test_category_content_template_persists_and_updates(db_session, test_user):
|
||||
category = journal_service.create_category(
|
||||
db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from app.markdown_utils import render_markdown
|
||||
|
||||
|
||||
def test_bold_and_heading_render_as_html():
|
||||
html = render_markdown("# 제목\n\n**굵게** 쓴 문장입니다")
|
||||
assert "<h1>제목</h1>" in html
|
||||
assert "<strong>굵게</strong>" in html
|
||||
|
||||
|
||||
def test_single_newline_becomes_line_break():
|
||||
html = render_markdown("첫째 줄\n둘째 줄")
|
||||
assert "<br" in html
|
||||
|
||||
|
||||
def test_list_renders_as_html_list():
|
||||
html = render_markdown("- 하나\n- 둘")
|
||||
assert "<ul>" in html
|
||||
assert "<li>하나</li>" in html
|
||||
|
||||
|
||||
def test_dash_and_star_bullets_render_identically():
|
||||
# 마크다운 글머리 기호는 -/*/+ 전부 동일하게 처리돼야 한다 — 에디터 쪽 기본 기호(unorderedListStyle)만
|
||||
# "-"로 바뀌었을 뿐, 렌더링 결과는 어떤 기호를 써도 같아야 한다.
|
||||
assert render_markdown("- 하나\n- 둘") == render_markdown("* 하나\n* 둘") == render_markdown("+ 하나\n+ 둘")
|
||||
|
||||
|
||||
def test_script_tag_is_stripped_not_executed():
|
||||
html = render_markdown('<script>alert("xss")</script>본문')
|
||||
assert "<script" not in html
|
||||
assert "alert" not in html or "<script" not in html # 태그는 지워지고 텍스트만 남아야 함
|
||||
|
||||
|
||||
def test_javascript_href_is_neutralized():
|
||||
html = render_markdown('[click me](javascript:alert(1))')
|
||||
assert "javascript:" not in html
|
||||
|
||||
|
||||
def test_onerror_attribute_is_stripped():
|
||||
html = render_markdown('<img src=x onerror="alert(1)">본문')
|
||||
assert "onerror" not in html
|
||||
assert "<img" not in html # img는 허용 태그 목록에 없음
|
||||
|
||||
|
||||
def test_allowed_link_href_is_preserved():
|
||||
html = render_markdown("[내 블로그](https://example.com)")
|
||||
assert 'href="https://example.com"' in html
|
||||
@@ -101,6 +101,22 @@ def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
|
||||
assert journal_service.list_entries(db_session, test_user.id) == []
|
||||
|
||||
|
||||
def test_create_entry_rejects_other_users_category(auth_client, db_session, test_user, other_user):
|
||||
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
|
||||
|
||||
response = auth_client.post(
|
||||
"/journal/new",
|
||||
data={
|
||||
"category_id": str(others_category.id),
|
||||
"entry_date": date.today().isoformat(),
|
||||
"content": "가로채기 시도",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "카테고리를 찾을 수 없어요" in response.text
|
||||
assert journal_service.list_entries(db_session, test_user.id) == []
|
||||
|
||||
|
||||
def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
today = date.today()
|
||||
@@ -111,12 +127,58 @@ def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
|
||||
assert "오늘의 기록" in response.text
|
||||
|
||||
|
||||
def test_journal_day_detail_renders_content_as_markdown(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
today = date.today()
|
||||
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="**굵은 글씨** 테스트")
|
||||
|
||||
response = auth_client.get(f"/journal/day/{today.isoformat()}")
|
||||
assert response.status_code == 200
|
||||
assert "<strong>굵은 글씨</strong>" in response.text
|
||||
|
||||
|
||||
def test_journal_day_detail_strips_script_tags_from_content(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
today = date.today()
|
||||
_make_entry(
|
||||
db_session, test_user.id, category.id, entry_date=today, content='<script>alert(1)</script>본문'
|
||||
)
|
||||
|
||||
response = auth_client.get(f"/journal/day/{today.isoformat()}")
|
||||
assert response.status_code == 200
|
||||
assert "<script" not in response.text
|
||||
|
||||
|
||||
def test_journal_day_detail_requires_login(client):
|
||||
response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_preview_renders_markdown(auth_client):
|
||||
response = auth_client.post("/journal/preview", data={"content": "**굵게** 그리고 - 목록"})
|
||||
assert response.status_code == 200
|
||||
assert "<strong>굵게</strong>" in response.text
|
||||
|
||||
|
||||
def test_preview_strips_script_tags(auth_client):
|
||||
response = auth_client.post("/journal/preview", data={"content": '<script>alert(1)</script>본문'})
|
||||
assert response.status_code == 200
|
||||
assert "<script" not in response.text
|
||||
|
||||
|
||||
def test_preview_shows_placeholder_for_blank_content(auth_client):
|
||||
response = auth_client.post("/journal/preview", data={"content": " "})
|
||||
assert response.status_code == 200
|
||||
assert "미리보기가 여기에 표시돼요" in response.text
|
||||
|
||||
|
||||
def test_preview_requires_login(client):
|
||||
response = client.post("/journal/preview", data={"content": "test"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_edit_entry_updates_content(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
|
||||
@@ -163,6 +225,27 @@ def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_edit_entry_rejects_moving_to_other_users_category(auth_client, db_session, test_user, other_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
|
||||
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
|
||||
|
||||
response = auth_client.post(
|
||||
f"/journal/{entry.id}/edit",
|
||||
data={
|
||||
"category_id": str(others_category.id),
|
||||
"entry_date": entry.entry_date.isoformat(),
|
||||
"content": "가로채기 시도",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "카테고리를 찾을 수 없어요" in response.text
|
||||
|
||||
db_session.refresh(entry)
|
||||
assert entry.category_id == category.id
|
||||
assert entry.content == "원래 내용"
|
||||
|
||||
|
||||
def test_delete_entry_removes_it(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록")
|
||||
|
||||
Reference in New Issue
Block a user