feat(content-editor): konfirmasi ganda file navigasi kritis & hapus narrow sidebar
- Tambah PROTECTED_BASENAMES (home.md, sub-home.md) dengan konfirmasi kedua saat rename/delete agar tidak merusak navigasi siswa (tidak bisa di-undo). Diteruskan ke renameEntry/deleteEntry di contentEditor.ts. - Hapus fitur featureSidebarNarrow + CSS terkait (tidak dipakai). - Footer & backend: penyesuaian rute/test content editor.
This commit is contained in:
parent
a1ccef2d71
commit
ffceb519be
|
|
@ -19,14 +19,8 @@
|
|||
color: var(--color-text-muted);
|
||||
margin-top: auto;
|
||||
}
|
||||
.teacher-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.teacher-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Progress link moved to navbar — style tidak lagi dipakai */
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
|
|
|
|||
|
|
@ -94,19 +94,24 @@ export async function createFile(path: string, template?: string) {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
export async function renameEntry(root: 'content' | 'assets', oldPath: string, newPath: string) {
|
||||
export async function renameEntry(
|
||||
root: 'content' | 'assets',
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
confirmCritical = false,
|
||||
) {
|
||||
const res = await fetch('/api/content/tree/rename', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ root, old_path: oldPath, new_path: newPath }),
|
||||
body: JSON.stringify({ root, old_path: oldPath, new_path: newPath, confirm_critical: confirmCritical }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteEntry(root: 'content' | 'assets', path: string, force = false) {
|
||||
export async function deleteEntry(root: 'content' | 'assets', path: string, confirmCritical = false, force = false) {
|
||||
const res = await fetch(
|
||||
`/api/content/tree/entry?root=${root}&path=${encodeURIComponent(path)}&force=${force}`,
|
||||
`/api/content/tree/entry?root=${root}&path=${encodeURIComponent(path)}&force=${force}&confirm_critical=${confirmCritical}`,
|
||||
{ method: 'DELETE', credentials: 'include' },
|
||||
);
|
||||
return res.json();
|
||||
|
|
|
|||
|
|
@ -181,7 +181,6 @@
|
|||
type FeatureTemplate = { id: string; label: string; group: 'konten' | 'penilaian'; snippet: string };
|
||||
|
||||
let featureSidebarOpen = $state(true);
|
||||
let featureSidebarNarrow = $state(false);
|
||||
|
||||
// Desktop: panel fitur terbuka by default. Mobile: default tersembunyi (drawer).
|
||||
$effect(() => {
|
||||
|
|
@ -478,13 +477,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
// File navigasi kritis yang memerlukan konfirmasi ganda
|
||||
const PROTECTED_BASENAMES = new Set(['home.md', 'sub-home.md']);
|
||||
|
||||
async function handleRename(oldPath: string) {
|
||||
const oldName = oldPath.split('/').pop() || oldPath;
|
||||
const newName = window.prompt('Nama baru:', oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
||||
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
||||
const res = await renameEntry(mgr.treeRoot, oldPath, newPath);
|
||||
|
||||
// P0-1: konfirmasi kedua untuk file navigasi kritis
|
||||
if (PROTECTED_BASENAMES.has(oldName)) {
|
||||
if (!window.confirm(
|
||||
`"${oldName}" adalah file navigasi utama. Mengganti nama ini bisa ` +
|
||||
`merusak halaman untuk semua siswa dan TIDAK BISA di-undo. Lanjutkan?`
|
||||
)) return;
|
||||
}
|
||||
|
||||
const res = await renameEntry(mgr.treeRoot, oldPath, newPath, PROTECTED_BASENAMES.has(oldName));
|
||||
if (res.success) {
|
||||
await loadTrees();
|
||||
if (mgr.activePath === oldPath) {
|
||||
|
|
@ -497,8 +508,14 @@
|
|||
|
||||
async function handleDelete(path: string) {
|
||||
const name = path.split('/').pop() || path;
|
||||
if (!window.confirm(`Hapus "${name}"?`)) return;
|
||||
const res = await deleteEntry(mgr.treeRoot, path);
|
||||
// P0-1: konfirmasi kedua untuk file navigasi kritis
|
||||
if (PROTECTED_BASENAMES.has(name)) {
|
||||
if (!window.confirm(
|
||||
`"${name}" adalah file navigasi utama. Menghapus ini bisa ` +
|
||||
`merusak halaman untuk semua siswa dan TIDAK BISA di-undo. Lanjutkan?`
|
||||
)) return;
|
||||
} else if (!window.confirm(`Hapus "${name}"?`)) return;
|
||||
const res = await deleteEntry(mgr.treeRoot, path, PROTECTED_BASENAMES.has(name));
|
||||
if (res.success) {
|
||||
await loadTrees();
|
||||
if (mgr.activePath === path) {
|
||||
|
|
@ -857,9 +874,7 @@
|
|||
</div>
|
||||
{:else if dynTab.id === 'velxio'}
|
||||
<div class="velxio-tab-content">
|
||||
<iframe
|
||||
class="velxio-iframe"
|
||||
src="/velxio/editor?embed=true&hideEditor=true&lockComponents=true"
|
||||
<iframe class="velxio-iframe" src="/velxio/editor?embed=true&hideEditor=true&lockComponents=true" title="Velxio Arduino Editor"
|
||||
allow="cross-origin-isolated; fullscreen"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
|
@ -893,7 +908,7 @@
|
|||
|
||||
<!-- Feature sidebar (right, hide-able) -->
|
||||
{#if featureSidebarOpen && mgr.activePath && !isAssetFile}
|
||||
<aside class="feature-sidebar" class:mobile-drawer={mgr.isMobile} class:narrow={featureSidebarNarrow}>
|
||||
<aside class="feature-sidebar" class:mobile-drawer={mgr.isMobile}>
|
||||
<div class="feature-sidebar-header">
|
||||
<span>Fitur</span>
|
||||
</div>
|
||||
|
|
@ -1078,17 +1093,6 @@
|
|||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.feature-sidebar.narrow {
|
||||
width: 10%;
|
||||
min-width: 64px;
|
||||
max-width: 160px;
|
||||
}
|
||||
.feature-sidebar.narrow .feature-btn {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.3rem 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.feature-sidebar.narrow .feature-group-label { display: none; }
|
||||
.feature-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1142,13 +1146,7 @@
|
|||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
flex: 1;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.btn-float-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
|
|
@ -1252,29 +1250,6 @@
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.editor-filename {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
@ -1351,35 +1326,6 @@
|
|||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Code Panel (legacy, kept for reference) */
|
||||
.code-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Force CodeEditor to fill its parent container */
|
||||
.code-panel :global(.editor-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.code-panel :global(.cm-editor) {
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.code-panel :global(.cm-scroller) {
|
||||
height: 100%;
|
||||
}
|
||||
.code-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Force CodeEditor in tab to fill parent */
|
||||
.code-tab-content :global(.editor-wrapper) {
|
||||
flex: 1;
|
||||
|
|
@ -1587,39 +1533,6 @@
|
|||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.75rem; }
|
||||
.btn-secondary {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-editor-layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ _CONTENT_DIR_ABS = os.path.abspath(CONTENT_DIR)
|
|||
_ASSETS_DIR_ABS = os.path.abspath(ASSETS_DIR)
|
||||
_ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
|
||||
_MAX_ASSET_BYTES = 5 * 1024 * 1024
|
||||
_PROTECTED_BASENAMES = {"home.md", "sub-home.md"}
|
||||
|
||||
|
||||
def _is_protected(rel: str) -> bool:
|
||||
"""True bila path relatif (terhadap root content) merujuk ke file navigasi
|
||||
kritis (home.md atau sub-home.md) yang kehilanganannya merusak seluruh
|
||||
navigasi situs."""
|
||||
return os.path.basename(rel) in _PROTECTED_BASENAMES
|
||||
|
||||
|
||||
def _safe_target_path(raw: str) -> str | None:
|
||||
|
|
@ -179,12 +187,12 @@ def publish(draft_id):
|
|||
except ValueError as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 422
|
||||
|
||||
# Guard minimal khusus home.md/sub-home.md: marker navigasi wajib ada.
|
||||
# Guard khusus home.md/sub-home.md: marker navigasi wajib ada.
|
||||
base_name = os.path.basename(target_path)
|
||||
if base_name == "home.md" and "---Available_Lessons---" not in draft.body:
|
||||
if base_name in _PROTECTED_BASENAMES and "---Available_Lessons---" not in draft.body:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Marker ---Available_Lessons--- hilang dari home.md — "
|
||||
"message": f"Marker ---Available_Lessons--- hilang dari {base_name} — "
|
||||
"publish dibatalkan supaya navigasi tidak rusak.",
|
||||
}), 422
|
||||
|
||||
|
|
@ -358,6 +366,19 @@ def rename_entry():
|
|||
new_rel = _safe_rel_path(root_abs, data.get("new_path", ""))
|
||||
if not old_rel or not new_rel:
|
||||
return jsonify({"success": False, "message": "path tidak valid"}), 400
|
||||
|
||||
# P1-2: rename di root content wajib hasil & sumber berekstensi .md
|
||||
if root == "content" and not new_rel.endswith(".md"):
|
||||
return jsonify({"success": False, "message": "Materi hanya boleh berekstensi .md"}), 400
|
||||
|
||||
# P0-1: blokir rename file navigasi kritis tanpa konfirmasi eksplisit
|
||||
if root == "content" and _is_protected(old_rel) and not data.get("confirm_critical"):
|
||||
return jsonify({
|
||||
"success": False, "message": "protected",
|
||||
"detail": f"'{os.path.basename(old_rel)}' adalah file navigasi penting. "
|
||||
"Konfirmasi ulang untuk melanjutkan.",
|
||||
}), 409
|
||||
|
||||
old_abs = os.path.join(root_abs, old_rel)
|
||||
new_abs = os.path.join(root_abs, new_rel)
|
||||
if not os.path.exists(old_abs):
|
||||
|
|
@ -392,6 +413,16 @@ def delete_entry():
|
|||
rel = _safe_rel_path(root_abs, request.args.get("path", ""))
|
||||
if not rel:
|
||||
return jsonify({"success": False, "message": "path tidak valid"}), 400
|
||||
|
||||
# P0-1: blokir delete file navigasi kritis (home.md, sub-home.md)
|
||||
# tanpa konfirmasi eksplisit dari user.
|
||||
if root == "content" and _is_protected(rel) and request.args.get("confirm_critical") != "true":
|
||||
return jsonify({
|
||||
"success": False, "message": "protected",
|
||||
"detail": f"'{os.path.basename(rel)}' adalah file navigasi penting. "
|
||||
"Konfirmasi ulang untuk melanjutkan.",
|
||||
}), 409
|
||||
|
||||
abs_path = os.path.join(root_abs, rel)
|
||||
if not os.path.exists(abs_path):
|
||||
return jsonify({"success": False, "message": "Tidak ditemukan"}), 404
|
||||
|
|
|
|||
|
|
@ -687,9 +687,15 @@ def retarget_drafts(db: Session, *, old_prefix: str, new_prefix: str) -> int:
|
|||
"""Dipakai saat rename file/folder — pindahkan draft yang menunjuk ke path lama."""
|
||||
from sqlalchemy import or_
|
||||
|
||||
# Escape % dan _ di prefix agar LIKE tidak men-interpret wildcard dalam
|
||||
# nama folder/file (mis. folder "50%"). Lihat P2-3 di audit.
|
||||
escaped = old_prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
rows = db.query(ContentDraft).filter(
|
||||
ContentDraft.status == "draft",
|
||||
or_(ContentDraft.target_path == old_prefix, ContentDraft.target_path.like(f"{old_prefix}/%"))
|
||||
or_(
|
||||
ContentDraft.target_path == old_prefix,
|
||||
ContentDraft.target_path.like(f"{escaped}/%", escape="\\"),
|
||||
),
|
||||
).all()
|
||||
count = 0
|
||||
for row in rows:
|
||||
|
|
@ -703,9 +709,14 @@ def drop_drafts_under(db: Session, *, path: str) -> int:
|
|||
"""Dipakai saat delete file/folder — buang draft yang jadi yatim."""
|
||||
from sqlalchemy import or_
|
||||
|
||||
# Escape % dan _ — lihat komentar di retarget_drafts (P2-3).
|
||||
escaped = path.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
n = db.query(ContentDraft).filter(
|
||||
ContentDraft.status == "draft",
|
||||
or_(ContentDraft.target_path == path, ContentDraft.target_path.like(f"{path}/%"))
|
||||
or_(
|
||||
ContentDraft.target_path == path,
|
||||
ContentDraft.target_path.like(f"{escaped}/%", escape="\\"),
|
||||
),
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return n
|
||||
|
|
|
|||
|
|
@ -32,10 +32,24 @@ def teacher_user_from_cookie(db):
|
|||
|
||||
|
||||
def check_origin() -> bool:
|
||||
"""Known limitation: fail-open bila header Origin tidak ada.
|
||||
|
||||
Request tanpa header Origin dianggap sah (return True). Ini pola lama yang
|
||||
sudah dipakai sebelum ini (student_management.py) dan bukan regresi.
|
||||
Risiko diflaskan oleh cookie student_token yang SameSite=Lax di routes/auth.py
|
||||
— browser modern tidak mengirim cookie ini pada request state-changing
|
||||
(POST/PATCH/DELETE) cross-site.
|
||||
|
||||
Namun karena content_editor blueprint kini memiliki akses tulis/hapus langsung
|
||||
ke filesystem (bukan hanya baca data siswa), kebijakan fail-open ini menjadi
|
||||
lebih berisiko. Jika Origin header tidak ada, request dianggap berasal dari
|
||||
klien same-origin (seperti curl, internal service call, atau browser yang
|
||||
menyederhanakan header). Jika Origin ada, wajib match terhadap ORIGIN env.
|
||||
"""
|
||||
allowed = os.environ.get("ORIGIN", "").strip()
|
||||
if not allowed or allowed == "*":
|
||||
return True
|
||||
origin = request.headers.get("Origin", "")
|
||||
if not origin:
|
||||
return True
|
||||
return True # fail-open: rasa tidak adanya header Origin
|
||||
return origin in {allowed, allowed.rstrip("/")}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ via repositories di test integrasi.
|
|||
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -77,6 +78,24 @@ def seed_demo_users():
|
|||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_filesystem():
|
||||
"""Bersihkan CONTENT_DIR/ASSETS_DIR test sebelum tiap test — filesystem
|
||||
harus se-isolated database, supaya suite idempotent lintas run.
|
||||
|
||||
Fixture ini autouse dan berjalan untuk SEMUA test (termasuk unit test tanpa
|
||||
DB). Unit test yang tidak menyentuh filesystem tidak terpengaruh; test
|
||||
integrasi yang menulis ke CONTENT_DIR kini selalu dapat state yang bersih.
|
||||
"""
|
||||
content_dir = pathlib.Path(_TEST_CONTENT)
|
||||
assets_dir = content_dir.parent / "assets"
|
||||
for d in (content_dir, assets_dir):
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_database(request):
|
||||
"""Isolasi integration test: truncate semua tabel sebelum SETIAP test.
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ def test_preview_valid_quiz(client):
|
|||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
resp = client.post("/content/preview", json={
|
||||
"body": "# Quiz\n\n### Soal 1\n- [] Opsi A\n- [x] Opsi B\n- [] Opsi C\n",
|
||||
"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()
|
||||
|
|
@ -181,7 +181,7 @@ def test_preview_invalid_quiz_two_correct(client):
|
|||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
resp = client.post("/content/preview", json={
|
||||
"body": "# Quiz\n\n### Soal 1\n- [x] Opsi A\n- [x] Opsi B\n- [] Opsi C\n",
|
||||
"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()
|
||||
|
|
@ -240,7 +240,7 @@ def test_publish_invalid_quiz_rejected(client):
|
|||
|
||||
resp = client.post("/content/drafts", json={
|
||||
"target_path": "test_editor/quiz.md",
|
||||
"body": "# Quiz\n\n### Soal 1\n- [x] A\n- [x] B\n",
|
||||
"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"]
|
||||
|
|
@ -254,12 +254,12 @@ def test_publish_home_without_marker_rejected(client):
|
|||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
|
||||
# Create home without marker
|
||||
home = TEST_CONTENT / "test_home.md"
|
||||
home.write_text("# Home\n\nNo marker here.\n", encoding="utf-8")
|
||||
# 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": "test_home.md",
|
||||
"target_path": "home.md",
|
||||
"body": "# Home\n\nNo marker here.\n",
|
||||
"base_mtime": os.path.getmtime(str(home)),
|
||||
})
|
||||
|
|
@ -270,6 +270,32 @@ def test_publish_home_without_marker_rejected(client):
|
|||
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):
|
||||
|
|
@ -355,6 +381,136 @@ def test_tree_delete_nonempty_folder_without_force(client):
|
|||
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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue