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.4 KB · 247 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : test/unit.test.mjs8 *  Purpose : Unit tests — blob store, tree ops, share logic, range parser9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import './setup.mjs';14import { describe, it, expect, beforeAll } from 'vitest';15import { Readable } from 'node:stream';16import { readFileSync } from 'node:fs';1718import { ensureDataDirs } from '../src/config.mjs';19import { getDb } from '../src/db/db.mjs';20import { putBlob, blobPath, refBlob, unrefBlob, gcBlobs } from '../src/storage/blobs.mjs';21import {22  mkdir, createFileNode, renameNode, moveNode, copyNode, trashNode, restoreNode,23  deleteForever, uniqueName, validateName, isDescendant, listDescendantFiles, ROOT_ID,24} from '../src/storage/nodes.mjs';25import {26  listVersions, restoreVersion, deleteVersion,27} from '../src/storage/versions.mjs';28import {29  createShare, getShareByToken, shareValidity, verifySharePassword, revokeShare,30} from '../src/shares/shares.mjs';31import {32  closeRequest, createRequest, getRequestByToken, recordReceived, requestValidity,33} from '../src/shares/requests.mjs';34import { parseRange } from '../src/web/http-helpers.mjs';35import { searchNodes } from '../src/search/search.mjs';3637beforeAll(() => {38  ensureDataDirs();39  getDb();40});4142const put = (text) => putBlob(Readable.from([Buffer.from(text)]));4344describe('blob store (CAS)', () => {45  it('stores by sha256 and dedups identical content', async () => {46    const a = await put('same-bytes');47    const b = await put('same-bytes');48    expect(a.sha).toBe(b.sha);49    expect(b.deduped).toBe(true);50    expect(readFileSync(blobPath(a.sha), 'utf8')).toBe('same-bytes');51  });5253  it('GC removes blobs only at refcount zero', async () => {54    const { sha } = await put('gc-me');55    refBlob(sha);56    await gcBlobs();57    expect(() => readFileSync(blobPath(sha))).not.toThrow();58    unrefBlob(sha);59    await gcBlobs();60    expect(() => readFileSync(blobPath(sha))).toThrow();61  });62});6364describe('tree ops', () => {65  it('rejects path-traversal and invalid names', () => {66    for (const bad of ['..', '.', 'a/b', 'a\\b', 'x\0y', '']) {67      expect(() => validateName(bad), bad).toThrow();68    }69    expect(validateName('notes 2026.md')).toBe('notes 2026.md');70  });7172  it('mkdir / rename / move with cycle protection', async () => {73    const a = mkdir(ROOT_ID, 'A');74    const b = mkdir(a.id, 'B');75    expect(isDescendant(b.id, a.id)).toBe(true);76    expect(() => moveNode(a.id, b.id)).toThrow(/into itself/);77    const renamed = renameNode(b.id, 'B2');78    expect(renamed.name).toBe('B2');79  });8081  it('keep-both conflicts generate name-2 style suffixes', async () => {82    const dir = mkdir(ROOT_ID, 'conflicts');83    const { sha, size } = await put('v1');84    createFileNode(dir.id, 'file.txt', { sha, size, mime: 'text/plain' });85    expect(uniqueName(dir.id, 'file.txt')).toBe('file-2.txt');86    const second = createFileNode(dir.id, 'file.txt', { sha, size, mime: 'text/plain' });87    expect(second.name).toBe('file-2.txt');88  });8990  it('copy shares blobs via refcount; deep-copies folders', async () => {91    const src = mkdir(ROOT_ID, 'copy-src');92    const inner = mkdir(src.id, 'inner');93    const { sha, size } = await put('copy-payload');94    createFileNode(inner.id, 'deep.txt', { sha, size, mime: 'text/plain' });95    const dst = mkdir(ROOT_ID, 'copy-dst');96    const copied = copyNode(src.id, dst.id);97    const files = listDescendantFiles(copied.id);98    expect(files).toHaveLength(1);99    expect(files[0].relPath).toBe('inner/deep.txt');100    const refs = getDb().prepare('SELECT refcount FROM blobs WHERE sha = ?').get(sha);101    expect(refs.refcount).toBe(2);102  });103104  it('trash remembers origin; restore returns there; delete forever unrefs', async () => {105    const home = mkdir(ROOT_ID, 'trash-home');106    const { sha, size } = await put('trashable');107    const file = createFileNode(home.id, 'gone.txt', { sha, size, mime: 'text/plain' });108    trashNode(file.id);109    const back = restoreNode(file.id);110    expect(back.parent_id).toBe(home.id);111    deleteForever(file.id);112    const refs = getDb().prepare('SELECT refcount FROM blobs WHERE sha = ?').get(sha);113    expect(refs.refcount).toBe(0);114  });115});116117describe('file versioning', () => {118  it('replace keeps the node id and archives the old content as a version', async () => {119    const dir = mkdir(ROOT_ID, 'versions');120    const v1 = await put('version-one');121    const file = createFileNode(dir.id, 'doc.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' });122123    const v2 = await put('version-two-longer');124    const replaced = createFileNode(dir.id, 'doc.txt', {125      sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace',126    });127    expect(replaced.id).toBe(file.id); // same node — shares/tags survive128    expect(replaced.blob_sha).toBe(v2.sha);129130    const versions = listVersions(file.id);131    expect(versions).toHaveLength(1);132    expect(versions[0].blob_sha).toBe(v1.sha);133    expect(versions[0].origin).toBe('replace');134    // The version row holds a ref — old bytes survive GC.135    await gcBlobs();136    expect(readFileSync(blobPath(v1.sha), 'utf8')).toBe('version-one');137  });138139  it('restoring a version archives the current content and keeps history', async () => {140    const dir = mkdir(ROOT_ID, 'versions-restore');141    const v1 = await put('restore-old');142    const file = createFileNode(dir.id, 'r.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' });143    const v2 = await put('restore-new');144    createFileNode(dir.id, 'r.txt', { sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace' });145146    const oldVersion = listVersions(file.id)[0];147    const restored = restoreVersion(file.id, oldVersion.id);148    expect(restored.blob_sha).toBe(v1.sha);149    const origins = listVersions(file.id).map((v) => v.origin).sort();150    expect(origins).toContain('restore'); // the superseded v2 content151  });152153  it('deleting a version and deleting the node release blob refs', async () => {154    const dir = mkdir(ROOT_ID, 'versions-gc');155    const v1 = await put('gc-version-1');156    const file = createFileNode(dir.id, 'g.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' });157    const v2 = await put('gc-version-2');158    createFileNode(dir.id, 'g.txt', { sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace' });159160    const version = listVersions(file.id)[0];161    expect(deleteVersion(file.id, version.id)).toBe(true);162    deleteForever(file.id);163    const refs = getDb().prepare('SELECT sha, refcount FROM blobs WHERE sha IN (?, ?)').all(v1.sha, v2.sha);164    expect(refs).toHaveLength(2);165    for (const row of refs) expect(row.refcount).toBe(0);166  });167});168169describe('file requests', () => {170  it('creates 12-char tokens and enforces the lifecycle', () => {171    const dir = mkdir(ROOT_ID, 'inbox');172    const request = createRequest(dir.id, { label: 'CV', maxFiles: 2 });173    expect(request.token).toMatch(/^[1-9A-HJ-NP-Za-km-z]{12}$/);174    expect(requestValidity(request).ok).toBe(true);175    expect(requestValidity(getRequestByToken(request.token)).ok).toBe(true);176177    recordReceived(request.id, { name: 'a.pdf' });178    recordReceived(request.id, { name: 'b.pdf' });179    expect(requestValidity(getRequestByToken(request.token))).toEqual({ ok: false, reason: 'full' });180181    const expired = createRequest(dir.id, { expiresAt: Date.now() - 1000 });182    expect(requestValidity(expired)).toEqual({ ok: false, reason: 'expired' });183184    const open = createRequest(dir.id, {});185    closeRequest(open.id);186    expect(requestValidity(getRequestByToken(open.token))).toEqual({ ok: false, reason: 'closed' });187  });188189  it('refuses non-folder targets and dies with a trashed folder', () => {190    const dir = mkdir(ROOT_ID, 'inbox-dead');191    const request = createRequest(dir.id, {});192    trashNode(dir.id);193    expect(requestValidity(getRequestByToken(request.token))).toEqual({ ok: false, reason: 'gone' });194    restoreNode(dir.id);195    expect(requestValidity(getRequestByToken(request.token)).ok).toBe(true);196  });197});198199describe('shares', () => {200  it('creates 10-char base58 tokens and validates lifecycle', async () => {201    const folder = mkdir(ROOT_ID, 'share-me');202    const share = await createShare(folder.id, {});203    expect(share.token).toMatch(/^[1-9A-HJ-NP-Za-km-z]{10}$/);204    expect(shareValidity(share).ok).toBe(true);205206    const expired = await createShare(folder.id, { expiresAt: Date.now() - 1000 });207    expect(shareValidity(expired)).toEqual({ ok: false, reason: 'expired' });208209    revokeShare(share.id);210    expect(shareValidity(getShareByToken(share.token))).toEqual({ ok: false, reason: 'revoked' });211  });212213  it('argon2 share passwords verify correctly', async () => {214    const folder = mkdir(ROOT_ID, 'pw-share');215    const share = await createShare(folder.id, { password: 'secret42' });216    expect(share.password_hash).toMatch(/^\$argon2id\$/);217    expect(await verifySharePassword(share, 'secret42')).toBe(true);218    expect(await verifySharePassword(share, 'nope')).toBe(false);219  });220221  it('max downloads exhausts the link', async () => {222    const folder = mkdir(ROOT_ID, 'maxdl-share');223    const share = await createShare(folder.id, { maxDownloads: 0 });224    expect(shareValidity(share)).toEqual({ ok: false, reason: 'exhausted' });225  });226});227228describe('range parser', () => {229  it('handles normal, open-ended, suffix, and invalid ranges', () => {230    expect(parseRange('bytes=0-99', 1000)).toEqual({ start: 0, end: 99 });231    expect(parseRange('bytes=500-', 1000)).toEqual({ start: 500, end: 999 });232    expect(parseRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 });233    expect(parseRange('bytes=999-5000', 1000)).toEqual({ start: 999, end: 999 });234    expect(parseRange('bytes=1000-', 1000)).toBeNull();235    expect(parseRange('bytes=abc', 1000)).toBeNull();236    expect(parseRange(undefined, 1000)).toBeNull();237  });238});239240describe('search', () => {241  it('finds nodes by name via FTS', async () => {242    mkdir(ROOT_ID, 'quarterly-report-folder');243    const rows = searchNodes('quarterly');244    expect(rows.some((r) => r.name === 'quarterly-report-folder')).toBe(true);245  });246});247