diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts
index 0892214..525e624 100644
--- a/frontend/src/hooks.server.test.ts
+++ b/frontend/src/hooks.server.test.ts
@@ -90,6 +90,114 @@ describe('hooks.server handle', () => {
}
});
+ 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 ', {
+ 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 = {
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
index 31ebdf4..4c9eeca 100644
--- a/frontend/src/hooks.server.ts
+++ b/frontend/src/hooks.server.ts
@@ -73,6 +73,16 @@ 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 '
+ )
+ 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
+