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)
This commit is contained in:
a2nr 2026-10-03 21:47:05 +07:00
parent bb8ca50650
commit 3c2546c818
5 changed files with 221 additions and 8 deletions

View File

@ -256,3 +256,74 @@ describe('extractBundle', () => {
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<string>(),
});
expect(bodies[0].overwrite).toBe(false);
});
it('respons tanpa message → fallback "HTTP <status>", 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');
});
});

View File

@ -135,14 +135,21 @@ function fileToBase64(file: File): Promise<string> {
});
}
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<ImportSummar
const res = await uploadAsset(
new File([bytes.slice().buffer as ArrayBuffer], spec.path.split('/').pop() ?? 'file'),
spec.path.includes('/') ? spec.path.slice(0, spec.path.lastIndexOf('/')) : '',
mode === 'overwrite',
);
if (!res.success) {
return { success: false, imported, skipped, failedPath: spec.path, message: res.message };
return { success: false, imported, skipped, failedPath: spec.path, message: res.message ?? 'Gagal mengunggah asset' };
}
}
imported++;

View File

@ -27,7 +27,7 @@ import re as _re
import shutil
import zipfile
from flask import Blueprint, jsonify, request, send_file
from flask import Blueprint, current_app, jsonify, request, send_file
from config import ASSETS_DIR, CONTENT_DIR
from services import repositories
@ -459,6 +459,30 @@ def delete_entry():
db.close()
def _sniff_image_format(raw: bytes) -> 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"<html", b"<!doctype", b"<?xml", b"<svg")):
return "HTML/XML"
try:
raw[:64].decode("utf-8")
return "teks"
except UnicodeDecodeError:
return "tidak dikenal"
@content_editor_bp.route("/assets/upload", methods=["POST"])
def upload_asset():
"""Body JSON (bukan multipart — lihat catatan proxy di hooks.server.ts):
@ -508,13 +532,23 @@ def upload_asset():
im.load() # raises jika bukan gambar yang bisa di-decode
except ImportError:
pass # Pillow tidak terinstal — skip validasi gambar
except (UnidentifiedImageError, OSError, SyntaxError):
return jsonify({"success": False, "message": "File bukan gambar yang valid"}), 400
except (UnidentifiedImageError, OSError, SyntaxError) as e:
fmt = _sniff_image_format(raw)
hint = " — konversi ke PNG/JPEG dulu" if fmt == "AVIF" else ""
current_app.logger.warning(
"upload_asset: PIL menolak %s (terdeteksi: %s): %r", filename, fmt, e)
return jsonify({
"success": False,
"message": f"File bukan gambar yang valid (terdeteksi: {fmt}){hint}",
}), 400
safe_name = _re.sub(r"[^a-zA-Z0-9_.-]", "_", os.path.basename(filename))
rel_path = f"{folder_rel}/{safe_name}" if folder_rel else safe_name
abs_path = os.path.join(_ASSETS_DIR_ABS, rel_path)
if os.path.exists(abs_path):
# overwrite=True hanya dikirim eksplisit (jalur import bundle mode
# "timpa"). Upload manual UI tidak mengirim flag → tetap 409, jadi
# file lama tidak pernah tertimpa tanpa niat.
if os.path.exists(abs_path) and not data.get("overwrite"):
return jsonify({"success": False, "message": "Nama file sudah ada, ganti nama dulu"}), 409
os.makedirs(os.path.dirname(abs_path), exist_ok=True)

View File

@ -11,6 +11,7 @@ Langkah:
Menulis zip probe sendiri (content/imported/probe.md + assets/imported/probe.png).
"""
import base64
import io
import sys
import zipfile
@ -23,13 +24,20 @@ TEACHER_TOKEN = "141214"
PROBE_ZIP = Path("/tmp/elemes-probe-bundle.zip")
DOWNLOAD_DIR = Path("/tmp/elemes-e2e-downloads")
# PNG 1x1 valid — backend memvalidasi isi gambar via Pillow (bukan cuma
# ekstensi), jadi probe harus gambar yang benar-benar bisa di-decode.
PNG_1PX = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
def make_probe_zip() -> 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

View File

@ -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"<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.