/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : test/unit.test.mjs * Purpose : Unit tests — blob store, tree ops, share logic, range parser * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import './setup.mjs'; import { describe, it, expect, beforeAll } from 'vitest'; import { Readable } from 'node:stream'; import { readFileSync } from 'node:fs'; import { ensureDataDirs } from '../src/config.mjs'; import { getDb } from '../src/db/db.mjs'; import { putBlob, blobPath, refBlob, unrefBlob, gcBlobs } from '../src/storage/blobs.mjs'; import { mkdir, createFileNode, renameNode, moveNode, copyNode, trashNode, restoreNode, deleteForever, uniqueName, validateName, isDescendant, listDescendantFiles, ROOT_ID, } from '../src/storage/nodes.mjs'; import { listVersions, restoreVersion, deleteVersion, } from '../src/storage/versions.mjs'; import { createShare, getShareByToken, shareValidity, verifySharePassword, revokeShare, } from '../src/shares/shares.mjs'; import { closeRequest, createRequest, getRequestByToken, recordReceived, requestValidity, } from '../src/shares/requests.mjs'; import { parseRange } from '../src/web/http-helpers.mjs'; import { searchNodes } from '../src/search/search.mjs'; beforeAll(() => { ensureDataDirs(); getDb(); }); const put = (text) => putBlob(Readable.from([Buffer.from(text)])); describe('blob store (CAS)', () => { it('stores by sha256 and dedups identical content', async () => { const a = await put('same-bytes'); const b = await put('same-bytes'); expect(a.sha).toBe(b.sha); expect(b.deduped).toBe(true); expect(readFileSync(blobPath(a.sha), 'utf8')).toBe('same-bytes'); }); it('GC removes blobs only at refcount zero', async () => { const { sha } = await put('gc-me'); refBlob(sha); await gcBlobs(); expect(() => readFileSync(blobPath(sha))).not.toThrow(); unrefBlob(sha); await gcBlobs(); expect(() => readFileSync(blobPath(sha))).toThrow(); }); }); describe('tree ops', () => { it('rejects path-traversal and invalid names', () => { for (const bad of ['..', '.', 'a/b', 'a\\b', 'x\0y', '']) { expect(() => validateName(bad), bad).toThrow(); } expect(validateName('notes 2026.md')).toBe('notes 2026.md'); }); it('mkdir / rename / move with cycle protection', async () => { const a = mkdir(ROOT_ID, 'A'); const b = mkdir(a.id, 'B'); expect(isDescendant(b.id, a.id)).toBe(true); expect(() => moveNode(a.id, b.id)).toThrow(/into itself/); const renamed = renameNode(b.id, 'B2'); expect(renamed.name).toBe('B2'); }); it('keep-both conflicts generate name-2 style suffixes', async () => { const dir = mkdir(ROOT_ID, 'conflicts'); const { sha, size } = await put('v1'); createFileNode(dir.id, 'file.txt', { sha, size, mime: 'text/plain' }); expect(uniqueName(dir.id, 'file.txt')).toBe('file-2.txt'); const second = createFileNode(dir.id, 'file.txt', { sha, size, mime: 'text/plain' }); expect(second.name).toBe('file-2.txt'); }); it('copy shares blobs via refcount; deep-copies folders', async () => { const src = mkdir(ROOT_ID, 'copy-src'); const inner = mkdir(src.id, 'inner'); const { sha, size } = await put('copy-payload'); createFileNode(inner.id, 'deep.txt', { sha, size, mime: 'text/plain' }); const dst = mkdir(ROOT_ID, 'copy-dst'); const copied = copyNode(src.id, dst.id); const files = listDescendantFiles(copied.id); expect(files).toHaveLength(1); expect(files[0].relPath).toBe('inner/deep.txt'); const refs = getDb().prepare('SELECT refcount FROM blobs WHERE sha = ?').get(sha); expect(refs.refcount).toBe(2); }); it('trash remembers origin; restore returns there; delete forever unrefs', async () => { const home = mkdir(ROOT_ID, 'trash-home'); const { sha, size } = await put('trashable'); const file = createFileNode(home.id, 'gone.txt', { sha, size, mime: 'text/plain' }); trashNode(file.id); const back = restoreNode(file.id); expect(back.parent_id).toBe(home.id); deleteForever(file.id); const refs = getDb().prepare('SELECT refcount FROM blobs WHERE sha = ?').get(sha); expect(refs.refcount).toBe(0); }); }); describe('file versioning', () => { it('replace keeps the node id and archives the old content as a version', async () => { const dir = mkdir(ROOT_ID, 'versions'); const v1 = await put('version-one'); const file = createFileNode(dir.id, 'doc.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' }); const v2 = await put('version-two-longer'); const replaced = createFileNode(dir.id, 'doc.txt', { sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace', }); expect(replaced.id).toBe(file.id); // same node — shares/tags survive expect(replaced.blob_sha).toBe(v2.sha); const versions = listVersions(file.id); expect(versions).toHaveLength(1); expect(versions[0].blob_sha).toBe(v1.sha); expect(versions[0].origin).toBe('replace'); // The version row holds a ref — old bytes survive GC. await gcBlobs(); expect(readFileSync(blobPath(v1.sha), 'utf8')).toBe('version-one'); }); it('restoring a version archives the current content and keeps history', async () => { const dir = mkdir(ROOT_ID, 'versions-restore'); const v1 = await put('restore-old'); const file = createFileNode(dir.id, 'r.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' }); const v2 = await put('restore-new'); createFileNode(dir.id, 'r.txt', { sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace' }); const oldVersion = listVersions(file.id)[0]; const restored = restoreVersion(file.id, oldVersion.id); expect(restored.blob_sha).toBe(v1.sha); const origins = listVersions(file.id).map((v) => v.origin).sort(); expect(origins).toContain('restore'); // the superseded v2 content }); it('deleting a version and deleting the node release blob refs', async () => { const dir = mkdir(ROOT_ID, 'versions-gc'); const v1 = await put('gc-version-1'); const file = createFileNode(dir.id, 'g.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' }); const v2 = await put('gc-version-2'); createFileNode(dir.id, 'g.txt', { sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace' }); const version = listVersions(file.id)[0]; expect(deleteVersion(file.id, version.id)).toBe(true); deleteForever(file.id); const refs = getDb().prepare('SELECT sha, refcount FROM blobs WHERE sha IN (?, ?)').all(v1.sha, v2.sha); expect(refs).toHaveLength(2); for (const row of refs) expect(row.refcount).toBe(0); }); }); describe('file requests', () => { it('creates 12-char tokens and enforces the lifecycle', () => { const dir = mkdir(ROOT_ID, 'inbox'); const request = createRequest(dir.id, { label: 'CV', maxFiles: 2 }); expect(request.token).toMatch(/^[1-9A-HJ-NP-Za-km-z]{12}$/); expect(requestValidity(request).ok).toBe(true); expect(requestValidity(getRequestByToken(request.token)).ok).toBe(true); recordReceived(request.id, { name: 'a.pdf' }); recordReceived(request.id, { name: 'b.pdf' }); expect(requestValidity(getRequestByToken(request.token))).toEqual({ ok: false, reason: 'full' }); const expired = createRequest(dir.id, { expiresAt: Date.now() - 1000 }); expect(requestValidity(expired)).toEqual({ ok: false, reason: 'expired' }); const open = createRequest(dir.id, {}); closeRequest(open.id); expect(requestValidity(getRequestByToken(open.token))).toEqual({ ok: false, reason: 'closed' }); }); it('refuses non-folder targets and dies with a trashed folder', () => { const dir = mkdir(ROOT_ID, 'inbox-dead'); const request = createRequest(dir.id, {}); trashNode(dir.id); expect(requestValidity(getRequestByToken(request.token))).toEqual({ ok: false, reason: 'gone' }); restoreNode(dir.id); expect(requestValidity(getRequestByToken(request.token)).ok).toBe(true); }); }); describe('shares', () => { it('creates 10-char base58 tokens and validates lifecycle', async () => { const folder = mkdir(ROOT_ID, 'share-me'); const share = await createShare(folder.id, {}); expect(share.token).toMatch(/^[1-9A-HJ-NP-Za-km-z]{10}$/); expect(shareValidity(share).ok).toBe(true); const expired = await createShare(folder.id, { expiresAt: Date.now() - 1000 }); expect(shareValidity(expired)).toEqual({ ok: false, reason: 'expired' }); revokeShare(share.id); expect(shareValidity(getShareByToken(share.token))).toEqual({ ok: false, reason: 'revoked' }); }); it('argon2 share passwords verify correctly', async () => { const folder = mkdir(ROOT_ID, 'pw-share'); const share = await createShare(folder.id, { password: 'secret42' }); expect(share.password_hash).toMatch(/^\$argon2id\$/); expect(await verifySharePassword(share, 'secret42')).toBe(true); expect(await verifySharePassword(share, 'nope')).toBe(false); }); it('max downloads exhausts the link', async () => { const folder = mkdir(ROOT_ID, 'maxdl-share'); const share = await createShare(folder.id, { maxDownloads: 0 }); expect(shareValidity(share)).toEqual({ ok: false, reason: 'exhausted' }); }); }); describe('range parser', () => { it('handles normal, open-ended, suffix, and invalid ranges', () => { expect(parseRange('bytes=0-99', 1000)).toEqual({ start: 0, end: 99 }); expect(parseRange('bytes=500-', 1000)).toEqual({ start: 500, end: 999 }); expect(parseRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 }); expect(parseRange('bytes=999-5000', 1000)).toEqual({ start: 999, end: 999 }); expect(parseRange('bytes=1000-', 1000)).toBeNull(); expect(parseRange('bytes=abc', 1000)).toBeNull(); expect(parseRange(undefined, 1000)).toBeNull(); }); }); describe('search', () => { it('finds nodes by name via FTS', async () => { mkdir(ROOT_ID, 'quarterly-report-folder'); const rows = searchNodes('quarterly'); expect(rows.some((r) => r.name === 'quarterly-report-folder')).toBe(true); }); });