Merge branch 'dev' into master — fix upload asset proxy & diagnostik bundle
This commit is contained in:
commit
75438e2ea0
|
|
@ -57,6 +57,147 @@ describe('hooks.server handle', () => {
|
|||
expect(response.headers.get('Cross-Origin-Resource-Policy')).toBe('cross-origin');
|
||||
});
|
||||
|
||||
it('should return 413 with size message when body exceeds adapter limit', async () => {
|
||||
// SvelteKitError dari adapter-node saat body POST melebihi BODY_SIZE_LIMIT.
|
||||
// Proxy harus balas 413 dengan pesan ukuran — bukan 502 "Backend tidak tersedia".
|
||||
const arrayBufferMock = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('Content-length of 947488 exceeds limit of 524288 bytes.'), { status: 413 })
|
||||
);
|
||||
const mockEvent: any = {
|
||||
request: {
|
||||
method: 'POST',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
arrayBuffer: arrayBufferMock
|
||||
},
|
||||
url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/api/content/assets/upload')
|
||||
};
|
||||
|
||||
const origFetch = global.fetch;
|
||||
global.fetch = vi.fn() as any;
|
||||
|
||||
try {
|
||||
const resolve = vi.fn();
|
||||
const response = await handle({ event: mockEvent, resolve });
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.message).toMatch(/terlalu besar/i);
|
||||
// Tidak boleh dikirim ke backend sama sekali
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
global.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should still return 502 for ordinary network errors (not 413)', async () => {
|
||||
// Regression guard: error jaringan biasa (fetch failed) TIDAK Boleh
|
||||
// diklasifikasikan 413 — harus tetap 502 "Backend tidak tersedia".
|
||||
const arrayBufferMock = vi.fn().mockRejectedValue(new TypeError('fetch failed'));
|
||||
const mockEvent: any = {
|
||||
request: {
|
||||
method: 'POST',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
arrayBuffer: arrayBufferMock
|
||||
},
|
||||
url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/api/content/assets/upload')
|
||||
};
|
||||
|
||||
const origFetch = global.fetch;
|
||||
global.fetch = vi.fn() as any;
|
||||
|
||||
try {
|
||||
const resolve = vi.fn();
|
||||
const response = await handle({ event: mockEvent, resolve });
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.message).toBe('Backend tidak tersedia');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
global.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 413 when error has status 413 even without "exceeds limit" message', async () => {
|
||||
// Deteksi via err.status === 413 harus bekerja tanpa regex pesan
|
||||
// (pesan library bisa berubah; status code adalah kontrak).
|
||||
const arrayBufferMock = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('request body too large'), { status: 413 })
|
||||
);
|
||||
const mockEvent: any = {
|
||||
request: {
|
||||
method: 'POST',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
arrayBuffer: arrayBufferMock
|
||||
},
|
||||
url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/api/content/assets/upload')
|
||||
};
|
||||
|
||||
const origFetch = global.fetch;
|
||||
global.fetch = vi.fn() as any;
|
||||
|
||||
try {
|
||||
const resolve = vi.fn();
|
||||
const response = await handle({ event: mockEvent, resolve });
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.message).toMatch(/terlalu besar/i);
|
||||
} finally {
|
||||
global.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should add restrictive CSP when proxying SVG assets (stored-XSS mitigasi)', async () => {
|
||||
// SVG bisa memuat <script>. Saat SVG dibuka langsung di tab browser,
|
||||
// script-nya jalan same-origin → bisa baca localStorage (token).
|
||||
// Proxy HARUS memberi CSP ketat — backend juga mengirim header ini,
|
||||
// tapi proxy sebelumnya hanya meneruskan content-type/set-cookie.
|
||||
const mockEvent: any = {
|
||||
request: { method: 'GET', headers: new Headers() },
|
||||
url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/assets/malware.svg')
|
||||
};
|
||||
const origFetch = global.fetch;
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response('<svg><script>alert(1)</script></svg>', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/svg+xml' }
|
||||
})
|
||||
) as any;
|
||||
|
||||
try {
|
||||
const resolve = vi.fn();
|
||||
const response = await handle({ event: mockEvent, resolve });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox");
|
||||
} finally {
|
||||
global.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT add CSP for non-SVG assets', async () => {
|
||||
const mockEvent: any = {
|
||||
request: { method: 'GET', headers: new Headers() },
|
||||
url: new URL('https://sinau-c-dev.manakin-gentoo.ts/assets/diagram.png')
|
||||
};
|
||||
const origFetch = global.fetch;
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response('binary', { status: 200, headers: { 'content-type': 'image/png' } })
|
||||
) as any;
|
||||
|
||||
try {
|
||||
const resolve = vi.fn();
|
||||
const response = await handle({ event: mockEvent, resolve });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-security-policy')).toBeNull();
|
||||
} finally {
|
||||
global.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should read body as arrayBuffer when proxying POST requests', async () => {
|
||||
const arrayBufferMock = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]).buffer);
|
||||
const mockEvent: any = {
|
||||
|
|
|
|||
|
|
@ -73,13 +73,43 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) resHeaders['set-cookie'] = setCookie;
|
||||
|
||||
// Mitigasi stored-XSS: SVG asset bisa memuat <script>. Route
|
||||
// /assets/* publik (same-origin) — kalau SVG dibuka langsung di
|
||||
// tab browser, script-nya bisa membaca localStorage (token
|
||||
// siswa/guru). Backend juga mengirim CSP ini (routes/lessons.py
|
||||
// send_assets), tapi proxy hanya meneruskan content-type/
|
||||
// set-cookie — jadi beri ulang di sini.
|
||||
if (isAsset && event.url.pathname.toLowerCase().endsWith('.svg')) {
|
||||
resHeaders['content-security-policy'] = "default-src 'none'; sandbox";
|
||||
}
|
||||
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: resHeaders,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
// adapter-node melempar SvelteKitError saat body POST melebihi
|
||||
// BODY_SIZE_LIMIT — bukan berarti backend mati. Laporkan sebagai 413
|
||||
// dengan pesan yang jelas, jangan 502 "Backend tidak tersedia".
|
||||
const isBodyTooLarge =
|
||||
err?.status === 413 || /exceeds limit/i.test(String(err?.message ?? ''));
|
||||
if (isBodyTooLarge) {
|
||||
console.warn(`[hooks] Body too large for ${event.request.method} ${event.url.pathname}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: 'Payload too large',
|
||||
// Nilai sumber: _MAX_ASSET_BYTES di routes/content_editor.py (backend).
|
||||
message: 'Ukuran file terlalu besar (maksimum 5 MiB per asset)'
|
||||
}),
|
||||
{
|
||||
status: 413,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
console.error(`API proxy error (${backendUrl}):`, err);
|
||||
return new Response(JSON.stringify({ error: 'Backend unavailable' }), {
|
||||
return new Response(JSON.stringify({ success: false, error: 'Backend unavailable', message: 'Backend tidak tersedia' }), {
|
||||
status: 502,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ services:
|
|||
environment:
|
||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||
- API_BACKEND=http://127.0.0.1:5000
|
||||
# Backend menerima asset ≤ 5 MiB (JSON base64 ≈ 7 MiB di wire).
|
||||
# Default adapter-node 512K menolak upload gambar sebelum sampai backend.
|
||||
# Nilai sumber: _MAX_ASSET_BYTES di routes/content_editor.py — sinkronkan.
|
||||
- BODY_SIZE_LIMIT=8M
|
||||
- PUBLIC_APP_BAR_TITLE=${APP_BAR_TITLE}
|
||||
- PUBLIC_COPYRIGHT_TEXT=${COPYRIGHT_TEXT}
|
||||
- PUBLIC_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -40,7 +40,11 @@ content_editor_bp = Blueprint("content_editor", __name__, url_prefix="/content")
|
|||
_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
|
||||
_MAX_ASSET_BYTES = 5 * 1024 * 1024 # 5 MiB — batas upload per asset.
|
||||
# SINKRONISASI: frontend hook (frontend/src/hooks.server.ts) mengembalikan
|
||||
# pesan 413 "maksimum 5 MiB per asset", dan podman-compose.yml menetapkan
|
||||
# BODY_SIZE_LIMIT=8M (5 MiB × 4/3 base64 + envelope). Ubah nilai di sini →
|
||||
# perbarui keduanya.
|
||||
_PROTECTED_BASENAMES = {"home.md", "sub-home.md"}
|
||||
|
||||
|
||||
|
|
@ -455,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):
|
||||
|
|
@ -504,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)
|
||||
|
|
|
|||
|
|
@ -294,4 +294,11 @@ def get_key_text(filename):
|
|||
@lessons_bp.route('/assets/<path:path>')
|
||||
def send_assets(path):
|
||||
"""Serve asset files (images, etc.) from ASSETS_DIR."""
|
||||
return send_from_directory(ASSETS_DIR, path)
|
||||
response = send_from_directory(ASSETS_DIR, path)
|
||||
# Mitigasi stored-XSS: SVG bisa memuat <script>. Route ini publik
|
||||
# (same-origin) — kalau SVG dibuka langsung di tab browser, script-nya
|
||||
# bisa membaca localStorage (token siswa/guru). CSP ketat mematikan
|
||||
# semua eksekusi di dokumen tersebut; sandbox melarang navigasi/form.
|
||||
if path.lower().endswith(".svg"):
|
||||
response.headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env python3
|
||||
"""E2E: upload asset asli (../mr-edu/assets/ilustrasi_beban.jpeg) via UI.
|
||||
|
||||
Verifikasi fix "Backend tidak tersedia" (502) saat upload:
|
||||
1. Upload file yang ASLINYA gagal (710 KB, body JSON-base64 ~947 KB) via UI.
|
||||
2. POST /api/content/assets/upload → 200, entri di tree, download bytes-cocok.
|
||||
3. Jalur 413: body > 8M → 413 + pesan ukuran (bukan 502).
|
||||
4. Cleanup probe (entri asli ilustrasi_beban.jpeg DIPERTAHANKAN).
|
||||
|
||||
Keamanan data user:
|
||||
- Bila file dengan nama sama SUDAH ada di server:
|
||||
• identik dengan source → delete via API lalu re-upload via UI (bukti fix,
|
||||
nama persis sama; file identik jadi tak ada data yang hilang);
|
||||
• berbeda → upload dengan nama probe (timestamp), entri asli tidak disentuh.
|
||||
|
||||
Run: /home/a2nr/lms-dev/venv/bin/python scripts/verify_asset_upload.py
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BASE = "https://sinau-c-dev.manakin-gentoo.ts.net"
|
||||
TOKEN = "141214"
|
||||
SRC = "/home/a2nr/mr-edu/assets/ilustrasi_beban.jpeg"
|
||||
ORIGINAL_NAME = "ilustrasi_beban.jpeg"
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
def login(page) -> None:
|
||||
"""Login flow via POST /api/login (paling andal, tak kena race auth.init)."""
|
||||
page.goto(BASE + "/login", wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
st = page.evaluate("""(token) => fetch('/api/login', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({token})
|
||||
}).then(x => x.status)""", TOKEN)
|
||||
assert st == 200, f"login gagal (status {st}) — cek token di DB"
|
||||
|
||||
|
||||
def wait_editor_ready(page) -> None:
|
||||
"""Tunggu tree sidebar ter-render (tab Materi/Assets) — bukan networkidle
|
||||
(sering tak tercapai: long-poll/keep-alive, container baru warm-up)."""
|
||||
page.goto(BASE + "/teacher/content", wait_until="domcontentloaded", timeout=90000)
|
||||
page.locator('button.tree-tab', has_text='Assets').first.wait_for(state="visible", timeout=45000)
|
||||
|
||||
|
||||
def fetch_tree(page):
|
||||
return page.evaluate("""() => fetch('/api/content/tree?root=assets',
|
||||
{credentials:'include'}).then(x => x.json())""")
|
||||
|
||||
|
||||
def find_asset(nodes: list, name: str):
|
||||
"""Cari node file bernama `name` di list tree (rekursif masuk children)."""
|
||||
for n in nodes:
|
||||
is_file = (n.get("type") == "file") or (n.get("is_dir") is False)
|
||||
if is_file and n.get("name") == name:
|
||||
return n
|
||||
if n.get("children"):
|
||||
r = find_asset(n["children"], name)
|
||||
if r:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def list_files(nodes: list, prefix: str = "") -> list[str]:
|
||||
"""Semua path file di tree (rekursif)."""
|
||||
out = []
|
||||
for n in nodes:
|
||||
p = f"{prefix}/{n['name']}" if prefix else n["name"]
|
||||
if (n.get("type") == "file") or (n.get("is_dir") is False):
|
||||
out.append(p)
|
||||
if n.get("children"):
|
||||
out.extend(list_files(n["children"], p))
|
||||
return out
|
||||
|
||||
|
||||
def delete_entry(page, path: str) -> None:
|
||||
page.evaluate("""(a) => fetch(`/api/content/tree/entry?root=assets&path=${encodeURIComponent(a)}&force=true`,
|
||||
{method:'DELETE', credentials:'include'})""", path)
|
||||
|
||||
|
||||
def upload_via_ui(page, local_file: str) -> None:
|
||||
"""Upload via UI: tab Assets → ⬆️ → file chooser → prompt folder ''.
|
||||
Tombol ⬆️ hanya render saat tab Assets aktif ({#if} di +page.svelte)."""
|
||||
page.locator('button.tree-tab', has_text='Assets').first.click()
|
||||
page.locator('button[title="Upload gambar"]').first.wait_for(state="visible", timeout=15000)
|
||||
# Dialog handler HARUS didaftarkan sebelum click (skill pitfall):
|
||||
# prompt "Folder tujuan" → accept("") = root assets
|
||||
# confirm "Salin path ke clipboard?" → dismiss
|
||||
page.on("dialog", lambda d: d.accept("") if "Folder tujuan" in (d.message or "") else d.dismiss())
|
||||
with page.expect_file_chooser(timeout=10000) as fc:
|
||||
page.locator('button[title="Upload gambar"]').first.click()
|
||||
fc.value.set_files(local_file)
|
||||
page.wait_for_timeout(8000) # upload base64 ~950 KB + refresh tree
|
||||
|
||||
|
||||
def test_413_path(page) -> None:
|
||||
"""Body > 8M (file 8 MiB) harus 413 + pesan ukuran, bukan 502."""
|
||||
b64 = base64.b64encode(random.Random(1).randbytes(8 * 1024 * 1024)).decode()
|
||||
res = page.evaluate("""(b64) => fetch('/api/content/assets/upload', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({filename:'huge.png', folder:'', content_base64:b64})
|
||||
}).then(async x => ({status: x.status, body: await x.json()}))""", b64)
|
||||
assert res["status"] == 413, f"413 path: dapat {res['status']}: {res['body']}"
|
||||
assert "terlalu besar" in res["body"].get("message", "").lower(), \
|
||||
f"413 path: pesan salah: {res['body']}"
|
||||
print(f"413 path: status={res['status']}, message='{res['body']['message']}' ✓")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not os.path.isfile(SRC):
|
||||
sys.exit(f"File sumber tidak ada: {SRC}")
|
||||
with open(SRC, "rb") as f:
|
||||
src_raw = f.read()
|
||||
print(f"source: {SRC} ({len(src_raw)} bytes, body ~{len(src_raw) * 4 // 3 // 1024} KB)")
|
||||
|
||||
errors: list[str] = []
|
||||
api_events: list[tuple[str, int, str]] = []
|
||||
upload_name = ORIGINAL_NAME
|
||||
local_file = SRC
|
||||
deleted_existing = False
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context(
|
||||
ignore_https_errors=True,
|
||||
service_workers="block",
|
||||
viewport={"width": 1280, "height": 800},
|
||||
)
|
||||
ctx.add_cookies([
|
||||
{"name": "student_token", "value": TOKEN,
|
||||
"domain": "sinau-c-dev.manakin-gentoo.ts.net", "path": "/",
|
||||
"http_only": True, "same_site": "Lax"}
|
||||
])
|
||||
ctx.add_init_script(
|
||||
"localStorage.setItem('student_token','141214');"
|
||||
"localStorage.setItem('student_last_active','" + str(int(time.time() * 1000)) + "');"
|
||||
)
|
||||
page = ctx.new_page()
|
||||
page.on("pageerror", lambda e: errors.append(f"pageerror: {e}"))
|
||||
page.on("response", lambda r: api_events.append(
|
||||
(r.request.method, r.status, r.url)) if "/api/" in r.url else None)
|
||||
|
||||
login(page)
|
||||
wait_editor_ready(page)
|
||||
|
||||
# ── 0) Cek state awal: entri dengan nama sama sudah ada? ──
|
||||
existing = find_asset(fetch_tree(page).get("tree", []), ORIGINAL_NAME)
|
||||
if existing:
|
||||
req = urllib.request.Request(f"{BASE}/assets/{existing['path']}")
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
existing_raw = resp.read()
|
||||
if existing_raw == src_raw:
|
||||
# Identik → delete + re-upload (bukti fix, nama persis sama)
|
||||
print(f"entri sama (identik) sudah ada di server — delete + re-upload {ORIGINAL_NAME}")
|
||||
delete_entry(page, existing["path"])
|
||||
page.wait_for_timeout(1000)
|
||||
deleted_existing = True
|
||||
else:
|
||||
# Berbeda → jangan timpa; pakai nama probe
|
||||
upload_name = f"beban_probe_{int(time.time())}.jpeg"
|
||||
local_file = f"/tmp/{upload_name}"
|
||||
shutil.copy(SRC, local_file)
|
||||
print(f"entri berbeda sudah ada di server — upload sebagai {upload_name} (asli tak disentuh)")
|
||||
|
||||
# ── 1) Upload via UI ──
|
||||
upload_via_ui(page, local_file)
|
||||
upload_evt = [(m, s, u) for m, s, u in api_events if "assets/upload" in u]
|
||||
print("upload events:", upload_evt)
|
||||
assert upload_evt and upload_evt[-1][1] == 200, \
|
||||
f"upload tidak sukses: {upload_evt or 'tidak ada POST assets/upload'}"
|
||||
|
||||
# ── 2) Tree memuat entri; download bytes-cocok ──
|
||||
node = find_asset(fetch_tree(page).get("tree", []), upload_name)
|
||||
assert node, f"entri {upload_name} tidak ada di tree assets"
|
||||
print(f"tree: entri {upload_name} ada ✓")
|
||||
with urllib.request.urlopen(f"{BASE}/assets/{node['path']}") as resp:
|
||||
data = resp.read()
|
||||
assert resp.status == 200 and data == src_raw, \
|
||||
f"download mismatch: {resp.status}, {len(data)} vs {len(src_raw)} bytes"
|
||||
print(f"download /assets/{node['path']}: 200, {len(data)} bytes cocok ✓")
|
||||
|
||||
# ── 3) Jalur 413 (body > 8M) ──
|
||||
test_413_path(page)
|
||||
|
||||
# ── 4) Cleanup: semua probe beban_probe_* (artefak kita), entri asli tetap ──
|
||||
stale = [p_ for p_ in list_files(fetch_tree(page).get("tree", [])) if p_.startswith("beban_probe")]
|
||||
for s in stale:
|
||||
delete_entry(page, s)
|
||||
page.wait_for_timeout(1000)
|
||||
final_files = list_files(fetch_tree(page).get("tree", []))
|
||||
left = [f for f in final_files if f.startswith("beban_probe")]
|
||||
assert not left, f"cleanup gagal — probe tersisa: {left}"
|
||||
print(f"cleanup {len(stale)} probe ✓; entri akhir: {len(final_files)} file")
|
||||
if upload_name == ORIGINAL_NAME:
|
||||
assert ORIGINAL_NAME in final_files, "entri asli hilang setelah cleanup!"
|
||||
print(f"entri asli {ORIGINAL_NAME} tersimpan di server ✓")
|
||||
|
||||
errs = [e for e in errors if "favicon" not in e.lower()]
|
||||
if errs:
|
||||
print("PAGE ERRORS:")
|
||||
for e in errs[:10]:
|
||||
print(" ", e)
|
||||
sys.exit(1)
|
||||
print("pageerrors: bersih ✓")
|
||||
browser.close()
|
||||
|
||||
print(f"\nSUCCESS: upload {upload_name} ({len(src_raw)} B, body ~{len(src_raw) * 4 // 3 // 1024} KB) "
|
||||
f"lolos proxy tanpa 502; 413 path jelas; data user utuh.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,3 +671,158 @@ 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.
|
||||
# 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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue