elemes/services/tests/test_content_editor_routes.py

960 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Route test content editor (teacher-only) — butuh PostgreSQL.
Mengunci kontrak:
- autentikasi guru dari cookie HttpOnly student_token;
- draft CRUD, preview, publish dengan validasi & konflik mtime;
- tree operasi struktural (create, rename, delete) + path traversal protection;
- upload asset (JSON+base64) dengan validasi ekstensi & ukuran;
- guard home.md marker ---Available_Lessons---;
- regression: _render_markdown_string() == render_markdown_content() untuk file yang sama;
"""
import base64
import io
import os
import pathlib
import shutil
import time
import zipfile
import pytest
from config import LESSON_DONE_MIN_PERCENT, LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT
from services import repositories as repo
from services.database import SessionLocal
from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN
DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip()
pytestmark = [
pytest.mark.skipif(
not DB_REQUIRED,
reason="butuh DATABASE_URL (PostgreSQL nyata)",
),
pytest.mark.integration,
]
# ── Fixtures ──────────────────────────────────────────────────────
TEST_CONTENT = pathlib.Path(os.environ.get("CONTENT_DIR", "/tmp/lms-contract/content"))
TEST_ASSETS = TEST_CONTENT.parent / "assets"
def _seed(db):
teacher = repo.create_user(db, display_name="Pak Guru", role="teacher")
repo.create_access_token(db, user_id=teacher.id, raw_token=TEACHER_TOKEN)
student = repo.create_user(db, display_name="Budi Santoso", role="student")
repo.create_access_token(db, user_id=student.id, raw_token=STUDENT_TOKEN)
db.commit()
return {"teacher": teacher, "student": student}
def _seed_and_setup():
db = SessionLocal()
try:
data = _seed(db)
finally:
db.close()
# Create test content files
test_dir = TEST_CONTENT / "test_editor"
test_dir.mkdir(parents=True, exist_ok=True)
(test_dir / "hello.md").write_text("# Hello World\n\nIni materi test.\n", encoding="utf-8")
(test_dir / "quiz.md").write_text(
"# Quiz Test\n\n### Soal 1\n- [] Opsi A\n- [x] Opsi B\n- [] Opsi C\n",
encoding="utf-8",
)
home = TEST_CONTENT / "home.md"
if not home.exists():
home.write_text(
"# Home\n\n---Available_Lessons---\n\n[Hello](lesson/hello.md)\n",
encoding="utf-8",
)
# Create assets dir
TEST_ASSETS.mkdir(parents=True, exist_ok=True)
return data
def _login_teacher(client):
client.set_cookie("student_token", TEACHER_TOKEN)
def _login_student(client):
client.set_cookie("student_token", STUDENT_TOKEN)
# ── Auth tests ────────────────────────────────────────────────────
def test_draft_requires_teacher(client):
_seed_and_setup()
resp = client.get("/content/drafts?target_path=test_editor/hello.md")
assert resp.status_code == 401
def test_draft_rejects_student(client):
_seed_and_setup()
_login_student(client)
resp = client.get("/content/drafts?target_path=test_editor/hello.md")
assert resp.status_code == 401
def test_tree_requires_teacher(client):
_seed_and_setup()
resp = client.get("/content/tree?root=content")
assert resp.status_code == 401
# ── Draft GET (file source) ──────────────────────────────────────
def test_get_draft_from_file(client):
_seed_and_setup()
_login_teacher(client)
resp = client.get("/content/drafts?target_path=test_editor/hello.md")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert data["source"] == "file"
assert data["draft_id"] is None
assert "Hello World" in data["body"]
assert data["base_mtime"] is not None
def test_get_draft_invalid_path(client):
_seed_and_setup()
_login_teacher(client)
resp = client.get("/content/drafts?target_path=../../etc/passwd")
assert resp.status_code == 400
def test_get_draft_missing_file(client):
_seed_and_setup()
_login_teacher(client)
resp = client.get("/content/drafts?target_path=nonexistent.md")
assert resp.status_code == 404
def test_get_draft_non_md_rejected(client):
_seed_and_setup()
_login_teacher(client)
resp = client.get("/content/drafts?target_path=test_editor/hello.txt")
assert resp.status_code == 400
# ── Draft POST (save) + GET (from draft) ─────────────────────────
def test_save_and_get_draft(client):
_seed_and_setup()
_login_teacher(client)
# Save a draft
resp = client.post("/content/drafts", json={
"target_path": "test_editor/hello.md",
"body": "# Draft Content\n\nIsi draft.",
"base_mtime": 12345.0,
})
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
draft_id = data["draft_id"]
# Get it back — should be from draft
resp = client.get("/content/drafts?target_path=test_editor/hello.md")
data = resp.get_json()
assert data["success"] is True
assert data["source"] == "draft"
assert data["draft_id"] == draft_id
assert "Draft Content" in data["body"]
# ── Preview ───────────────────────────────────────────────────────
def test_preview_valid_quiz(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/preview", json={
"body": "# Quiz\n\n---QUIZ_FLASHCARD---\n### Soal 1\n- [] Opsi A\n- [x] Opsi B\n- [] Opsi C\n---END_QUIZ_FLASHCARD---\n",
})
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert len(data["quiz_data"]) == 1
assert data["quiz_data"][0]["type"] == "mcq"
def test_preview_invalid_quiz_two_correct(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/preview", json={
"body": "# Quiz\n\n---QUIZ_FLASHCARD---\n### Soal 1\n- [x] Opsi A\n- [x] Opsi B\n- [] Opsi C\n---END_QUIZ_FLASHCARD---\n",
})
assert resp.status_code == 422
data = resp.get_json()
assert data["success"] is False
assert "tepat satu" in data["message"]
def test_preview_arduino_velxio_tabs(client):
_seed_and_setup()
_login_teacher(client)
body = (
"# LED Arduino\n\n"
"Materi pengenalan LED.\n\n"
"---INITIAL_CODE_ARDUINO---\n"
"void setup() { pinMode(13, OUTPUT); }\n"
"void loop() { digitalWrite(13, HIGH); }\n"
"---END_INITIAL_CODE_ARDUINO---\n\n"
"---VELXIO_CIRCUIT---\n"
'{"board": "arduino:avr:uno"}\n'
"---END_VELXIO_CIRCUIT---\n"
)
resp = client.post("/content/preview", json={"body": body})
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert data["active_tabs"] == ["velxio"]
assert "LED Arduino" in data["lesson_content"]
# ── Publish ───────────────────────────────────────────────────────
def test_publish_arduino_velxio_lesson(client):
_seed_and_setup()
_login_teacher(client)
target_path = "test_editor/arduino_lesson.md"
(TEST_CONTENT / target_path).write_text("# Old", encoding="utf-8")
body = (
"# Belajar Arduino Uno\n\n"
"---INITIAL_CODE_ARDUINO---\n"
"void setup() {}\n"
"---END_INITIAL_CODE_ARDUINO---\n\n"
"---VELXIO_CIRCUIT---\n"
'{"board": "arduino:avr:uno", "components": []}\n'
"---END_VELXIO_CIRCUIT---\n"
)
resp = client.post("/content/drafts", json={
"target_path": target_path,
"body": body,
"base_mtime": os.path.getmtime(str(TEST_CONTENT / target_path)),
})
draft_id = resp.get_json()["draft_id"]
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 200
assert resp.get_json()["success"] is True
# Verify published file on disk
saved = (TEST_CONTENT / target_path).read_text(encoding="utf-8")
assert "---INITIAL_CODE_ARDUINO---" in saved
assert "---VELXIO_CIRCUIT---" in saved
assert "arduino:avr:uno" in saved
def test_publish_success(client):
_seed_and_setup()
_login_teacher(client)
# Save draft
resp = client.post("/content/drafts", json={
"target_path": "test_editor/hello.md",
"body": "# Published Content\n\nBaru.",
"base_mtime": os.path.getmtime(str(TEST_CONTENT / "test_editor/hello.md")),
})
draft_id = resp.get_json()["draft_id"]
# Publish
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert data["published_path"] == "test_editor/hello.md"
# Verify file on disk
content = (TEST_CONTENT / "test_editor/hello.md").read_text(encoding="utf-8")
assert "Published Content" in content
def test_publish_stale_mtime_conflict(client):
_seed_and_setup()
_login_teacher(client)
# Save draft with old mtime
resp = client.post("/content/drafts", json={
"target_path": "test_editor/hello.md",
"body": "# New Content",
"base_mtime": 1.0, # very old
})
draft_id = resp.get_json()["draft_id"]
# Publish — should conflict
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 409
data = resp.get_json()
assert "conflict" in data["message"]
def test_publish_invalid_quiz_rejected(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/drafts", json={
"target_path": "test_editor/quiz.md",
"body": "# Quiz\n\n---QUIZ_FLASHCARD---\n### Soal 1\n- [x] A\n- [x] B\n---END_QUIZ_FLASHCARD---\n",
"base_mtime": os.path.getmtime(str(TEST_CONTENT / "test_editor/quiz.md")),
})
draft_id = resp.get_json()["draft_id"]
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 422
assert "tepat satu" in resp.get_json()["message"]
def test_publish_home_without_marker_rejected(client):
_seed_and_setup()
_login_teacher(client)
# gunakan home.md asli yang sudah di-seed oleh _seed_and_setup
home = TEST_CONTENT / "home.md"
assert home.exists(), "home.md harus ada dari _seed_and_setup"
resp = client.post("/content/drafts", json={
"target_path": "home.md",
"body": "# Home\n\nNo marker here.\n",
"base_mtime": os.path.getmtime(str(home)),
})
draft_id = resp.get_json()["draft_id"]
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 422
assert "---Available_Lessons---" in resp.get_json()["message"]
def test_publish_sub_home_without_marker_rejected(client):
"""P1-1: guard sub-home.md di publish flow."""
_seed_and_setup()
_login_teacher(client)
# Buat folder + sub-home.md dengan marker valid
sub_dir = TEST_CONTENT / "folder_x"
sub_dir.mkdir(parents=True, exist_ok=True)
sub_home = sub_dir / "sub-home.md"
sub_home.write_text(
"# Sub Home\n\n---Available_Lessons---\n\n[Hello](lesson/test_editor/hello.md)\n",
encoding="utf-8",
)
resp = client.post("/content/drafts", json={
"target_path": "folder_x/sub-home.md",
"body": "# Sub Home\n\nNo marker here.\n",
"base_mtime": os.path.getmtime(str(sub_home)),
})
draft_id = resp.get_json()["draft_id"]
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 422
assert "---Available_Lessons---" in resp.get_json()["message"]
# ── Tree ──────────────────────────────────────────────────────────
def test_get_tree_content(client):
_seed_and_setup()
_login_teacher(client)
resp = client.get("/content/tree?root=content")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert isinstance(data["tree"], list)
def test_tree_create_folder(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/tree/folder", json={
"root": "content",
"path": "test_editor/new_folder",
})
assert resp.status_code == 200
assert (TEST_CONTENT / "test_editor/new_folder").is_dir()
def test_tree_create_file(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/tree/file", json={
"root": "content",
"path": "test_editor/new_file.md",
})
assert resp.status_code == 200
assert (TEST_CONTENT / "test_editor/new_file.md").is_file()
def test_tree_file_rejects_assets_root(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/tree/file", json={
"root": "assets",
"path": "test.png",
})
assert resp.status_code == 400
def test_tree_path_traversal_rejected(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/tree/folder", json={
"root": "content",
"path": "../../etc/passwd",
})
assert resp.status_code == 400
def test_tree_rename(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "test_editor/hello.md",
"new_path": "test_editor/renamed.md",
})
assert resp.status_code == 200
assert (TEST_CONTENT / "test_editor/renamed.md").is_file()
assert not (TEST_CONTENT / "test_editor/hello.md").exists()
def test_tree_delete_file(client):
_seed_and_setup()
_login_teacher(client)
resp = client.delete("/content/tree/entry?root=content&path=test_editor/hello.md")
assert resp.status_code == 200
assert not (TEST_CONTENT / "test_editor/hello.md").exists()
def test_tree_delete_nonempty_folder_without_force(client):
_seed_and_setup()
_login_teacher(client)
# test_editor has files in it
resp = client.delete("/content/tree/entry?root=content&path=test_editor")
assert resp.status_code == 409
data = resp.get_json()
assert data.get("needs_force") is True
# ── P0-1: Proteksi file navigasi kritis (home.md, sub-home.md) ──
def test_tree_delete_home_protected(client):
_seed_and_setup()
_login_teacher(client)
# tanpa confirm_critical → 409
resp = client.delete("/content/tree/entry?root=content&path=home.md")
assert resp.status_code == 409
data = resp.get_json()
assert data["message"] == "protected"
# file masih ada
assert (TEST_CONTENT / "home.md").exists()
def test_tree_delete_home_with_confirm(client):
_seed_and_setup()
_login_teacher(client)
# dengan confirm_critical=true → 200, file terhapus
resp = client.delete("/content/tree/entry?root=content&path=home.md&confirm_critical=true")
assert resp.status_code == 200
assert not (TEST_CONTENT / "home.md").exists()
def test_tree_delete_sub_home_protected(client):
_seed_and_setup()
_login_teacher(client)
# buat folder_x/sub-home.md
sub_dir = TEST_CONTENT / "folder_x"
sub_dir.mkdir(parents=True, exist_ok=True)
(sub_dir / "sub-home.md").write_text("# Sub\n\n---Available_Lessons---\n\n", encoding="utf-8")
# tanpa confirm_critical → 409
resp = client.delete("/content/tree/entry?root=content&path=folder_x/sub-home.md")
assert resp.status_code == 409
assert resp.get_json()["message"] == "protected"
# file masih ada
assert (sub_dir / "sub-home.md").exists()
def test_tree_delete_sub_home_with_confirm(client):
_seed_and_setup()
_login_teacher(client)
sub_dir = TEST_CONTENT / "folder_x"
sub_dir.mkdir(parents=True, exist_ok=True)
(sub_dir / "sub-home.md").write_text("# Sub\n\n---Available_Lessons---\n\n", encoding="utf-8")
resp = client.delete("/content/tree/entry?root=content&path=folder_x/sub-home.md&confirm_critical=true")
assert resp.status_code == 200
assert not (sub_dir / "sub-home.md").exists()
def test_tree_delete_normal_file_not_protected(client):
_seed_and_setup()
_login_teacher(client)
# file biasa tidak perlu confirm_critical → 200
resp = client.delete("/content/tree/entry?root=content&path=test_editor/hello.md")
assert resp.status_code == 200
assert not (TEST_CONTENT / "test_editor/hello.md").exists()
def test_tree_rename_home_protected(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "home.md",
"new_path": "home_renamed.md",
})
assert resp.status_code == 409
assert resp.get_json()["message"] == "protected"
assert (TEST_CONTENT / "home.md").exists()
assert not (TEST_CONTENT / "home_renamed.md").exists()
def test_tree_rename_home_with_confirm(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "home.md",
"new_path": "home_renamed.md",
"confirm_critical": True,
})
assert resp.status_code == 200
assert not (TEST_CONTENT / "home.md").exists()
assert (TEST_CONTENT / "home_renamed.md").exists()
def test_tree_rename_normal_file_not_protected(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "test_editor/hello.md",
"new_path": "test_editor/renamed.md",
})
assert resp.status_code == 200
assert (TEST_CONTENT / "test_editor/renamed.md").is_file()
# ── P1-2: Enforce .md extension di rename untuk root content ──
def test_tree_rename_reject_non_md_extension(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "test_editor/hello.md",
"new_path": "test_editor/hello.txt",
})
assert resp.status_code == 400
assert "Materi hanya boleh berekstensi .md" in resp.get_json()["message"]
assert (TEST_CONTENT / "test_editor/hello.md").exists()
assert not (TEST_CONTENT / "test_editor/hello.txt").exists()
def test_tree_rename_assets_root_allows_any_extension(client):
_seed_and_setup()
_login_teacher(client)
# Assets root tidak dibatasi ekstensi
asset_file = TEST_ASSETS / "test_image.png"
asset_file.parent.mkdir(parents=True, exist_ok=True)
asset_file.write_bytes(b"fake-png")
resp = client.patch("/content/tree/rename", json={
"root": "assets",
"old_path": "test_image.png",
"new_path": "test_image.bin",
})
assert resp.status_code == 200
assert (TEST_ASSETS / "test_image.bin").exists()
# ── Draft delete ──────────────────────────────────────────────────
def test_discard_draft(client):
_seed_and_setup()
_login_teacher(client)
# Save draft
resp = client.post("/content/drafts", json={
"target_path": "test_editor/hello.md",
"body": "# To be discarded",
})
draft_id = resp.get_json()["draft_id"]
# Delete it
resp = client.delete(f"/content/drafts/{draft_id}")
assert resp.status_code == 200
assert resp.get_json()["success"] is True
# Should now read from file again
resp = client.get("/content/drafts?target_path=test_editor/hello.md")
data = resp.get_json()
assert data["source"] == "file"
# ── Regression: _render_markdown_string == render_markdown_content ─
def test_render_markdown_string_matches_file_render(client):
"""_render_markdown_string() and render_markdown_content() produce identical dict
for the same file content (regression for CONTENT-02 refactor)."""
from services.lesson_service import _render_markdown_string, render_markdown_content
test_file = TEST_CONTENT / "test_editor" / "hello.md"
if not test_file.exists():
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.write_text("# Hello\n\nTest content.\n", encoding="utf-8")
file_result = render_markdown_content(str(test_file))
content = test_file.read_text(encoding="utf-8")
string_result = _render_markdown_string(content)
# Compare all keys
assert set(file_result.keys()) == set(string_result.keys())
for key in file_result:
assert file_result[key] == string_result[key], f"Mismatch at key '{key}'"
# ── Author ID verification ────────────────────────────────────────
def test_save_draft_stores_correct_author_id(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/drafts", json={
"target_path": "test_editor/hello.md",
"body": "# Test",
})
draft_id = resp.get_json()["draft_id"]
# Query DB directly to verify author_id
db = SessionLocal()
try:
from services.models import ContentDraft
draft = db.query(ContentDraft).filter(ContentDraft.id == draft_id).first()
assert draft is not None
teacher = db.query(repo.User).filter(repo.User.role == "teacher").first()
assert draft.author_id == teacher.id
finally:
db.close()
# ── Export bundle ──────────────────────────────────────────────────
def test_export_requires_teacher(client):
_seed_and_setup()
resp = client.get("/content/export")
assert resp.status_code == 401
def test_export_returns_zip_with_content_and_assets(client):
_seed_and_setup()
_login_teacher(client)
(TEST_ASSETS / "diag.png").write_bytes(b"fake-png")
resp = client.get("/content/export")
assert resp.status_code == 200
assert "application/zip" in resp.headers["Content-Type"]
assert resp.headers["Content-Disposition"].startswith("attachment; filename=")
zf = zipfile.ZipFile(io.BytesIO(resp.data))
names = set(zf.namelist())
assert "content/test_editor/hello.md" in names
assert "content/home.md" in names
assert "assets/diag.png" in names
def test_export_scope_content_excludes_assets(client):
_seed_and_setup()
_login_teacher(client)
(TEST_ASSETS / "diag.png").write_bytes(b"fake-png")
resp = client.get("/content/export?scope=content")
assert resp.status_code == 200
names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist()
assert any(n.startswith("content/") for n in names)
assert not any(n.startswith("assets/") for n in names)
# ── Upload asset: GIF diterima, overwrite eksplisit, kontrak pesan error ──
# GIF89a 1x1 hitam — GIF valid minimal
GIF_1PX = base64.b64decode(
"R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==")
def _upload(client, filename, raw, folder="", overwrite=None):
payload = {
"filename": filename,
"folder": folder,
"content_base64": base64.b64encode(raw).decode(),
}
if overwrite is not None:
payload["overwrite"] = overwrite
return client.post("/content/assets/upload", json=payload)
def test_upload_gif_accepted(client):
_seed_and_setup()
_login_teacher(client)
resp = _upload(client, "anim.gif", GIF_1PX)
assert resp.status_code == 200, resp.get_json()
body = resp.get_json()
assert body["success"] is True
assert body["url"] == "/assets/anim.gif"
assert (TEST_ASSETS / "anim.gif").read_bytes() == GIF_1PX
def test_upload_existing_name_rejected_without_overwrite(client):
_seed_and_setup()
_login_teacher(client)
_upload(client, "dup.gif", GIF_1PX)
resp = _upload(client, "dup.gif", GIF_1PX)
assert resp.status_code == 409
assert resp.get_json()["message"] # pesan tidak boleh kosong
def test_upload_existing_name_overwritten_with_flag(client):
_seed_and_setup()
_login_teacher(client)
_upload(client, "dup.gif", GIF_1PX)
resp = _upload(client, "dup.gif", GIF_1PX, overwrite=True)
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()["success"] is True
def test_upload_corrupt_gif_rejected_with_message(client):
_seed_and_setup()
_login_teacher(client)
resp = _upload(client, "bad.gif", b"GIF89a\x00garbage")
assert resp.status_code == 400
assert resp.get_json()["message"] # "File bukan gambar yang valid"
def test_upload_oversize_rejected_with_message(client):
_seed_and_setup()
_login_teacher(client)
big = GIF_1PX + b"\x00" * (5 * 1024 * 1024)
resp = _upload(client, "big.gif", big)
assert resp.status_code == 400
assert "mib" in resp.get_json()["message"].lower()
def test_upload_invalid_image_message_includes_detected_format(client):
"""Pesan penolakan PIL harus menyebut format terdeteksi — file 404 HTML
tersamar .png (bug nyata: electron_flow_sparkfun.png 153 byte) harus
terbaca penyebabnya langsung dari toast."""
_seed_and_setup()
_login_teacher(client)
resp = _upload(client, "fake.png", b"<html>\r\n<head><title>404 Not Found</title></head></html>\r\n")
assert resp.status_code == 400
msg = resp.get_json()["message"]
assert "File bukan gambar yang valid" in msg
assert "HTML" in msg # format terdeteksi disebut eksplisit
def test_upload_avif_reports_avif(client):
"""AVIF dinamai .png ditolak dengan format terdeteksi + hint konversi."""
_seed_and_setup()
_login_teacher(client)
avif = b"\x00\x00\x00\x1cftypavif" + b"\x00" * 16
resp = _upload(client, "photo.png", avif)
assert resp.status_code == 400
msg = resp.get_json()["message"]
assert "AVIF" in msg
assert "PNG/JPEG" in msg # hint konversi ikut disertakan
# ── Boundary 5 MiB (cabang len(raw) — bukan pre-check len(b64)) ──────
# SVG dipakai karena dikecualikan dari validasi gambar PIL (Pillow tidak
# parse SVG), sehingga ukuran murni yang diuji, bukan validasi isi.
# Blok ini self-contained (helper sendiri) agar tidak bergantung helper
# lain di file ini.
import base64 as _b64_mod # noqa: E402 (import lokal — blok ini standalone)
_SVG_HDR = b'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
def _svg_of(raw_size: int) -> bytes:
return _SVG_HDR + b" " * (raw_size - len(_SVG_HDR))
def _post_upload_plain(client, filename, raw):
"""POST upload tanpa flag overwrite (blok ini standalone — tidak
memakai helper _upload di atas agar commit-nya bisa berdiri sendiri)."""
return client.post("/content/assets/upload", json={
"filename": filename,
"folder": "",
"content_base64": _b64_mod.b64encode(raw).decode(),
})
def test_upload_exactly_5mib_accepted(client):
"""File TEPAT _MAX_ASSET_BYTES (5 MiB) harus lolos — bukan ditolak."""
_seed_and_setup()
_login_teacher(client)
resp = _post_upload_plain(client, "exact5mib.svg", _svg_of(5 * 1024 * 1024))
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()["success"] is True
def test_upload_5mib_plus_1_rejected(client):
"""5 MiB + 1 byte harus ditolak cabang len(raw) dengan pesan ukuran."""
_seed_and_setup()
_login_teacher(client)
resp = _post_upload_plain(client, "over5mib.svg", _svg_of(5 * 1024 * 1024 + 1))
assert resp.status_code == 400
assert "mib" in resp.get_json()["message"].lower()
# ── CSP saat serve SVG (mitigasi stored-XSS via SVG ber-script) ──────
def test_serve_svg_has_restrictive_csp(client):
"""SVG diserve dengan CSP ketat — <script> di dalamnya tak bisa jalan
saat SVG dibuka langsung di tab browser (same-origin)."""
(TEST_ASSETS / "malware.svg").write_bytes(
b'<svg xmlns="http://www.w3.org/2000/svg"><script>fetch("/api/login")</script></svg>'
)
resp = client.get("/assets/malware.svg")
assert resp.status_code == 200
assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; sandbox"
def test_serve_raster_image_no_csp(client):
"""Gambar raster (PNG) tidak diberi CSP — hanya SVG yang berisiko."""
(TEST_ASSETS / "normal.png").write_bytes(b"\x89PNG\r\n\x1a\n")
resp = client.get("/assets/normal.png")
assert resp.status_code == 200
assert "Content-Security-Policy" not in resp.headers
# ── Sinkronisasi registry & progress saat publish (tanpa restart) ────
def test_publish_evaluation_change_recomputes_progress(client):
"""Publish yang menambah quiz ke lesson exercise-only langsung mengubah
composite/state progress siswa — tanpa menunggu server restart."""
_seed_and_setup()
_login_teacher(client)
lesson_path = "test_editor/eval_lesson.md"
(TEST_CONTENT / lesson_path).write_text(
"# Eval\n\n---INITIAL_CODE---\n```c\nint main(){return 0;}\n```\n",
encoding="utf-8",
)
(TEST_CONTENT / "home.md").write_text(
"# Home\n\n---Available_Lessons---\n[Eval](lesson/eval_lesson.md)\n",
encoding="utf-8",
)
db = SessionLocal()
try:
student = repo.find_user_by_raw_token(db, STUDENT_TOKEN)
assert student is not None
lesson = repo.upsert_lesson(db, slug="eval_lesson", title="Eval", order_index=0)
db.commit()
repo.set_exercise_passed(db, user_id=student.id, lesson_id=lesson.id)
repo.recompute_progress(
db,
user_id=student.id,
lesson_id=lesson.id,
has_exercise=True,
has_quiz=False,
exercise_weight=LESSON_EXERCISE_WEIGHT,
quiz_weight=LESSON_QUIZ_WEIGHT,
done_min_percent=LESSON_DONE_MIN_PERCENT,
)
db.commit()
assert repo.get_progress(db, user_id=student.id, lesson_id=lesson.id).state == "done"
student_id, lesson_id = student.id, lesson.id
finally:
db.close()
resp = client.post("/content/drafts", json={
"target_path": lesson_path,
"body": (
"# Eval\n\n---INITIAL_CODE---\n```c\nint main(){return 0;}\n```\n\n"
"---QUIZ_MCQ---\n### 1+1?\n- [x] 2\n- [] 3\n---END_QUIZ_MCQ---\n"
),
"base_mtime": os.path.getmtime(str(TEST_CONTENT / lesson_path)),
})
assert resp.status_code == 200
draft_id = resp.get_json()["draft_id"]
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 200
data = resp.get_json()
assert data["evaluation_changed"] is True
assert data["progress_recomputed"] == 1
db = SessionLocal()
try:
progress = repo.get_progress(db, user_id=student_id, lesson_id=lesson_id)
assert progress is not None
# Exercise 100×0,7 + Quiz 0×0,3 = 70 < 75 → siswa harus mengerjakan quiz.
assert progress.state == "in_progress"
assert progress.composite_percent == 70.0
# Skor historis tidak dikarang — quiz memang belum pernah dikerjakan.
assert progress.quiz_score_earned is None
finally:
db.close()
def test_publish_home_syncs_lesson_registry(client):
"""Publish home.md langsung menyinkronkan tabel lessons — lesson baru
tampil di laporan progress tanpa restart server."""
_seed_and_setup()
_login_teacher(client)
(TEST_CONTENT / "test_editor" / "new_lesson.md").write_text(
"# New Lesson\n\nisi", encoding="utf-8"
)
home = TEST_CONTENT / "home.md"
resp = client.post("/content/drafts", json={
"target_path": "home.md",
"body": (
"# Home\n\n---Available_Lessons---\n"
"[Hello](lesson/hello.md)\n[New](lesson/new_lesson.md)\n"
),
"base_mtime": os.path.getmtime(str(home)),
})
draft_id = resp.get_json()["draft_id"]
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 200
data = resp.get_json()
assert data["registry"]["specs"] == 2
db = SessionLocal()
try:
assert repo.get_lesson_by_slug(db, "hello") is not None
assert repo.get_lesson_by_slug(db, "new_lesson") is not None
finally:
db.close()
def test_sync_registry_requires_teacher(client):
_seed_and_setup()
resp = client.post("/content/sync-registry")
assert resp.status_code == 401
def test_sync_registry_rejects_student(client):
_seed_and_setup()
_login_student(client)
resp = client.post("/content/sync-registry")
assert resp.status_code == 401
def test_sync_registry_teacher_ok(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/sync-registry")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "registry" in data
assert "progress" in data