SPB Git

Repository Public

requests.test.ts 26.0 KB 322 lines typescript

1import { randomBytes } from 'node:crypto';2import { existsSync, readdirSync } from 'node:fs';3import { afterAll, beforeAll, describe, expect, it } from 'vitest';4import { config } from '../src/config.ts';5import { sql } from '../src/db.ts';6import { clamdPing } from '../src/tools.ts';7import { housekeeping } from '../src/worker.ts';8import { anon, Client, closeAll, sha, signup, work } from './harness.ts';910afterAll(closeAll);11const EICAR = Buffer.from('X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*');12const PDF = Buffer.concat([Buffer.from('%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n'), randomBytes(2000)]);13const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1f, 0x15, 0xc4, 0x89]), randomBytes(500)]);14const P = '/api/v1/public';15let a: Awaited<ReturnType<typeof signup>>, b: Awaited<ReturnType<typeof signup>>;16beforeAll(async () => { a = await signup(); b = await signup(); expect(await clamdPing(), 'clamd must be running for these tests (brew install clamav + freshclam into ~/.cache/sp-drive-clamav)').toBe(true); });1718let seq = 0;19async function portal(over: Record<string, unknown> = {}, owner = a) {20  const folder = (await owner.client.post('/api/v1/folders', { parentId: owner.rootId, name: `Dépôts ${++seq}` })).body;21  const r = await owner.client.post('/api/v1/requests', { folderId: folder.id, title: `Portail ${seq}`, ...over });22  expect(r.status, JSON.stringify(r.body)).toBe(200);23  return { id: r.body.request.id as string, token: (r.body.url as string).split('/request/')[1]!, folderId: folder.id as string };24}25interface F { name: string; data: Buffer }26const bearer = (t: string) => ({ authorization: `Bearer ${t}` });27async function start(token: string, files: F[], fields: Record<string, unknown> = {}, c: Client = anon()) {28  const r = await c.post(`${P}/requests/${token}/submissions`, { ...fields, files: files.map((f) => ({ name: f.name, size: f.data.length })) });29  return { c, r, token: r.body?.submissionToken as string, items: (r.body?.items ?? []) as { uploadId: string; name: string }[] };30}31async function send(c: Client, subToken: string, uploadId: string, data: Buffer, chunk = 1024 * 1024) {32  let last;33  for (let off = 0; off < data.length; off += chunk) { last = await c.patch(`${P}/uploads/${uploadId}?offset=${off}`, data.subarray(off, off + chunk), bearer(subToken)); if (last.status !== 200) return last; }34  return last!;35}36/** A complete deposit: admission, transfer, finalisation, background processing. */37async function deposit(token: string, files: F[], fields: Record<string, unknown> = {}) {38  const s = await start(token, files, fields);39  expect(s.r.status, JSON.stringify(s.r.body)).toBe(200);40  for (const [i, it] of s.items.entries()) expect((await send(s.c, s.token, it.uploadId, files[i]!.data)).status).toBe(200);41  const fin = await s.c.post(`${P}/submissions/finalize`, {}, bearer(s.token));42  await work();43  const status = await s.c.get(`${P}/submissions/status`, bearer(s.token));44  return { ...s, fin, status: status.body };45}46const tree = async (c: Client, folderId: string): Promise<string[]> => {47  const l = (await c.get(`/api/v1/folders/${folderId}`)).body;48  const out: string[] = l.files.map((f: any) => f.name);49  for (const d of l.folders) out.push(...(await tree(c, d.id)).map((n) => `${d.name}/${n}`));50  return out.sort();51};5253describe('deposit portals', () => {54  it('accepts a deposit without an account and places it atomically in a sub-folder, with a journal and a notification', async () => {55    const p = await portal({ description: 'Vos pièces', confirmationMessage: 'Merci, bien reçu.', fields: { name: 'required', email: 'optional', reference: 'optional', custom: [{ key: 'dossier', label: 'No de dossier', required: true }] } });56    const info = (await anon().get(`${P}/requests/${p.token}`)).body;57    expect(info).toMatchObject({ title: `Portail ${seq}`, needsPassword: false, description: 'Vos pièces' });58    expect(JSON.stringify(info)).not.toMatch(/folder|owner|Dépôts|submissions|bytesReceived|"id"/i); // nothing about the destination or the owner59    const big = randomBytes(2_500_000);60    const d = await deposit(p.token, [{ name: 'pièce.pdf', data: PDF }, { name: 'photo.png', data: PNG }, { name: 'gros.bin', data: big }], { name: 'Marie Tremblay', email: 'marie@example.test', custom: { dossier: 'D-42' } });61    expect(d.fin.body.status).toBe('processing');62    expect(d.status).toMatchObject({ status: 'completed', confirmationMessage: 'Merci, bien reçu.' });63    expect(d.status.ref).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);64    expect(d.status.items).toEqual([{ name: 'pièce.pdf', status: 'accepted', reason: null }, { name: 'photo.png', status: 'accepted', reason: null }, { name: 'gros.bin', status: 'accepted', reason: null }]);65    const files = await tree(a.client, p.folderId);66    expect(files).toHaveLength(3);67    expect(files[0]).toMatch(new RegExp(`^\\d{4}-\\d{2}-\\d{2} Marie Tremblay ${d.status.ref}/gros\\.bin$`));68    // content is intact and marked clean by the scan that preceded placement69    const sub = (await a.client.get(`/api/v1/folders/${p.folderId}`)).body.folders[0];70    const placed = (await a.client.get(`/api/v1/folders/${sub.id}`)).body.files;71    const g = placed.find((f: any) => f.name === 'gros.bin');72    expect(g).toMatchObject({ size: big.length, sha256: sha(big), origin: 'request', scan: 'clean' });73    expect((await a.client.get(`/api/v1/files/${g.id}/content?download=1`)).raw.equals(big)).toBe(true);74    for (const it of d.items) expect(existsSync(`${config.quarantineDir}/up-${it.uploadId}`)).toBe(false); // quarantine emptied75    // owner's view: submission, form answers, journal, notification76    const subs = (await a.client.get(`/api/v1/requests/${p.id}/submissions`)).body;77    expect(subs[0]).toMatchObject({ ref: d.status.ref, status: 'completed', name: 'Marie Tremblay', files: 3, placed: 3, rejected: 0 });78    const detail = (await a.client.get(`/api/v1/submissions/${subs[0].id}`)).body;79    expect(detail.meta).toEqual({ dossier: 'D-42' });80    expect(detail.events.map((e: any) => e.action)).toEqual(['submission.started', 'submission.finalized', 'submission.completed']);81    expect(detail.items.every((i: any) => i.status === 'placed' && i.fileId)).toBe(true);82    const note = (await a.client.get('/api/v1/notifications')).body.find((n: any) => n.kind === 'request.submission');83    expect(note.data).toMatchObject({ ref: d.status.ref, placed: 3, rejected: 0, name: 'Marie Tremblay' });84    expect((await a.client.get('/api/v1/requests')).body.find((r: any) => r.id === p.id)).toMatchObject({ submissions: 1, bytesReceived: PDF.length + PNG.length + big.length });85  });8687  it('a depositor can never list, read, search, replace or delete anything — not the folder, not other deposits', async () => {88    const p = await portal({ subfolderPerSubmission: false });89    const existing = (await a.client.upload(p.folderId, 'rapport.pdf', PDF)).body.file;90    const other = await start(p.token, [{ name: 'autre.pdf', data: PDF }]);91    const me = await start(p.token, [{ name: 'rapport.pdf', data: Buffer.concat([PDF, Buffer.from('v2')]) }]);92    const h = bearer(me.token);93    // the submission token opens no owner route, with or without cookies94    for (const [m, url, body] of [['GET', '/api/v1/folders/root'], ['GET', `/api/v1/folders/${p.folderId}`], ['GET', `/api/v1/files/${existing.id}`], ['GET', `/api/v1/files/${existing.id}/content`],95      ['GET', '/api/v1/search?q=rapport'], ['GET', '/api/v1/recent'], ['GET', `/api/v1/zip?folders=${p.folderId}`], ['GET', `/api/v1/requests/${p.id}/submissions`], ['GET', '/api/v1/requests'],96      ['POST', '/api/v1/trash', { fileIds: [existing.id] }], ['POST', '/api/v1/uploads', { folderId: p.folderId, name: 'x', size: 1 }], ['PATCH', `/api/v1/files/${existing.id}`, { name: 'pris.pdf' }]] as const)97      expect([url, (await me.c.call(m, url, body, h)).status]).toEqual([url, 401]);98    // nor another submission's upload, status or finalisation99    expect((await me.c.get(`${P}/uploads/${other.items[0]!.uploadId}`, h)).status).toBe(404);100    expect((await me.c.patch(`${P}/uploads/${other.items[0]!.uploadId}?offset=0`, PDF, h)).status).toBe(404);101    expect((await anon().get(`${P}/uploads/${me.items[0]!.uploadId}`)).status).toBe(401);102    expect((await anon().get(`${P}/submissions/status`, bearer('a'.repeat(43)))).status).toBe(401);103    // an owner upload id is not reachable through the public upload route104    const ownerUpload = (await a.client.post('/api/v1/uploads', { folderId: p.folderId, name: 'o.bin', size: 5 })).body;105    expect((await me.c.patch(`${P}/uploads/${ownerUpload.id}?offset=0`, Buffer.from('12345'), h)).status).toBe(404);106    // and a portal upload is not reachable through the owner upload route by another account107    expect((await b.client.patch(`/api/v1/uploads/${me.items[0]!.uploadId}?offset=0`, PDF)).status).toBe(404);108    // depositing a file with an existing name never replaces it109    await send(me.c, me.token, me.items[0]!.uploadId, Buffer.concat([PDF, Buffer.from('v2')]));110    await me.c.post(`${P}/submissions/finalize`, {}, h); await work();111    const st = (await me.c.get(`${P}/submissions/status`, h)).body;112    expect(JSON.stringify(st)).not.toMatch(/folder|autre\.pdf|fileId|"id"/); // own files only, no identifiers113    const names = (await a.client.get(`/api/v1/folders/${p.folderId}`)).body.files.map((f: any) => f.name).sort();114    expect(names).toEqual(['rapport (2).pdf', 'rapport.pdf']);115    expect((await a.client.get(`/api/v1/files/${existing.id}`)).body.versions).toHaveLength(1);116    expect((await a.client.get(`/api/v1/files/${existing.id}/content?download=1`)).raw.equals(PDF)).toBe(true);117    // once finalised the token cannot send anything more118    expect((await me.c.patch(`${P}/uploads/${me.items[0]!.uploadId}?offset=0`, PDF, h)).status).toBe(410);119    expect((await me.c.post(`${P}/submissions/finalize`, {}, h)).status).toBe(410);120    // other accounts see nothing of the portal121    for (const url of [`/api/v1/requests/${p.id}`, `/api/v1/requests/${p.id}/submissions`]) expect((await b.client.get(url)).status).toBe(404);122    const subId = (await a.client.get(`/api/v1/requests/${p.id}/submissions`)).body[0].id;123    expect((await b.client.get(`/api/v1/submissions/${subId}`)).status).toBe(404);124    for (const [m, url] of [['PATCH', `/api/v1/requests/${p.id}`], ['DELETE', `/api/v1/requests/${p.id}`], ['POST', `/api/v1/requests/${p.id}/rotate`]] as const) expect((await b.client.call(m, url, {})).status).toBe(404);125  });126127  it('quarantines, scans with the real antivirus and inspects signatures before anything reaches the folder', async () => {128    const p = await portal();129    const exe = Buffer.concat([Buffer.from('MZ\x90\x00\x03\x00\x00\x00'), randomBytes(600)]);130    const zipAsPdf = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0, 0, 0, 8, 0]), randomBytes(300)]);131    const script = Buffer.from('#!/bin/sh\nrm -rf ~\n');132    const d = await deposit(p.token, [{ name: 'virus.pdf', data: EICAR }, { name: 'vacances.jpg', data: exe }, { name: 'facture.pdf', data: zipAsPdf }, { name: 'notes.txt', data: script }, { name: 'bon.pdf', data: PDF }, { name: 'liste.csv', data: Buffer.from('a;b\n1;2\n') }]);133    expect(Object.fromEntries(d.status.items.map((i: any) => [i.name, i.reason ?? 'ok']))).toEqual({ 'virus.pdf': 'infected', 'vacances.jpg': 'executable', 'facture.pdf': 'type_mismatch', 'notes.txt': 'executable', 'bon.pdf': 'ok', 'liste.csv': 'ok' });134    expect(d.status.status).toBe('completed');135    expect((await tree(a.client, p.folderId)).map((n) => n.split('/')[1])).toEqual(['bon.pdf', 'liste.csv']);136    // the infected bytes exist nowhere any more: not in quarantine, not as a stored object137    for (const it of d.items) expect(existsSync(`${config.quarantineDir}/up-${it.uploadId}`)).toBe(false);138    expect(await sql`select 1 from storage_objects where sha256 = ${sha(EICAR)}`).toHaveLength(0);139    const subId = (await a.client.get(`/api/v1/requests/${p.id}/submissions`)).body[0].id;140    const detail = (await a.client.get(`/api/v1/submissions/${subId}`)).body;141    const rejected = detail.events.filter((e: any) => e.action === 'submission.item_rejected');142    expect(rejected).toHaveLength(4);143    expect(rejected.find((e: any) => e.meta.code === 'infected').meta.signature).toMatch(/eicar/i);144    // a deposit where everything is refused is "rejected" and creates no folder145    const p2 = await portal();146    const d2 = await deposit(p2.token, [{ name: 'virus.txt', data: EICAR }]);147    expect(d2.status.status).toBe('rejected');148    expect(await tree(a.client, p2.folderId)).toEqual([]);149    expect((await a.client.get(`/api/v1/folders/${p2.folderId}`)).body.folders).toEqual([]);150  });151152  it('keeps deposits in quarantine while the antivirus is unavailable, then places them when it returns', async () => {153    const p = await portal();154    const socket = config.clamdSocket;155    config.clamdSocket = '/tmp/sp-drive-test/absent.sock';156    let s;157    try {158      s = await start(p.token, [{ name: 'attente.pdf', data: PDF }]);159      await send(s.c, s.token, s.items[0]!.uploadId, PDF);160      await s.c.post(`${P}/submissions/finalize`, {}, bearer(s.token));161      await work();162      expect((await s.c.get(`${P}/submissions/status`, bearer(s.token))).body).toMatchObject({ status: 'processing', items: [{ name: 'attente.pdf', status: 'pending' }] });163      expect(await tree(a.client, p.folderId)).toEqual([]); // fail closed: nothing placed unscanned164      expect(existsSync(`${config.quarantineDir}/up-${s.items[0]!.uploadId}`)).toBe(true);165      expect((await a.client.get('/api/v1/health/ready')).status).toBe(503);166    } finally { config.clamdSocket = socket; }167    await work();168    expect((await s.c.get(`${P}/submissions/status`, bearer(s.token))).body.status).toBe('completed');169    expect(await tree(a.client, p.folderId)).toHaveLength(1);170  });171172  it('applies expiry, pause and revocation to transfers that started before', async () => {173    for (const how of ['expire', 'pause', 'revoke', 'trash-target'] as const) {174      const p = await portal({ expiresAt: new Date(Date.now() + 3600_000).toISOString() });175      const data = randomBytes(3 * 1024 * 1024);176      const s = await start(p.token, [{ name: 'long.bin', data }]);177      expect((await s.c.patch(`${P}/uploads/${s.items[0]!.uploadId}?offset=0`, data.subarray(0, 1024 * 1024), bearer(s.token))).status).toBe(200);178      if (how === 'expire') await sql`update upload_requests set expires_at = now() - interval '1 second' where id = ${p.id}`;179      if (how === 'pause') await a.client.patch(`/api/v1/requests/${p.id}`, { active: false });180      if (how === 'revoke') await a.client.del(`/api/v1/requests/${p.id}`);181      if (how === 'trash-target') await a.client.post('/api/v1/trash', { folderIds: [p.folderId] });182      const next = await s.c.patch(`${P}/uploads/${s.items[0]!.uploadId}?offset=${1024 * 1024}`, data.subarray(1024 * 1024), bearer(s.token));183      expect([how, next.status, next.body.error]).toEqual([how, 410, 'portal_closed']);184      expect([how, (await s.c.post(`${P}/submissions/finalize`, {}, bearer(s.token))).status]).toEqual([how, 410]);185      expect([how, (await anon().get(`${P}/requests/${p.token}`)).status]).toEqual([how, how === 'revoke' ? 404 : 410]);186      expect([how, (await start(p.token, [{ name: 'x.pdf', data: PDF }])).r.status]).toEqual([how, how === 'revoke' ? 404 : 410]);187      await work();188      if (how !== 'trash-target') expect(await tree(a.client, p.folderId)).toEqual([]);189      if (how === 'pause') { // resuming the portal lets the same deposit finish190        await a.client.patch(`/api/v1/requests/${p.id}`, { active: true });191        expect((await s.c.patch(`${P}/uploads/${s.items[0]!.uploadId}?offset=${1024 * 1024}`, data.subarray(1024 * 1024), bearer(s.token))).status).toBe(200);192        await s.c.post(`${P}/submissions/finalize`, {}, bearer(s.token)); await work();193        expect(await tree(a.client, p.folderId)).toHaveLength(1);194      }195      if (how === 'revoke') expect(existsSync(`${config.quarantineDir}/up-${s.items[0]!.uploadId}`)).toBe(false); // partial bytes are destroyed at once196      else if (how !== 'pause') expect(existsSync(`${config.quarantineDir}/up-${s.items[0]!.uploadId}`)).toBe(true); // until housekeeping expires the deposit197    }198  });199200  it('refuses a target that disappears between admission and placement', async () => {201    const p = await portal();202    const s = await start(p.token, [{ name: 'tard.pdf', data: PDF }]);203    await send(s.c, s.token, s.items[0]!.uploadId, PDF);204    await s.c.post(`${P}/submissions/finalize`, {}, bearer(s.token));205    await a.client.post('/api/v1/trash', { folderIds: [p.folderId] });206    await work();207    expect((await s.c.get(`${P}/submissions/status`, bearer(s.token))).body).toMatchObject({ status: 'rejected', items: [{ status: 'rejected', reason: 'target_unavailable' }] });208    expect(existsSync(`${config.quarantineDir}/up-${s.items[0]!.uploadId}`)).toBe(false);209  });210211  it('enforces every limit at admission: type, size, count, totals, frequency, owner quota', async () => {212    const p = await portal({ allowedExtensions: ['pdf', 'png'], maxFileBytes: 100_000, maxFilesPerSubmission: 2, maxTotalBytes: 150_000, submissionsPerHour: 3 });213    const tryOne = async (files: F[], c?: Client) => { const r = (await start(p.token, files, {}, c)).r; return r.body.error ?? r.status; };214    expect(await tryOne([{ name: 'macro.docm', data: PDF }])).toBe('type_not_allowed');215    expect(await tryOne([{ name: 'outil.exe', data: PDF }])).toBe('type_not_allowed');216    expect(await tryOne([{ name: 'gros.pdf', data: randomBytes(100_001) }])).toBe('file_too_large');217    expect(await tryOne([{ name: '1.pdf', data: PDF }, { name: '2.pdf', data: PDF }, { name: '3.pdf', data: PDF }])).toBe('too_many_files');218    expect(await tryOne([{ name: 'a.pdf', data: randomBytes(90_000) }, { name: 'b.pdf', data: randomBytes(90_000) }])).toBe('portal_full');219    // totals count deposits in flight: two visitors cannot both reserve the last bytes220    const race = await Promise.all([0, 1, 2, 3].map(() => start(p.token, [{ name: 'c.pdf', data: randomBytes(60_000) }])));221    expect(race.filter((x) => x.r.status === 200)).toHaveLength(2);222    expect(race.filter((x) => x.r.body.error === 'portal_full')).toHaveLength(2);223    // more bytes than declared are refused at the chunk224    const ok = race.find((x) => x.r.status === 200)!;225    expect((await ok.c.patch(`${P}/uploads/${ok.items[0]!.uploadId}?offset=0`, randomBytes(60_001), bearer(ok.token))).status).toBe(413);226    // frequency per address227    const p2 = await portal({ submissionsPerHour: 2 });228    const same = anon();229    expect((await start(p2.token, [{ name: 'a.pdf', data: PDF }], {}, same)).r.status).toBe(200);230    expect((await start(p2.token, [{ name: 'a.pdf', data: PDF }], {}, same)).r.status).toBe(200);231    expect((await start(p2.token, [{ name: 'a.pdf', data: PDF }], {}, same)).r.status).toBe(429);232    expect((await start(p2.token, [{ name: 'a.pdf', data: PDF }])).r.status).toBe(200); // another address is not punished233    // number of deposits234    const p3 = await portal({ maxSubmissions: 1 });235    expect((await deposit(p3.token, [{ name: 'a.pdf', data: PDF }])).status.status).toBe('completed');236    expect((await anon().get(`${P}/requests/${p3.token}`)).status).toBe(410);237    expect((await a.client.get(`/api/v1/requests/${p3.id}`)).body.request.state).toBe('full');238    // the owner's account quota bounds every portal, without telling the depositor anything about the account239    const small = await signup({ quotaBytes: 50_000 });240    const p4 = await portal({}, small);241    const r = (await start(p4.token, [{ name: 'a.pdf', data: randomBytes(60_000) }])).r;242    expect(r.body.error).toBe('portal_full'); expect(JSON.stringify(r.body)).not.toMatch(/quota|used|limit/);243    // settings are validated: no portal above the scanner's capacity, no negative limits244    expect((await a.client.post('/api/v1/requests', { folderId: a.rootId, title: 'x', maxFileBytes: 3 * 1024 ** 3 })).status).toBe(400);245  });246247  it('checks the form: required fields, custom fields, consent', async () => {248    const p = await portal({ requireConsent: true, consentText: 'J’accepte.', fields: { name: 'required', email: 'required', reference: 'off', custom: [{ key: 'matricule', label: 'Matricule', required: true }] } });249    const files = [{ name: 'a.pdf', data: PDF }];250    expect((await start(p.token, files, { email: 'x@example.test', custom: { matricule: '1' }, consent: true })).r.body).toMatchObject({ error: 'field_required', field: 'name' });251    expect((await start(p.token, files, { name: 'X', email: 'pas un courriel', custom: { matricule: '1' }, consent: true })).r.status).toBe(400);252    expect((await start(p.token, files, { name: 'X', email: 'x@example.test', consent: true })).r.body).toMatchObject({ error: 'field_required', field: 'matricule' });253    expect((await start(p.token, files, { name: 'X', email: 'x@example.test', custom: { matricule: '1' } })).r.body.error).toBe('consent_required');254    const ok = await deposit(p.token, files, { name: '<script>alert(1)</script>', email: 'x@example.test', custom: { matricule: '1', inconnu: 'ignoré' }, consent: true });255    expect(ok.status.status).toBe('completed');256    const subId = (await a.client.get(`/api/v1/requests/${p.id}/submissions`)).body[0].id;257    const detail = (await a.client.get(`/api/v1/submissions/${subId}`)).body;258    expect(detail.meta).toEqual({ matricule: '1' }); expect(detail.consentAt).toBeTruthy();259    // a hostile submitter name cannot shape a path: the sub-folder name is sanitised260    expect((await a.client.get(`/api/v1/folders/${p.folderId}`)).body.folders[0].name).not.toMatch(/[<>/\\]/);261  });262263  it('neutralises dangerous file names', async () => {264    const p = await portal({ subfolderPerSubmission: false });265    const d = await deposit(p.token, [{ name: '../../../etc/cron.d/x.pdf', data: PDF }, { name: '..\\..\\boot.png', data: PNG }, { name: 'a\u0000b‮gnp.pdf', data: Buffer.concat([PDF, Buffer.from('2')]) },266      { name: '.htaccess.pdf', data: Buffer.concat([PDF, Buffer.from('3')]) }, { name: 'CON.pdf', data: Buffer.concat([PDF, Buffer.from('4')]) }, { name: 'même.PDF', data: Buffer.concat([PDF, Buffer.from('5')]) }, { name: 'MÊME.pdf', data: Buffer.concat([PDF, Buffer.from('6')]) }]);267    expect(d.status.status).toBe('completed');268    const names = (await a.client.get(`/api/v1/folders/${p.folderId}`)).body.files.map((f: any) => f.name);269    expect(names).toHaveLength(7);270    for (const n of names) { expect(n).not.toMatch(/[/\\\u0000-\u001f‮]/); expect(n.startsWith('.')).toBe(false); }271    expect(new Set(names.map((n: string) => n.toLowerCase())).size).toBe(7);272    expect((await a.client.get(`/api/v1/folders/${a.rootId}`)).body.files.filter((f: any) => /cron|boot/.test(f.name))).toEqual([]);273    // nothing was written outside the data directory by a name274    expect(existsSync('/tmp/sp-drive-test/etc')).toBe(false);275  });276277  it('protects a portal with a hashed password and stops guessing', async () => {278    const p = await portal({ password: 'sésame ouvre-toi' });279    const [row] = await sql<{ password_hash: string }[]>`select password_hash from upload_requests where id = ${p.id}`;280    expect(row!.password_hash.startsWith('scrypt$')).toBe(true);281    const files = [{ name: 'a.pdf', data: PDF }];282    expect((await anon().get(`${P}/requests/${p.token}`)).body.needsPassword).toBe(true);283    expect((await start(p.token, files)).r.body.error).toBe('password_required');284    expect((await start(p.token, files, { password: 'faux' })).r.body.error).toBe('password_invalid');285    expect((await start(p.token, files, { password: 'sésame ouvre-toi' })).r.status).toBe(200);286    for (let i = 0; i < 19; i++) await start(p.token, files, { password: `essai ${i}` });287    expect((await start(p.token, files, { password: 'sésame ouvre-toi' })).r.status).toBe(429); // locked for a while, from any address288    expect(JSON.stringify(await sql`select meta from audit_events where action = 'request.password_failed'`)).not.toMatch(/essai|sésame/);289  });290291  it('rotates, deletes and cleans up', async () => {292    const p = await portal();293    await deposit(p.token, [{ name: 'gardé.pdf', data: PDF }]);294    const rot = await a.client.post(`/api/v1/requests/${p.id}/rotate`);295    const fresh = (rot.body.url as string).split('/request/')[1]!;296    expect((await anon().get(`${P}/requests/${p.token}`)).status).toBe(404);297    expect((await anon().get(`${P}/requests/${fresh}`)).status).toBe(200);298    // an abandoned deposit is expired by housekeeping and its bytes destroyed299    const s = await start(fresh, [{ name: 'abandon.pdf', data: PDF }]);300    await s.c.patch(`${P}/uploads/${s.items[0]!.uploadId}?offset=0`, PDF.subarray(0, 500), bearer(s.token));301    expect(existsSync(`${config.quarantineDir}/up-${s.items[0]!.uploadId}`)).toBe(true);302    await sql`update upload_submissions set created_at = now() - interval '25 hours' where token_hash is not null and status = 'open'`;303    await housekeeping();304    expect(readdirSync(config.quarantineDir)).toHaveLength(0); // every abandoned deposit of this run is gone305    expect((await s.c.get(`${P}/submissions/status`, bearer(s.token))).body).toMatchObject({ status: 'expired', items: [{ status: 'rejected', reason: 'expired' }] });306    // deleting the portal keeps what was deposited307    expect((await a.client.del(`/api/v1/requests/${p.id}?purge=1`)).status).toBe(200);308    expect((await anon().get(`${P}/requests/${fresh}`)).status).toBe(404);309    expect(await tree(a.client, p.folderId)).toHaveLength(1);310    expect((await a.client.get(`/api/v1/requests/${p.id}`)).status).toBe(404);311  });312313  it('an incomplete file is refused at finalisation while complete ones go through', async () => {314    const p = await portal();315    const s = await start(p.token, [{ name: 'complet.pdf', data: PDF }, { name: 'partiel.png', data: PNG }]);316    await send(s.c, s.token, s.items[0]!.uploadId, PDF);317    await s.c.patch(`${P}/uploads/${s.items[1]!.uploadId}?offset=0`, PNG.subarray(0, 100), bearer(s.token));318    await s.c.post(`${P}/submissions/finalize`, {}, bearer(s.token)); await work();319    expect((await s.c.get(`${P}/submissions/status`, bearer(s.token))).body.items).toEqual([{ name: 'complet.pdf', status: 'accepted', reason: null }, { name: 'partiel.png', status: 'rejected', reason: 'incomplete' }]);320  });321});322
Files main
Menu

Theme