Repository Public
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\u0000bgnp.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