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/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