/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : test/e2e.test.mjs * Purpose : E2E — boot server, login, chunk-upload, thumbnail, * password-protected share, download through it * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawn } from 'node:child_process'; import { mkdtempSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const PORT = 7442; const B = `http://127.0.0.1:${PORT}`; const PASSWORD = 'e2e-password-1'; let server; let cookie; const H = () => ({ cookie, 'x-spbdrive-csrf': '1' }); const HJ = () => ({ ...H(), 'content-type': 'application/json' }); async function waitForServer() { for (let i = 0; i < 60; i += 1) { try { const res = await fetch(`${B}/healthz`); if (res.ok) return; } catch { /* not up yet */ } await new Promise((r) => setTimeout(r, 400)); } throw new Error('server did not boot'); } beforeAll(async () => { server = spawn(process.execPath, ['src/server.mjs'], { env: { ...process.env, SPBDRIVE_DATA_DIR: mkdtempSync(path.join(os.tmpdir(), 'spbdrive-e2e-')), SPBDRIVE_PORT: String(PORT), SPBDRIVE_PUBLIC_URL: B, SPBDRIVE_LOG_LEVEL: 'silent', SPBDRIVE_BOOTSTRAP_PASSWORD: PASSWORD, }, stdio: 'ignore', }); await waitForServer(); }, 40_000); afterAll(() => { server?.kill('SIGTERM'); }); describe('e2e: upload → thumbnail → protected share → download', () => { let nodeId; let shareToken; let payload; it('logs in and receives a session cookie', async () => { const res = await fetch(`${B}/api/v1/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: PASSWORD }), }); expect(res.status).toBe(200); cookie = res.headers.get('set-cookie').split(';')[0]; expect(cookie).toMatch(/^spbdrive_sid=/); }); it('rejects unauthenticated API access', async () => { const res = await fetch(`${B}/api/v1/me`); expect(res.status).toBe(401); }); it('chunk-uploads a multi-chunk PNG', async () => { const sharp = (await import('sharp')).default; payload = await sharp({ create: { width: 1600, height: 1200, channels: 3, noise: { type: 'gaussian', mean: 128, sigma: 40 } }, }).png({ compressionLevel: 0 }).toBuffer(); expect(payload.length).toBeGreaterThan(1024 * 1024); const initRes = await fetch(`${B}/api/v1/upload/init`, { method: 'POST', headers: HJ(), body: JSON.stringify({ parentId: 1, name: 'e2e.png', size: payload.length }), }); const init = await initRes.json(); // Force multi-chunk exercise by splitting manually at 1 MB inside the 8 MB chunk API? No — // upload API chunk size is fixed; send each declared chunk. for (let n = 0; n < init.nChunks; n += 1) { const slice = payload.subarray(n * init.chunkSize, Math.min((n + 1) * init.chunkSize, payload.length)); const res = await fetch(`${B}/api/v1/upload/${init.uploadId}/chunk/${n}`, { method: 'PUT', headers: { ...H(), 'content-type': 'application/octet-stream' }, body: slice, }); expect(res.status).toBe(200); } const doneRes = await fetch(`${B}/api/v1/upload/${init.uploadId}/complete`, { method: 'POST', headers: HJ(), body: JSON.stringify({}), }); const done = await doneRes.json(); expect(doneRes.status).toBe(200); expect(done.node.size).toBe(payload.length); nodeId = done.node.id; }, 30_000); it('serves a webp thumbnail', async () => { const res = await fetch(`${B}/thumb/${nodeId}?size=256`, { headers: { cookie } }); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('image/webp'); }, 20_000); it('honors Range requests on /stream', async () => { const res = await fetch(`${B}/stream/${nodeId}`, { headers: { cookie, range: 'bytes=10-19' } }); expect(res.status).toBe(206); expect(res.headers.get('content-range')).toBe(`bytes 10-19/${payload.length}`); const buf = Buffer.from(await res.arrayBuffer()); expect(buf.equals(payload.subarray(10, 20))).toBe(true); }); it('creates a password-protected share', async () => { const res = await fetch(`${B}/api/v1/shares`, { method: 'POST', headers: HJ(), body: JSON.stringify({ nodeId, password: 'sharepw', expiresAt: Date.now() + 3_600_000 }), }); const { share } = await res.json(); expect(share.token).toHaveLength(10); shareToken = share.token; }); it('gates the public page, unlocks with the password, downloads the bytes', async () => { // Gate let res = await fetch(`${B}/s/${shareToken}`); const gate = await res.text(); expect(gate).toContain('Protected link'); const csrf = res.headers.get('set-cookie').match(/spbdrive_csrf=([a-f0-9]+)/)[1]; // Wrong password stays gated res = await fetch(`${B}/s/${shareToken}/unlock`, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `spbdrive_csrf=${csrf}` }, body: `password=wrong&_csrf=${csrf}`, }); expect(res.status).toBe(401); // Right password → signed gate cookie res = await fetch(`${B}/s/${shareToken}/unlock`, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `spbdrive_csrf=${csrf}` }, body: `password=sharepw&_csrf=${csrf}`, }); expect(res.status).toBe(302); const gateCookie = res.headers.get('set-cookie').split(';')[0]; // Download through the share res = await fetch(`${B}/s/${shareToken}/dl`, { headers: { cookie: gateCookie } }); expect(res.status).toBe(200); const buf = Buffer.from(await res.arrayBuffer()); expect(buf.equals(payload)).toBe(true); // Revoke kills it instantly, and the error page leaks no filename const { shares } = await (await fetch(`${B}/api/v1/shares`, { headers: { cookie } })).json(); const mine = shares.find((s) => s.token === shareToken); await fetch(`${B}/api/v1/shares/${mine.id}`, { method: 'DELETE', headers: H() }); res = await fetch(`${B}/s/${shareToken}`, { headers: { cookie: gateCookie } }); expect(res.status).toBe(410); const dead = await res.text(); expect(dead).not.toContain('e2e.png'); }); it('creates a text file, edits it, and restores the old version', async () => { // Create let res = await fetch(`${B}/api/v1/files`, { method: 'POST', headers: HJ(), body: JSON.stringify({ parentId: 1, name: 'note.md', content: '# first draft' }), }); const { node } = await res.json(); expect(res.status).toBe(200); expect(node.strategy).toBe('markdown'); // Edit via the editor endpoint res = await fetch(`${B}/api/v1/nodes/${node.id}/content`, { method: 'PUT', headers: { ...H(), 'content-type': 'text/plain' }, body: '# second draft — improved', }); expect(res.status).toBe(200); // Old content is a version res = await fetch(`${B}/api/v1/nodes/${node.id}/versions`, { headers: { cookie } }); const { versions } = await res.json(); expect(versions).toHaveLength(1); expect(versions[0].origin).toBe('edit'); // Download the old version bytes res = await fetch(`${B}/api/v1/nodes/${node.id}/versions/${versions[0].id}/dl`, { headers: { cookie } }); expect(await res.text()).toBe('# first draft'); // Restore it res = await fetch(`${B}/api/v1/nodes/${node.id}/versions/${versions[0].id}/restore`, { method: 'POST', headers: HJ(), body: '{}', }); expect(res.status).toBe(200); res = await fetch(`${B}/api/v1/preview/${node.id}/raw`, { headers: { cookie } }); expect(await res.text()).toBe('# first draft'); }); it('accepts a stranger upload through a file request, then dies on close', async () => { // Owner creates a folder + request link let res = await fetch(`${B}/api/v1/nodes`, { method: 'POST', headers: HJ(), body: JSON.stringify({ parentId: 1, name: 'Inbox', type: 'folder' }), }); const folder = (await res.json()).node; res = await fetch(`${B}/api/v1/requests`, { method: 'POST', headers: HJ(), body: JSON.stringify({ folderId: folder.id, label: 'send me stuff', maxFiles: 3 }), }); const { request } = await res.json(); expect(request.token).toHaveLength(12); // A stranger (no cookies at all) uploads through /r/ const payloadBytes = Buffer.from('hello from a stranger'); res = await fetch(`${B}/r/${request.token}/upload/init`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'from-stranger.txt', size: payloadBytes.length }), }); const init = await res.json(); expect(res.status).toBe(200); res = await fetch(`${B}/r/${request.token}/upload/${init.uploadId}/chunk/0`, { method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, body: payloadBytes, }); expect(res.status).toBe(200); res = await fetch(`${B}/r/${request.token}/upload/${init.uploadId}/complete`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', }); expect(res.status).toBe(200); // The file landed in the owner's folder res = await fetch(`${B}/api/v1/nodes/${folder.id}/children`, { headers: { cookie } }); const { children } = await res.json(); expect(children.some((c) => c.name === 'from-stranger.txt')).toBe(true); // Public upload sessions can't be reused via the private API path check: // closing the request kills the public page instantly. const { requests } = await (await fetch(`${B}/api/v1/requests`, { headers: { cookie } })).json(); const mine = requests.find((r) => r.token === request.token); await fetch(`${B}/api/v1/requests/${mine.id}`, { method: 'DELETE', headers: H() }); res = await fetch(`${B}/r/${request.token}`); expect(res.status).toBe(410); expect(await res.text()).not.toContain('Inbox'); // leaks no folder name }); it('locks out after 5 wrong passwords', async () => { for (let i = 0; i < 5; i += 1) { await fetch(`${B}/api/v1/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: 'bad-guess' }), }); } const res = await fetch(`${B}/api/v1/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: PASSWORD }), }); expect(res.status).toBe(429); }); });