SPB Git

Repository Public

previews.test.ts 9.4 KB 139 lines typescript

1import { execFileSync } from 'node:child_process';2import { randomBytes } from 'node:crypto';3import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs';4import { join } from 'node:path';5import { Readable } from 'node:stream';6import { pipeline } from 'node:stream/promises';7import ExcelJS from 'exceljs';8import { afterAll, beforeAll, describe, expect, it } from 'vitest';9import yazl from 'yazl';10import { config } from '../src/config.ts';11import { migrate, migrateDown, sql } from '../src/db.ts';12import { runSandboxed, withWorkDir } from '../src/tools.ts';13import { closeAll, signup, work } from './harness.ts';1415afterAll(closeAll);16const FX = '/tmp/sp-drive-test/fixtures';17let a: Awaited<ReturnType<typeof signup>>;18const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');19beforeAll(async () => {20  a = await signup();21  mkdirSync(FX, { recursive: true });22  writeFileSync(join(FX, 'px.png'), PNG_1PX);23  execFileSync('/usr/bin/sips', ['-z', '300', '400', join(FX, 'px.png'), '--out', join(FX, 'image.png')], { stdio: 'pipe' });24  execFileSync('/usr/bin/sips', ['-s', 'format', 'heic', join(FX, 'image.png'), '--out', join(FX, 'photo.heic')], { stdio: 'pipe' });25  writeFileSync(join(FX, 'lettre.txt'), 'Bonjour SP Drive\nCeci est une lettre.\n');26  execFileSync(config.soffice, ['--headless', `-env:UserInstallation=file://${FX}/lo`, '--convert-to', 'docx:MS Word 2007 XML', '--outdir', FX, join(FX, 'lettre.txt')], { stdio: 'pipe', timeout: 120000 });27  const wb = new ExcelJS.Workbook();28  const s1 = wb.addWorksheet('Budget'); s1.addRow(['Poste', 'Montant']); s1.addRow(['Loyer', 1200]); s1.addRow(['Total', { formula: 'B2', result: 1200 }]);29  wb.addWorksheet('Notes').addRow(['Deuxième feuille']);30  await wb.xlsx.writeFile(join(FX, 'budget.xlsx'));31  mkdirSync(join(FX, 'arch/sous'), { recursive: true }); writeFileSync(join(FX, 'arch/sous/a.txt'), 'a'); writeFileSync(join(FX, 'arch/b.txt'), 'bb');32  execFileSync('/usr/bin/tar', ['czf', join(FX, 'arch.tar.gz'), '-C', FX, 'arch']);33  execFileSync('/usr/bin/zip', ['-qr', join(FX, 'arch.zip'), 'arch'], { cwd: FX });34  // a zip-based "document" declaring 400 MB of zeros in a few hundred kilobytes35  const z = new yazl.ZipFile();36  z.addReadStream(Readable.from((function* () { const block = Buffer.alloc(1 << 20); for (let i = 0; i < 400; i++) yield block; })()), 'word/document.xml');37  z.end(); await pipeline(z.outputStream, createWriteStream(join(FX, 'bombe.docx')));38});39const up = async (name: string, data?: Buffer) => (await a.client.upload(a.rootId, name, data ?? readFileSync(join(FX, name)))).body.file;40const preview = (id: string, kind: string) => a.client.get(`/api/v1/files/${id}/preview/${kind}`);4142describe('previews and scanning', () => {43  it('answers "pending" until the worker has run, then serves thumbnails and HEIC renditions', async () => {44    const png = await up('image.png'), heic = await up('photo.heic');45    expect((await preview(png.id, 'thumb')).status).toBe(202);46    await work();47    const t = await preview(png.id, 'thumb');48    expect(t.status).toBe(200); expect(t.headers['content-type']).toBe('image/jpeg'); expect(t.raw.subarray(0, 3).toString('hex')).toBe('ffd8ff');49    const full = await preview(heic.id, 'image');50    expect(full.status).toBe(200); expect(full.raw.subarray(0, 3).toString('hex')).toBe('ffd8ff');51    expect((await a.client.get(`/api/v1/folders/${a.rootId}`)).body.files.find((f: any) => f.id === png.id)).toMatchObject({ thumb: true, scan: 'clean' });52    expect((await preview(png.id, 'nope')).status).toBe(404);53    // derived previews are encrypted at rest like originals54    const [p] = await sql<{ storage_key: string }[]>`select p.storage_key from previews p join file_versions v on v.object_id = p.object_id where v.file_id = ${png.id} and p.kind = 'thumb'`;55    expect(readFileSync(join(config.blobDir, p!.storage_key)).subarray(0, 3).toString('hex')).not.toBe('ffd8ff');56  });57  it('converts office documents to PDF in the sandbox and keeps the original untouched', async () => {58    const original = readFileSync(join(FX, 'lettre.docx'));59    const f = await up('lettre.docx');60    await work();61    const pdf = await preview(f.id, 'pdf');62    expect(pdf.status, JSON.stringify(pdf.body)).toBe(200);63    expect(pdf.raw.subarray(0, 5).toString()).toBe('%PDF-');64    expect((await preview(f.id, 'thumb')).status).toBe(200);65    expect((await a.client.get(`/api/v1/files/${f.id}/content?download=1`)).raw.equals(original)).toBe(true);66    expect((await a.client.get(`/api/v1/files/${f.id}`)).body.previews).toEqual({ pdf: 'ready' });67  });68  it('reports a failed conversion instead of pretending, and refuses archive bombs before opening them', async () => {69    const broken = await up('cassé.docx', randomBytes(5000));70    const bomb = await up('bombe.docx');71    await work();72    const r = await preview(broken.id, 'pdf');73    expect(r.status).toBe(422); expect(r.body.error).toBe('preview_failed'); expect(r.body.message).toBeTruthy();74    const b = await preview(bomb.id, 'pdf');75    expect(b.status).toBe(422); expect(b.body.message).toMatch(/unreasonable size/);76    expect((await a.client.get(`/api/v1/files/${broken.id}/content?download=1`)).status).toBe(200); // the file itself stays available77  });78  it('reads workbooks sheet by sheet in a separate process', async () => {79    const f = await up('budget.xlsx');80    await work();81    const r = await preview(f.id, 'sheet');82    expect(r.status, JSON.stringify(r.body)).toBe(200);83    expect(r.body.sheets.map((s: any) => s.name)).toEqual(['Budget', 'Notes']);84    expect(r.body.sheets[0].rows).toEqual([['Poste', 'Montant'], ['Loyer', '1200'], ['Total', '1200']]);85  });86  it('inventories ZIP and TAR archives without extracting them', async () => {87    const zip = await up('arch.zip'), tgz = await up('arch.tar.gz');88    await work();89    const z = (await preview(zip.id, 'archive')).body, t = (await preview(tgz.id, 'archive')).body;90    expect(z.entries.filter((e: any) => !e.dir).map((e: any) => `${e.path}:${e.size}`).sort()).toEqual(['arch/b.txt:2', 'arch/sous/a.txt:1']);91    expect(t.entries.filter((e: any) => !e.dir).map((e: any) => `${e.path}:${e.size}`).sort()).toEqual(['arch/b.txt:2', 'arch/sous/a.txt:1']);92    expect(z.suspicious).toBe(false);93    // hostile entry names are displayed inert94    const evil = new yazl.ZipFile(); evil.addBuffer(Buffer.from('x'), 'ok/../../../../etc/passwd'.replace(/\.\.\//g, 'up/')); evil.end();95    const chunks: Buffer[] = []; for await (const c of evil.outputStream) chunks.push(c as Buffer);96    const raw = Buffer.concat(chunks); const patched = Buffer.from(raw.toString('latin1').replace(/up\//g, '../'), 'latin1'); // "../" written straight into the archive97    const e = await up('evil.zip', patched);98    await work();99    const l = await preview(e.id, 'archive');100    if (l.status === 200) for (const en of l.body.entries) expect(en.path).not.toMatch(/\.\./); else expect(l.status).toBe(422); // listed safely or refused, never trusted101  });102  it('flags infected uploads of the owner, blocks previews and warns', async () => {103    const f = await up('eicar.txt', Buffer.from('X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'));104    await work();105    expect((await a.client.get(`/api/v1/files/${f.id}`)).body.file.scan).toBe('infected');106    expect((await a.client.get(`/api/v1/files/${f.id}/content`)).status).toBe(451);107    expect((await a.client.get(`/api/v1/zip?files=${f.id}`)).status).toBe(400); // left out of archives108    const n = (await a.client.get('/api/v1/notifications')).body.find((x: any) => x.kind === 'scan.infected');109    expect(n.data.signature).toMatch(/eicar/i);110  });111  it('runs converters without network, without the home directory and with a time limit', async () => {112    await withWorkDir(async (work) => {113      const net = await runSandboxed(work, '/usr/bin/curl', ['-s', '-m', '5', '-o', '/dev/null', '-w', '%{http_code}', 'http://127.0.0.1:5432'], { timeoutMs: 15000 });114      expect(net.code).not.toBe(0);115      const key = await runSandboxed(work, '/bin/cat', [config.masterKeyFile], { timeoutMs: 5000 });116      expect(key.code).not.toBe(0); expect(key.stdout).toBe('');117      const home = await runSandboxed(work, '/bin/ls', [`${process.env.HOME}/.ssh`], { timeoutMs: 5000 });118      expect(home.code).not.toBe(0);119      const blobs = await runSandboxed(work, '/bin/ls', [config.blobDir], { timeoutMs: 5000 });120      expect(blobs.code).not.toBe(0);121      const pg = await runSandboxed(work, '/opt/homebrew/opt/postgresql@17/bin/psql', ['-h', '/tmp', '-l'], { timeoutMs: 8000 });122      expect(pg.code).not.toBe(0); // the database's local socket is out of reach too123      const outside = await runSandboxed(work, '/usr/bin/touch', ['/tmp/sp-drive-test/data/blobs/escape'], { timeoutMs: 5000 });124      expect(outside.code).not.toBe(0);125      expect((await runSandboxed(work, '/usr/bin/touch', [join(work, 'ok')], { timeoutMs: 5000 })).code).toBe(0);126      await expect(runSandboxed(work, '/bin/sleep', ['30'], { timeoutMs: 1000 })).rejects.toThrow(/exceeded/);127    });128  });129});130131describe('migrations', () => {132  it('revert and re-apply cleanly', async () => {133    expect(await migrateDown()).toBe('001_init');134    expect(await sql`select 1 from information_schema.tables where table_name = 'files'`).toHaveLength(0);135    expect(await migrate()).toEqual(['001_init']);136    expect(await sql`select 1 from information_schema.tables where table_name = 'files'`).toHaveLength(1);137  });138});139
Files main
Menu

Theme