- 그림(또는 빈 그림 자리) 더블클릭 → 숨은 파일 고르기(png · jpg · webp) → 새 판으로 그 항목만 다시 그림
· 그림 한 번 누름 = 아무 일 없음 · 그림 아래 설명 더블클릭 = 그림 줄 글 고침
- POST /api/help/{page}/img (multipart · name = 그 그림 이름) — require_system_admin(403) · 확장자 ·
5 MB · Pillow 로 진짜 그림 확인 · 폭 1800 px 넘으면 줄임 · WebP 로 그 이름에 원자 쓰기 ·
_manual.json(손으로 바꾼 그림 이름 — 자동 캡처가 안 덮음)에 더함
- 시험: WebP · 줄임 · manual · 확장자 · 크기 · 그림 아님 · 경로 탈출 · 403
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""PLAN 538 [?] 사용자 가이드 — 변환 글 · 그림 내려주기 · 바로 고침.
|
|
|
|
짝: `ui_template/ui_template_help.ts`(`renderHelpMarkdown`)
|
|
— `helper_538_help_render.cjs` 가 Node 로 돌림 ·
|
|
`common_util/common_util_help_router.py`(GET /api/help · 그림 · PUT · POST).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from common_util import common_util_help_router as help_router
|
|
from common_util.common_util_auth import verify_session
|
|
|
|
HERE = os.path.dirname(__file__)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def html() -> dict:
|
|
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
["node", os.path.join(HERE, "helper_538_help_render.cjs")],
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
timeout=60,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def test_덩어리마다_원문_줄(html) -> None:
|
|
admin = html["admin"]
|
|
for tag in [
|
|
'<h2 data-src="0-0">',
|
|
'<p data-src="2-3">',
|
|
'<ul data-src="5-6">',
|
|
'class="ui-markdown-table" data-src="8-10"',
|
|
'data-src="12-13"',
|
|
'<figure data-src="17-17"',
|
|
]:
|
|
assert tag in admin, tag
|
|
assert '<ul data-src="2-3">' in html["crlf"]
|
|
|
|
|
|
def test_페이지_안_넘기면_옛_출력(html) -> None:
|
|
"""B01 지식 채팅 — 원문 줄 · 그림 · 상자 · 링크 없음."""
|
|
plain = html["plain"]
|
|
assert "data-src" not in plain and "<figure" not in plain and "<aside" not in plain
|
|
assert plain.startswith("<h2>종단 설계</h2><p>첫 문단 [표준 횡단](B06/00_설명서)")
|
|
|
|
|
|
def test_그림_상자_링크(html) -> None:
|
|
admin, user = html["admin"], html["user"]
|
|
assert (
|
|
'<img src="/api/help/B05/img/b05_full.webp?v=abc" alt="화면 통째" loading="lazy">' in admin
|
|
)
|
|
assert "<figcaption>화면 통째</figcaption>" in admin
|
|
assert "그림 없음 — 더블클릭해 지정" in admin and "그림 없음" not in user
|
|
assert "그림 준비 중" in user
|
|
assert (
|
|
'<aside class="ui-help-note ui-help-note--warn" data-src="12-13"><strong>주의</strong>'
|
|
in admin
|
|
)
|
|
assert '<aside class="ui-help-note ui-help-note--info"' in admin
|
|
assert 'data-help-goto="B06/00_설명서">표준 횡단</a>' in admin
|
|
assert 'data-help-goto="B05/구조물_돌쌓기(메)">돌쌓기</a>' in admin
|
|
|
|
|
|
def test_html_은_먼저_막음(html) -> None:
|
|
for out in html.values():
|
|
assert "<script>" not in out
|
|
assert "../x.webp" in html["admin"] and 'data-img="../x.webp"' not in html["admin"]
|
|
|
|
|
|
# ── 서버 ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def root(monkeypatch):
|
|
path = Path(tempfile.mkdtemp())
|
|
(path / "B05").mkdir()
|
|
(path / "B05" / "00_설명서.md").write_bytes("# 종단\n\n첫 문단\n".encode())
|
|
monkeypatch.setattr(help_router, "HELP_ROOT", path)
|
|
yield path
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
|
|
def client(role: str = "SYSTEM_ADMIN") -> TestClient:
|
|
app = FastAPI()
|
|
app.include_router(help_router.router)
|
|
app.dependency_overrides[verify_session] = lambda: {"role": role, "user_id": 1}
|
|
return TestClient(app)
|
|
|
|
|
|
def test_guide_admin_칸(root) -> None:
|
|
assert client().get("/api/help").json()["admin"] is True
|
|
body = client("USER").get("/api/help").json()
|
|
assert body["admin"] is False and body["pages"][0]["page"] == "B05"
|
|
|
|
|
|
def test_그림_내려주기_no_cache(root) -> None:
|
|
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
|
|
res = client("USER").get("/api/help/B05/img/full.webp")
|
|
assert res.status_code == 200 and res.content == b"RIFF0000WEBP"
|
|
assert res.headers["cache-control"] == "no-cache"
|
|
assert res.headers["content-type"] == "image/webp"
|
|
assert list(client().get("/api/help").json()["pages"][0]["images"]) == ["full.webp"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"/api/help/B05/img/none.webp",
|
|
"/api/help/B05/img/00_설명서.md",
|
|
"/api/help/B05/img/..%2F..%2Fmain.py",
|
|
"/api/help/B05/img/a.gif",
|
|
"/api/help/..%2FB05/img/full.webp",
|
|
"/api/help/b05/img/full.webp",
|
|
],
|
|
)
|
|
def test_그림_404(root, url) -> None:
|
|
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
|
|
assert client().get(url).status_code == 404
|
|
|
|
|
|
# ── 538-3 글 바로 고침 ────────────────────────────────────────────────────────
|
|
|
|
|
|
def put(c: TestClient, version: str, start: int, end: int, text: str, key: str = "00_설명서"):
|
|
body = {"version": version, "start": start, "end": end, "text": text}
|
|
return c.put(f"/api/help/B05/{key}", json=body)
|
|
|
|
|
|
def version_of(root: Path) -> str:
|
|
return help_router.file_version((root / "B05" / "00_설명서.md").read_bytes())
|
|
|
|
|
|
def test_그_줄만_갈고_새_판(root) -> None:
|
|
old = version_of(root)
|
|
res = put(client(), old, 2, 2, "고친 문단\n둘째 줄")
|
|
assert res.status_code == 200
|
|
item = res.json()["item"]
|
|
text = (root / "B05" / "00_설명서.md").read_bytes().decode()
|
|
assert text == "# 종단\n\n고친 문단\n둘째 줄\n" == item["text"]
|
|
assert item["version"] == version_of(root) != old
|
|
assert put(client(), item["version"], 2, 3, "").status_code == 200 # 빈 글 = 그 줄 지움
|
|
assert (root / "B05" / "00_설명서.md").read_bytes().decode() == "# 종단\n\n"
|
|
|
|
|
|
def test_crlf_bom_그대로(root) -> None:
|
|
path = root / "B05" / "00_설명서.md"
|
|
path.write_bytes(b"\xef\xbb\xbf# A\r\n\r\nB\r\n")
|
|
assert put(client(), version_of(root), 2, 2, "C").status_code == 200
|
|
assert path.read_bytes() == b"\xef\xbb\xbf# A\r\n\r\nC\r\n"
|
|
|
|
|
|
def test_판_다르면_409_파일_그대로(root) -> None:
|
|
before = (root / "B05" / "00_설명서.md").read_bytes()
|
|
assert put(client(), "0" * 16, 2, 2, "남의 글").status_code == 409
|
|
assert (root / "B05" / "00_설명서.md").read_bytes() == before
|
|
|
|
|
|
def test_시스템_관리자만_403(root) -> None:
|
|
before = (root / "B05" / "00_설명서.md").read_bytes()
|
|
for role in ["USER", "COMPANY_ADMIN"]:
|
|
assert put(client(role), version_of(root), 2, 2, "x").status_code == 403
|
|
assert (root / "B05" / "00_설명서.md").read_bytes() == before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("start", "end", "key", "code"),
|
|
[
|
|
(3, 2, "00_설명서", 400),
|
|
(0, 9, "00_설명서", 400),
|
|
(0, 0, "없음", 404),
|
|
(0, 0, "_manual", 404),
|
|
(0, 0, "..%2F..%2FCLAUDE", 404),
|
|
],
|
|
)
|
|
def test_나쁜_범위_항목(root, start, end, key, code) -> None:
|
|
assert put(client(), version_of(root), start, end, "x", key).status_code == code
|
|
|
|
|
|
def test_원자_쓰기_실패해도_원본_그대로(root, monkeypatch) -> None:
|
|
before = (root / "B05" / "00_설명서.md").read_bytes()
|
|
|
|
def boom(*_args):
|
|
raise OSError("쓰기 실패")
|
|
|
|
monkeypatch.setattr("common_util.common_util_atomic.os.replace", boom)
|
|
with pytest.raises(OSError):
|
|
put(client(), version_of(root), 2, 2, "반쯤")
|
|
assert (root / "B05" / "00_설명서.md").read_bytes() == before
|
|
assert sorted(p.name for p in (root / "B05").iterdir()) == [
|
|
"00_설명서.md"
|
|
] # 임시 파일 남지 않음
|
|
|
|
|
|
# ── 538-4 그림 바꾸기 ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def png(width: int = 40, height: int = 20) -> bytes:
|
|
from PIL import Image
|
|
|
|
out = io.BytesIO()
|
|
Image.new("RGB", (width, height), (200, 30, 30)).save(out, "PNG")
|
|
return out.getvalue()
|
|
|
|
|
|
def upload(c: TestClient, name: str, blob: bytes, filename: str = "고른.png", page: str = "B05"):
|
|
return c.post(
|
|
f"/api/help/{page}/img",
|
|
data={"name": name},
|
|
files={"file": (filename, blob, "application/octet-stream")},
|
|
)
|
|
|
|
|
|
def test_그림_webp_로_덮고_manual_에_적음(root) -> None:
|
|
from PIL import Image
|
|
|
|
(root / "B05" / "full.webp").write_bytes(b"old")
|
|
res = upload(client(), "full.webp", png(3000, 1500))
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
with Image.open(root / "B05" / "full.webp") as img:
|
|
assert (img.format, img.size) == ("WEBP", (1800, 900)) # 폭 1800 넘으면 줄임
|
|
assert body == {
|
|
"name": "full.webp",
|
|
"version": help_router.image_version(root / "B05" / "full.webp"),
|
|
}
|
|
assert upload(client(), "new_one.webp", png(), "a.jpg").status_code == 200 # 새 이름도 받음
|
|
manual = json.loads((root / "B05" / "_manual.json").read_text(encoding="utf-8"))
|
|
assert manual == ["full.webp", "new_one.webp"]
|
|
assert [i["key"] for i in client().get("/api/help").json()["pages"][0]["items"]] == [
|
|
"00_설명서"
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "blob", "filename", "code"),
|
|
[
|
|
("full.webp", png(), "고른.gif", 400), # 확장자
|
|
("full.webp", b"not an image at all", "고른.png", 400), # 그림 아님
|
|
("full.webp", b"", "고른.png", 400),
|
|
("../full.webp", png(), "고른.png", 404), # 경로 탈출
|
|
("full.gif", png(), "고른.png", 404),
|
|
("한글.webp", png(), "고른.png", 404),
|
|
],
|
|
)
|
|
def test_그림_거절(root, name, blob, filename, code) -> None:
|
|
assert upload(client(), name, blob, filename).status_code == code
|
|
assert sorted(p.name for p in (root / "B05").iterdir()) == ["00_설명서.md"]
|
|
|
|
|
|
def test_그림_5MB_넘으면_거절(root, monkeypatch) -> None:
|
|
monkeypatch.setattr(help_router, "MAX_IMG_BYTES", 1000)
|
|
assert upload(client(), "full.webp", png(400, 400) + b"\0" * 2000).status_code == 400
|
|
|
|
|
|
def test_그림_시스템_관리자만_403(root) -> None:
|
|
assert upload(client("USER"), "full.webp", png()).status_code == 403
|
|
assert upload(client(), "full.webp", png(), page="..%2FB05").status_code == 404
|
|
assert not (root / "B05" / "full.webp").exists()
|