spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : test/e2e.test.mjs8 * Purpose : E2E — boot server, login, chunk-upload, thumbnail,9 * password-protected share, download through it10 * License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import { describe, it, expect, beforeAll, afterAll } from 'vitest';15import { spawn } from 'node:child_process';16import { mkdtempSync } from 'node:fs';17import os from 'node:os';18import path from 'node:path';1920const PORT = 7442;21const B = `http://127.0.0.1:${PORT}`;22const PASSWORD = 'e2e-password-1';23let server;24let cookie;2526const H = () => ({ cookie, 'x-spbdrive-csrf': '1' });27const HJ = () => ({ ...H(), 'content-type': 'application/json' });2829async function waitForServer() {30 for (let i = 0; i < 60; i += 1) {31 try {32 const res = await fetch(`${B}/healthz`);33 if (res.ok) return;34 } catch { /* not up yet */ }35 await new Promise((r) => setTimeout(r, 400));36 }37 throw new Error('server did not boot');38}3940beforeAll(async () => {41 server = spawn(process.execPath, ['src/server.mjs'], {42 env: {43 ...process.env,44 SPBDRIVE_DATA_DIR: mkdtempSync(path.join(os.tmpdir(), 'spbdrive-e2e-')),45 SPBDRIVE_PORT: String(PORT),46 SPBDRIVE_PUBLIC_URL: B,47 SPBDRIVE_LOG_LEVEL: 'silent',48 SPBDRIVE_BOOTSTRAP_PASSWORD: PASSWORD,49 },50 stdio: 'ignore',51 });52 await waitForServer();53}, 40_000);5455afterAll(() => {56 server?.kill('SIGTERM');57});5859describe('e2e: upload → thumbnail → protected share → download', () => {60 let nodeId;61 let shareToken;62 let payload;6364 it('logs in and receives a session cookie', async () => {65 const res = await fetch(`${B}/api/v1/auth/login`, {66 method: 'POST',67 headers: { 'content-type': 'application/json' },68 body: JSON.stringify({ password: PASSWORD }),69 });70 expect(res.status).toBe(200);71 cookie = res.headers.get('set-cookie').split(';')[0];72 expect(cookie).toMatch(/^spbdrive_sid=/);73 });7475 it('rejects unauthenticated API access', async () => {76 const res = await fetch(`${B}/api/v1/me`);77 expect(res.status).toBe(401);78 });7980 it('chunk-uploads a multi-chunk PNG', async () => {81 const sharp = (await import('sharp')).default;82 payload = await sharp({83 create: { width: 1600, height: 1200, channels: 3, noise: { type: 'gaussian', mean: 128, sigma: 40 } },84 }).png({ compressionLevel: 0 }).toBuffer();85 expect(payload.length).toBeGreaterThan(1024 * 1024);8687 const initRes = await fetch(`${B}/api/v1/upload/init`, {88 method: 'POST', headers: HJ(),89 body: JSON.stringify({ parentId: 1, name: 'e2e.png', size: payload.length }),90 });91 const init = await initRes.json();92 // Force multi-chunk exercise by splitting manually at 1 MB inside the 8 MB chunk API? No —93 // upload API chunk size is fixed; send each declared chunk.94 for (let n = 0; n < init.nChunks; n += 1) {95 const slice = payload.subarray(n * init.chunkSize, Math.min((n + 1) * init.chunkSize, payload.length));96 const res = await fetch(`${B}/api/v1/upload/${init.uploadId}/chunk/${n}`, {97 method: 'PUT',98 headers: { ...H(), 'content-type': 'application/octet-stream' },99 body: slice,100 });101 expect(res.status).toBe(200);102 }103 const doneRes = await fetch(`${B}/api/v1/upload/${init.uploadId}/complete`, {104 method: 'POST', headers: HJ(), body: JSON.stringify({}),105 });106 const done = await doneRes.json();107 expect(doneRes.status).toBe(200);108 expect(done.node.size).toBe(payload.length);109 nodeId = done.node.id;110 }, 30_000);111112 it('serves a webp thumbnail', async () => {113 const res = await fetch(`${B}/thumb/${nodeId}?size=256`, { headers: { cookie } });114 expect(res.status).toBe(200);115 expect(res.headers.get('content-type')).toBe('image/webp');116 }, 20_000);117118 it('honors Range requests on /stream', async () => {119 const res = await fetch(`${B}/stream/${nodeId}`, { headers: { cookie, range: 'bytes=10-19' } });120 expect(res.status).toBe(206);121 expect(res.headers.get('content-range')).toBe(`bytes 10-19/${payload.length}`);122 const buf = Buffer.from(await res.arrayBuffer());123 expect(buf.equals(payload.subarray(10, 20))).toBe(true);124 });125126 it('creates a password-protected share', async () => {127 const res = await fetch(`${B}/api/v1/shares`, {128 method: 'POST', headers: HJ(),129 body: JSON.stringify({ nodeId, password: 'sharepw', expiresAt: Date.now() + 3_600_000 }),130 });131 const { share } = await res.json();132 expect(share.token).toHaveLength(10);133 shareToken = share.token;134 });135136 it('gates the public page, unlocks with the password, downloads the bytes', async () => {137 // Gate138 let res = await fetch(`${B}/s/${shareToken}`);139 const gate = await res.text();140 expect(gate).toContain('Protected link');141 const csrf = res.headers.get('set-cookie').match(/spbdrive_csrf=([a-f0-9]+)/)[1];142143 // Wrong password stays gated144 res = await fetch(`${B}/s/${shareToken}/unlock`, {145 method: 'POST', redirect: 'manual',146 headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `spbdrive_csrf=${csrf}` },147 body: `password=wrong&_csrf=${csrf}`,148 });149 expect(res.status).toBe(401);150151 // Right password → signed gate cookie152 res = await fetch(`${B}/s/${shareToken}/unlock`, {153 method: 'POST', redirect: 'manual',154 headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `spbdrive_csrf=${csrf}` },155 body: `password=sharepw&_csrf=${csrf}`,156 });157 expect(res.status).toBe(302);158 const gateCookie = res.headers.get('set-cookie').split(';')[0];159160 // Download through the share161 res = await fetch(`${B}/s/${shareToken}/dl`, { headers: { cookie: gateCookie } });162 expect(res.status).toBe(200);163 const buf = Buffer.from(await res.arrayBuffer());164 expect(buf.equals(payload)).toBe(true);165166 // Revoke kills it instantly, and the error page leaks no filename167 const { shares } = await (await fetch(`${B}/api/v1/shares`, { headers: { cookie } })).json();168 const mine = shares.find((s) => s.token === shareToken);169 await fetch(`${B}/api/v1/shares/${mine.id}`, { method: 'DELETE', headers: H() });170 res = await fetch(`${B}/s/${shareToken}`, { headers: { cookie: gateCookie } });171 expect(res.status).toBe(410);172 const dead = await res.text();173 expect(dead).not.toContain('e2e.png');174 });175176 it('creates a text file, edits it, and restores the old version', async () => {177 // Create178 let res = await fetch(`${B}/api/v1/files`, {179 method: 'POST', headers: HJ(),180 body: JSON.stringify({ parentId: 1, name: 'note.md', content: '# first draft' }),181 });182 const { node } = await res.json();183 expect(res.status).toBe(200);184 expect(node.strategy).toBe('markdown');185186 // Edit via the editor endpoint187 res = await fetch(`${B}/api/v1/nodes/${node.id}/content`, {188 method: 'PUT', headers: { ...H(), 'content-type': 'text/plain' },189 body: '# second draft — improved',190 });191 expect(res.status).toBe(200);192193 // Old content is a version194 res = await fetch(`${B}/api/v1/nodes/${node.id}/versions`, { headers: { cookie } });195 const { versions } = await res.json();196 expect(versions).toHaveLength(1);197 expect(versions[0].origin).toBe('edit');198199 // Download the old version bytes200 res = await fetch(`${B}/api/v1/nodes/${node.id}/versions/${versions[0].id}/dl`, { headers: { cookie } });201 expect(await res.text()).toBe('# first draft');202203 // Restore it204 res = await fetch(`${B}/api/v1/nodes/${node.id}/versions/${versions[0].id}/restore`, {205 method: 'POST', headers: HJ(), body: '{}',206 });207 expect(res.status).toBe(200);208 res = await fetch(`${B}/api/v1/preview/${node.id}/raw`, { headers: { cookie } });209 expect(await res.text()).toBe('# first draft');210 });211212 it('accepts a stranger upload through a file request, then dies on close', async () => {213 // Owner creates a folder + request link214 let res = await fetch(`${B}/api/v1/nodes`, {215 method: 'POST', headers: HJ(),216 body: JSON.stringify({ parentId: 1, name: 'Inbox', type: 'folder' }),217 });218 const folder = (await res.json()).node;219 res = await fetch(`${B}/api/v1/requests`, {220 method: 'POST', headers: HJ(),221 body: JSON.stringify({ folderId: folder.id, label: 'send me stuff', maxFiles: 3 }),222 });223 const { request } = await res.json();224 expect(request.token).toHaveLength(12);225226 // A stranger (no cookies at all) uploads through /r/<token>227 const payloadBytes = Buffer.from('hello from a stranger');228 res = await fetch(`${B}/r/${request.token}/upload/init`, {229 method: 'POST', headers: { 'content-type': 'application/json' },230 body: JSON.stringify({ name: 'from-stranger.txt', size: payloadBytes.length }),231 });232 const init = await res.json();233 expect(res.status).toBe(200);234 res = await fetch(`${B}/r/${request.token}/upload/${init.uploadId}/chunk/0`, {235 method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, body: payloadBytes,236 });237 expect(res.status).toBe(200);238 res = await fetch(`${B}/r/${request.token}/upload/${init.uploadId}/complete`, {239 method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',240 });241 expect(res.status).toBe(200);242243 // The file landed in the owner's folder244 res = await fetch(`${B}/api/v1/nodes/${folder.id}/children`, { headers: { cookie } });245 const { children } = await res.json();246 expect(children.some((c) => c.name === 'from-stranger.txt')).toBe(true);247248 // Public upload sessions can't be reused via the private API path check:249 // closing the request kills the public page instantly.250 const { requests } = await (await fetch(`${B}/api/v1/requests`, { headers: { cookie } })).json();251 const mine = requests.find((r) => r.token === request.token);252 await fetch(`${B}/api/v1/requests/${mine.id}`, { method: 'DELETE', headers: H() });253 res = await fetch(`${B}/r/${request.token}`);254 expect(res.status).toBe(410);255 expect(await res.text()).not.toContain('Inbox'); // leaks no folder name256 });257258 it('locks out after 5 wrong passwords', async () => {259 for (let i = 0; i < 5; i += 1) {260 await fetch(`${B}/api/v1/auth/login`, {261 method: 'POST', headers: { 'content-type': 'application/json' },262 body: JSON.stringify({ password: 'bad-guess' }),263 });264 }265 const res = await fetch(`${B}/api/v1/auth/login`, {266 method: 'POST', headers: { 'content-type': 'application/json' },267 body: JSON.stringify({ password: PASSWORD }),268 });269 expect(res.status).toBe(429);270 });271});272