SPB Git

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%
10.9 KB · 272 lines javascript
Raw Blame History
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