From 3c2546c818d6fa0897f3818e6ff8fb4858bc11ef Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 3 Oct 2026 21:47:05 +0700 Subject: [PATCH] fix(content-editor): diagnostik upload asset + kelengkapan bundle - upload_asset: deteksi format dari magic bytes saat PIL menolak, log exception asli - contentEditor.ts: import bundle stop di failure pertama (informasi utuh tetap tersedia); guard frontend - verify_import_export.py: probe PNG 1x1 valid (PNG palsu ditolak PIL) - test_content_editor_routes.py: coverage pesan error + AVIF - bundle: elemes_content_fixed.zip (electron_flow_sparkfun.png 404 nginx dibuang) --- .../src/lib/services/contentEditor.test.ts | 71 ++++++++++++++ frontend/src/lib/services/contentEditor.ts | 14 ++- routes/content_editor.py | 42 ++++++++- scripts/verify_import_export.py | 10 +- services/tests/test_content_editor_routes.py | 92 +++++++++++++++++++ 5 files changed, 221 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/services/contentEditor.test.ts b/frontend/src/lib/services/contentEditor.test.ts index 9a078e2..cbc6c16 100644 --- a/frontend/src/lib/services/contentEditor.test.ts +++ b/frontend/src/lib/services/contentEditor.test.ts @@ -255,4 +255,75 @@ describe('extractBundle', () => { const { extractBundle } = await import('./contentEditor'); await expect(extractBundle(new File([new Uint8Array([1, 2, 3])], 'b.zip'))).rejects.toThrow(); }); +}); + +describe('importBundle — overwrite asset & pesan fallback', () => { + it('mode overwrite mengirim overwrite:true untuk asset', async () => { + const { importBundle } = await import('./contentEditor'); + const bodies: any[] = []; + global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('/api/content/tree/folder')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/api/content/assets/upload')) { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + }); + const result = await importBundle({ + contentFiles: [], + assetFiles: [{ path: 'ovw.png', bytes: new Uint8Array([1]) }], + mode: 'overwrite', + }); + expect(bodies[0].overwrite).toBe(true); + expect(result.success).toBe(true); + }); + + it('mode skip mengirim overwrite:false untuk asset', async () => { + const { importBundle } = await import('./contentEditor'); + const bodies: any[] = []; + global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('/api/content/assets/upload')) { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + }); + await importBundle({ + contentFiles: [], + assetFiles: [{ path: 's.png', bytes: new Uint8Array([1]) }], + mode: 'skip', + existingPaths: new Set(), + }); + expect(bodies[0].overwrite).toBe(false); + }); + + it('respons tanpa message → fallback "HTTP ", bukan undefined', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes('/api/content/assets/upload')) { + return Promise.resolve(new Response(JSON.stringify({ error: 'Backend unavailable' }), { status: 502 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + }); + const result = await importBundle({ + contentFiles: [], + assetFiles: [{ path: 'x.png', bytes: new Uint8Array([1]) }], + mode: 'overwrite', + }); + expect(result.success).toBe(false); + expect(result.message).toContain('502'); + expect(result.message).not.toContain('undefined'); + }); + + it('uploadAsset merespons JSON sukses apa adanya (res.ok true)', async () => { + const { uploadAsset } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, url: '/assets/x.png' }), { status: 200 }), + ); + const res = await uploadAsset(new File(['x'], 'x.png'), ''); + expect(res.success).toBe(true); + expect(res.url).toBe('/assets/x.png'); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/services/contentEditor.ts b/frontend/src/lib/services/contentEditor.ts index ae064ca..59eb213 100644 --- a/frontend/src/lib/services/contentEditor.ts +++ b/frontend/src/lib/services/contentEditor.ts @@ -135,14 +135,21 @@ function fileToBase64(file: File): Promise { }); } -export async function uploadAsset(file: File, folder = '') { +export async function uploadAsset(file: File, folder = '', overwrite = false) { const content_base64 = await fileToBase64(file); const res = await fetch('/api/content/assets/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ filename: file.name, folder, content_base64 }), + body: JSON.stringify({ filename: file.name, folder, content_base64, overwrite }), }); + if (!res.ok) { + // Respons error bisa berupa JSON {message, ...}, JSON lain (mis. 502 + // proxy {"error": ...}), atau HTML (Flask default 404/500). Pastikan + // selalu menghasilkan string — jangan pernah 'undefined'. + const data = await res.json().catch(() => null); + return { success: false, message: data?.message ?? `HTTP ${res.status}` }; + } return res.json(); } @@ -237,9 +244,10 @@ export async function importBundle(options: ImportOptions): Promise str: + """Deteksi format aktual dari magic bytes — dipakai untuk pesan error + diagnostik saat PIL menolak file (PIL tidak menyebut format aslinya; + kasus nyata: halaman 404 HTML tersamar sebagai .png di bundle zip).""" + if raw.startswith(b"\x89PNG"): + return "PNG" + if raw.startswith(b"\xff\xd8\xff"): + return "JPEG" + if raw.startswith((b"GIF87a", b"GIF89a")): + return "GIF" + if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": + return "WebP" + if raw[4:8] == b"ftyp" and raw[8:12] in (b"avif", b"avis"): + return "AVIF" + head = raw[:512].lstrip().lower() + if head.startswith((b" Path: if PROBE_ZIP.exists(): return PROBE_ZIP with zipfile.ZipFile(PROBE_ZIP, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("content/imported/probe.md", "# Probe Import\n\nMateri test.\n") - zf.writestr("assets/imported/probe.png", b"\x89PNG\r\n\x1a\nfake-png") + zf.writestr("assets/imported/probe.png", PNG_1PX) return PROBE_ZIP diff --git a/services/tests/test_content_editor_routes.py b/services/tests/test_content_editor_routes.py index 1265d54..9aef33f 100644 --- a/services/tests/test_content_editor_routes.py +++ b/services/tests/test_content_editor_routes.py @@ -10,6 +10,7 @@ Mengunci kontrak: - regression: _render_markdown_string() == render_markdown_content() untuk file yang sama; """ +import base64 import io import os import pathlib @@ -670,6 +671,97 @@ def test_export_scope_content_excludes_assets(client): 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"\r\n404 Not Found\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.