Repository Public
1import { execFileSync } from 'node:child_process';2import { randomBytes } from 'node:crypto';3import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';4import { join } from 'node:path';5import { afterAll, beforeAll, describe, expect, it } from 'vitest';6import { config } from '../src/config.ts';7import { sql } from '../src/db.ts';8import { Client, closeAll, sha, signup, work } from './harness.ts';910afterAll(closeAll);11let a: Awaited<ReturnType<typeof signup>>, b: Awaited<ReturnType<typeof signup>>;12beforeAll(async () => { a = await signup(); b = await signup(); });13const mkdir = async (c: Client, parentId: string, name: string) => (await c.post('/api/v1/folders', { parentId, name })).body;14const ls = async (c: Client, id: string) => (await c.get(`/api/v1/folders/${id}`)).body;15const walk = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]));1617describe('uploads', () => {18 it('stores a multi-chunk file, verifies its SHA-256 and serves it back with Range', async () => {19 const data = randomBytes(5 * 1024 * 1024 + 123);20 const up = await a.client.upload(a.rootId, 'gros.bin', data, { chunk: 1024 * 1024 });21 expect(up.status).toBe(200);22 expect(up.body.file).toMatchObject({ name: 'gros.bin', size: data.length, sha256: sha(data) });23 const id = up.body.file.id;24 const full = await a.client.get(`/api/v1/files/${id}/content?download=1`);25 expect(full.status).toBe(200);26 expect(full.raw.equals(data)).toBe(true);27 expect(full.headers['content-disposition']).toContain('attachment');28 expect(full.headers['content-security-policy']).toContain('sandbox');29 const mid = await a.client.get(`/api/v1/files/${id}/content`, { range: 'bytes=65530-200005' }); // crosses several cipher blocks30 expect(mid.status).toBe(206);31 expect(mid.headers['content-range']).toBe(`bytes 65530-200005/${data.length}`);32 expect(mid.raw.equals(data.subarray(65530, 200006))).toBe(true);33 const tail = await a.client.get(`/api/v1/files/${id}/content`, { range: 'bytes=-100' });34 expect(tail.raw.equals(data.subarray(data.length - 100))).toBe(true);35 expect((await a.client.get(`/api/v1/files/${id}/content`, { range: `bytes=${data.length}-` })).status).toBe(416);36 expect((await a.client.get(`/api/v1/files/${id}/content`, { 'if-none-match': `"${sha(data)}"` })).status).toBe(304);37 });38 it('never writes plaintext to the blob store, and detects tampering', async () => {39 const marker = Buffer.from(`PLAINTEXT-MARKER-${randomBytes(8).toString('hex')}-`.repeat(4000));40 const up = await a.client.upload(a.rootId, 'secret.txt', marker);41 const [o] = await sql<{ storage_key: string }[]>`select o.storage_key from storage_objects o where o.sha256 = ${sha(marker)}`;42 const blob = join(config.blobDir, o!.storage_key);43 expect(readFileSync(blob).includes(marker.subarray(0, 40))).toBe(false);44 expect(statSync(blob).mode & 0o077).toBe(0);45 for (const f of walk(config.blobDir)) expect(readFileSync(f).includes(marker.subarray(0, 40))).toBe(false);46 expect(readdirSync(config.tmpDir).filter((n) => n.startsWith('up-'))).toHaveLength(0); // temp file removed47 // flip one byte of the ciphertext: the read must fail, never return altered content48 const raw = readFileSync(blob); raw[100] = raw[100]! ^ 1; writeFileSync(blob, raw);49 const res = await a.client.get(`/api/v1/files/${up.body.file.id}/content?download=1`).catch((e) => ({ status: 599, raw: Buffer.alloc(0), error: e }));50 expect(res.status === 599 || !res.raw.equals(marker)).toBe(true);51 raw[100] = raw[100]! ^ 1; writeFileSync(blob, raw);52 expect((await a.client.get(`/api/v1/files/${up.body.file.id}/content?download=1`)).raw.equals(marker)).toBe(true);53 });54 it('resumes after an interruption from the offset the server reports', async () => {55 const data = randomBytes(3 * 1024 * 1024);56 const u = (await a.client.post('/api/v1/uploads', { folderId: a.rootId, name: 'reprise.bin', size: data.length })).body;57 expect((await a.client.patch(`/api/v1/uploads/${u.id}?offset=0`, data.subarray(0, 1024 * 1024))).body.received).toBe(1024 * 1024);58 // "connection lost": a new client instance asks where to continue59 const later = new Client(a.client.auth); await later.login();60 expect((await later.get(`/api/v1/uploads/${u.id}`)).body.received).toBe(1024 * 1024);61 // a chunk at the wrong place is refused and tells the right offset62 const wrong = await later.patch(`/api/v1/uploads/${u.id}?offset=${2 * 1024 * 1024}`, data.subarray(2 * 1024 * 1024));63 expect(wrong.status).toBe(409); expect(wrong.body.received).toBe(1024 * 1024);64 // a corrupted chunk is refused and leaves the offset untouched65 const corrupt = await later.patch(`/api/v1/uploads/${u.id}?offset=${1024 * 1024}`, data.subarray(1024 * 1024, 2 * 1024 * 1024), { 'x-chunk-sha256': sha(Buffer.from('other')) });66 expect(corrupt.status).toBe(422);67 expect((await later.get(`/api/v1/uploads/${u.id}`)).body.received).toBe(1024 * 1024);68 expect((await later.post(`/api/v1/uploads/${u.id}/complete`)).status).toBe(409); // not all bytes yet69 expect((await later.patch(`/api/v1/uploads/${u.id}?offset=${1024 * 1024}`, data.subarray(1024 * 1024))).body.received).toBe(data.length);70 expect((await later.post(`/api/v1/uploads/${u.id}/complete`, { sha256: sha(Buffer.from('not this')) })).status).toBe(422); // whole-file checksum71 const done = await later.post(`/api/v1/uploads/${u.id}/complete`, { sha256: sha(data) });72 expect(done.body.file.sha256).toBe(sha(data));73 expect((await later.post(`/api/v1/uploads/${u.id}/complete`, { sha256: sha(data) })).body.file.id).toBe(done.body.file.id); // idempotent74 });75 it('refuses bytes beyond the declared size and cancels cleanly', async () => {76 const u = (await a.client.post('/api/v1/uploads', { folderId: a.rootId, name: 'petit.bin', size: 10 })).body;77 expect((await a.client.patch(`/api/v1/uploads/${u.id}?offset=0`, randomBytes(11))).status).toBe(413);78 expect((await a.client.get(`/api/v1/uploads/${u.id}`)).body.received).toBe(0);79 expect((await a.client.del(`/api/v1/uploads/${u.id}`)).status).toBe(200);80 expect((await a.client.patch(`/api/v1/uploads/${u.id}?offset=0`, randomBytes(10))).status).toBe(410);81 expect(readdirSync(config.tmpDir).filter((n) => n.includes(u.id))).toHaveLength(0);82 });83 it('serialises concurrent chunks for one upload: exactly one wins each offset', async () => {84 const data = randomBytes(64 * 1024);85 const u = (await a.client.post('/api/v1/uploads', { folderId: a.rootId, name: 'course.bin', size: data.length })).body;86 const res = await Promise.all(Array.from({ length: 6 }, () => a.client.patch(`/api/v1/uploads/${u.id}?offset=0`, data)));87 expect(res.filter((r) => r.status === 200)).toHaveLength(1);88 expect(res.filter((r) => r.status === 409)).toHaveLength(5);89 const done = await a.client.post(`/api/v1/uploads/${u.id}/complete`, { sha256: sha(data) });90 expect(done.body.file.size).toBe(data.length);91 });92 it('handles name collisions explicitly: fail, rename or new version — never a silent overwrite', async () => {93 const dir = await mkdir(a.client, a.rootId, 'Collisions');94 const v1 = Buffer.from('version 1'), v2 = Buffer.from('version 2 plus longue');95 const first = await a.client.upload(dir.id, 'Rapport.pdf', v1);96 const fail = await a.client.upload(dir.id, 'rapport.PDF', v2, { onConflict: 'fail' }); // case-insensitive collision97 expect(fail.status).toBe(409); expect(fail.body).toMatchObject({ error: 'name_conflict', name: 'rapport.PDF' });98 const renamed = await a.client.upload(dir.id, 'Rapport.pdf', v2, { onConflict: 'rename' });99 expect(renamed.body.file.name).toBe('Rapport (2).pdf');100 const versioned = await a.client.upload(dir.id, 'Rapport.pdf', v2, { onConflict: 'version' });101 expect(versioned.body.file.id).toBe(first.body.file.id);102 const detail = (await a.client.get(`/api/v1/files/${first.body.file.id}`)).body;103 expect(detail.versions.map((v: any) => v.seq)).toEqual([2, 1]);104 expect((await a.client.get(`/api/v1/files/${first.body.file.id}/content?download=1`)).raw.equals(v2)).toBe(true);105 expect((await a.client.get(`/api/v1/files/${first.body.file.id}/content?download=1&v=${detail.versions[1].id}`)).raw.equals(v1)).toBe(true);106 // restoring version 1 records a version 3 with the old content107 expect((await a.client.post(`/api/v1/files/${first.body.file.id}/versions/${detail.versions[1].id}/restore`)).body.sha256).toBe(sha(v1));108 expect((await a.client.get(`/api/v1/files/${first.body.file.id}`)).body.versions).toHaveLength(3);109 // a folder and a file cannot share a name110 expect((await a.client.post('/api/v1/folders', { parentId: dir.id, name: 'rapport.pdf' })).status).toBe(409);111 });112 it('resolves a race of identical names: all uploads land, none is lost', async () => {113 const dir = await mkdir(a.client, a.rootId, 'Course');114 const res = await Promise.all(Array.from({ length: 8 }, (_, i) => a.client.upload(dir.id, 'photo.jpg', Buffer.from(`contenu ${i}`))));115 expect(res.every((r) => r.status === 200)).toBe(true);116 const names = (await ls(a.client, dir.id)).files.map((f: any) => f.name).sort();117 expect(new Set(names).size).toBe(8);118 expect(names).toContain('photo.jpg'); expect(names).toContain('photo (8).jpg');119 const folders = await Promise.all(Array.from({ length: 6 }, () => a.client.post('/api/v1/folders', { parentId: dir.id, name: 'Même nom' })));120 expect(folders.filter((r) => r.status === 200)).toHaveLength(1);121 expect(folders.filter((r) => r.status === 409)).toHaveLength(5);122 });123 it('creates the folder structure of a folder upload and neutralises hostile paths', async () => {124 const dir = await mkdir(a.client, a.rootId, 'Import');125 await a.client.upload(dir.id, 'a.txt', Buffer.from('a'), { relativePath: 'Projet/src/a.txt' });126 await a.client.upload(dir.id, 'b.txt', Buffer.from('b'), { relativePath: 'Projet/src/b.txt' });127 const evil = await a.client.upload(dir.id, '../../../evil.txt', Buffer.from('e'), { relativePath: '../../../../etc/evil.txt' });128 expect(evil.body.file.name).not.toMatch(/[/\\]/);129 const top = await ls(a.client, dir.id);130 expect(top.folders.map((f: any) => f.name).sort()).toEqual(['Projet', 'etc']);131 const projet = await ls(a.client, top.folders.find((f: any) => f.name === 'Projet').id);132 const src = await ls(a.client, projet.folders[0].id);133 expect(src.files.map((f: any) => f.name).sort()).toEqual(['a.txt', 'b.txt']);134 expect(src.breadcrumbs.map((c: any) => c.name)).toEqual(['', 'Import', 'Projet', 'src']);135 expect((await ls(a.client, a.rootId)).files.some((f: any) => f.name.includes('evil'))).toBe(false); // nothing escaped upward136 });137 it('enforces the quota, counting uploads in flight, and deduplicates within the account only', async () => {138 const small = await signup({ quotaBytes: 1024 * 1024 });139 expect((await small.client.post('/api/v1/uploads', { folderId: small.rootId, name: 'trop.bin', size: 2 * 1024 * 1024 })).body.error).toBe('quota_exceeded');140 const first = await small.client.post('/api/v1/uploads', { folderId: small.rootId, name: 'a.bin', size: 700 * 1024 });141 expect(first.status).toBe(200);142 expect((await small.client.post('/api/v1/uploads', { folderId: small.rootId, name: 'b.bin', size: 700 * 1024 })).body.error).toBe('quota_exceeded'); // reserved by the first143 await small.client.del(`/api/v1/uploads/${first.body.id}`);144 const data = randomBytes(400 * 1024);145 expect((await small.client.upload(small.rootId, 'un.bin', data)).status).toBe(200);146 expect((await small.client.upload(small.rootId, 'deux.bin', data)).status).toBe(200); // same bytes: one stored object147 expect((await small.client.get('/api/v1/auth/state')).body.quota.used).toBe(data.length);148 const dup = (await small.client.get('/api/v1/duplicates')).body.groups;149 expect(dup).toHaveLength(1); expect(dup[0].files).toHaveLength(2);150 // another account uploading the same bytes pays for them and learns nothing151 const other = await signup({ quotaBytes: 1024 * 1024 });152 expect((await other.client.upload(other.rootId, 'copie.bin', data)).status).toBe(200);153 expect((await other.client.get('/api/v1/auth/state')).body.quota.used).toBe(data.length);154 expect((await other.client.get('/api/v1/duplicates')).body.groups).toHaveLength(0);155 const objs = await sql`select owner_id from storage_objects where sha256 = ${sha(data)}`;156 expect(objs).toHaveLength(2);157 });158});159160describe('explorer', () => {161 it('moves, copies, renames; refuses cycles and collisions', async () => {162 const top = await mkdir(a.client, a.rootId, 'Organisation');163 const x = await mkdir(a.client, top.id, 'X'), y = await mkdir(a.client, top.id, 'Y');164 const inner = await mkdir(a.client, x.id, 'Inner');165 const f = (await a.client.upload(x.id, 'note.txt', Buffer.from('note'))).body.file;166 expect((await a.client.post('/api/v1/move', { folderIds: [x.id], destinationId: inner.id })).body.error).toBe('move_into_self');167 expect((await a.client.post('/api/v1/move', { folderIds: [x.id], destinationId: x.id })).body.error).toBe('move_into_self');168 expect((await a.client.post('/api/v1/move', { fileIds: [f.id], destinationId: y.id })).status).toBe(200);169 expect((await ls(a.client, y.id)).files[0].name).toBe('note.txt');170 await a.client.upload(x.id, 'note.txt', Buffer.from('autre'));171 const back = await a.client.post('/api/v1/move', { fileIds: [f.id], destinationId: x.id });172 expect(back.status).toBe(409);173 expect((await a.client.post('/api/v1/move', { fileIds: [f.id], destinationId: x.id, onConflict: 'rename' })).status).toBe(200);174 expect((await ls(a.client, x.id)).files.map((q: any) => q.name).sort()).toEqual(['note (2).txt', 'note.txt']);175 expect((await a.client.patch(`/api/v1/files/${f.id}`, { name: 'note.txt' })).status).toBe(409);176 expect((await a.client.patch(`/api/v1/files/${f.id}`, { name: 'renommé.txt' })).body.name).toBe('renommé.txt');177 const copy = await a.client.post('/api/v1/copy', { folderIds: [x.id], destinationId: y.id });178 expect(copy.body).toEqual({ files: 2, folders: 2 });179 const copied = await ls(a.client, (await ls(a.client, y.id)).folders[0].id);180 expect(copied.files).toHaveLength(2); expect(copied.folders[0].name).toBe('Inner');181 expect((await a.client.post('/api/v1/copy', { folderIds: [x.id], destinationId: x.id })).status).toBe(200); // copy into itself terminates182 expect((await a.client.patch(`/api/v1/folders/${a.rootId}`, { name: 'x' })).status).toBe(400);183 });184 it('trashes, restores and purges whole subtrees; storage is reclaimed', async () => {185 const top = await mkdir(a.client, a.rootId, 'Corbeille-test');186 const sub = await mkdir(a.client, top.id, 'Sous');187 const data = randomBytes(200 * 1024);188 const f = (await a.client.upload(sub.id, 'doc.bin', data)).body.file;189 await a.client.post('/api/v1/trash', { folderIds: [top.id] });190 expect((await a.client.get(`/api/v1/folders/${sub.id}`)).status).toBe(404);191 expect((await a.client.get('/api/v1/search?q=doc.bin')).body.files).toHaveLength(0);192 const trash = (await a.client.get('/api/v1/trash')).body;193 expect(trash.folders.map((x: any) => x.id)).toEqual([top.id]); // one root, not every descendant194 // meanwhile the name is reused195 await mkdir(a.client, a.rootId, 'Corbeille-test');196 await a.client.post('/api/v1/restore', { folderIds: [top.id] });197 const names = (await ls(a.client, a.rootId)).folders.map((x: any) => x.name).filter((n: string) => n.startsWith('Corbeille-test')).sort();198 expect(names).toEqual(['Corbeille-test', 'Corbeille-test (2)']);199 expect((await ls(a.client, sub.id)).files[0].id).toBe(f.id);200 // purge201 await a.client.post('/api/v1/trash', { fileIds: [f.id] });202 const before = (await a.client.get('/api/v1/auth/state')).body.quota.used;203 expect((await a.client.post('/api/v1/purge', { fileIds: [sub.id] })).status).toBe(404); // not a trash root204 await a.client.post('/api/v1/purge', { fileIds: [f.id] });205 const [o] = await sql<{ storage_key: string }[]>`select storage_key from storage_objects where sha256 = ${sha(data)}`;206 await work();207 expect((await a.client.get('/api/v1/auth/state')).body.quota.used).toBe(before - data.length);208 expect(() => statSync(join(config.blobDir, o!.storage_key))).toThrow();209 // restoring an item whose folder is gone falls back to the root210 const g = (await a.client.upload(sub.id, 'orphelin.txt', Buffer.from('o'))).body.file;211 await a.client.post('/api/v1/trash', { fileIds: [g.id] });212 await a.client.post('/api/v1/trash', { folderIds: [sub.id] });213 await a.client.post('/api/v1/purge', { folderIds: [sub.id] });214 await a.client.post('/api/v1/restore', { fileIds: [g.id] });215 expect((await ls(a.client, a.rootId)).files.some((x: any) => x.id === g.id)).toBe(true);216 });217 it('searches by name without accents or case, with filters, tags, favourites and recents', async () => {218 const dir = await mkdir(a.client, a.rootId, 'Recherche');219 const f1 = (await a.client.upload(dir.id, 'Résumé été 2026.pdf', Buffer.from('%PDF-1.4 x'))).body.file;220 await a.client.upload(dir.id, 'budget.xlsx', Buffer.from('xx'));221 await a.client.upload(dir.id, '100%_fini.txt', Buffer.from('t'));222 expect((await a.client.get(`/api/v1/search?q=${encodeURIComponent('resume ETE')}`)).body.files.map((f: any) => f.id)).toEqual([f1.id]);223 expect((await a.client.get('/api/v1/search?q=&kind=sheet')).body.files.map((f: any) => f.name)).toContain('budget.xlsx');224 expect((await a.client.get(`/api/v1/search?q=${encodeURIComponent('100%_')}`)).body.files).toHaveLength(1); // wildcards are literal225 expect((await a.client.get(`/api/v1/search?q=${encodeURIComponent("' or 1=1 --")}`)).body.files).toHaveLength(0);226 const tag = (await a.client.post('/api/v1/tags', { name: 'Impôts', color: 'green' })).body;227 expect((await a.client.post('/api/v1/tags', { name: 'impôts' })).status).toBe(409);228 await a.client.patch(`/api/v1/files/${f1.id}`, { tagIds: [tag.id], starred: true });229 expect((await a.client.get(`/api/v1/search?tag=${tag.id}`)).body.files.map((f: any) => f.id)).toEqual([f1.id]);230 expect((await a.client.get('/api/v1/starred')).body.files.map((f: any) => f.id)).toContain(f1.id);231 expect((await a.client.get('/api/v1/recent')).body.files[0].name).toBe('100%_fini.txt');232 const foreignTag = (await b.client.post('/api/v1/tags', { name: 'Autre' })).body;233 expect((await a.client.patch(`/api/v1/files/${f1.id}`, { tagIds: [foreignTag.id] })).status).toBe(404);234 });235 it('exports a folder as a ZIP with its structure', async () => {236 const dir = await mkdir(a.client, a.rootId, 'Export');237 const sub = await mkdir(a.client, dir.id, 'Sous dossier');238 const one = randomBytes(70000), two = Buffer.from('deux');239 await a.client.upload(dir.id, 'un.bin', one); await a.client.upload(sub.id, 'deux é.txt', two);240 const zip = await a.client.get(`/api/v1/zip?folders=${dir.id}`);241 expect(zip.status).toBe(200); expect(zip.headers['content-type']).toBe('application/zip');242 const path = join('/tmp/sp-drive-test', 'export.zip'); writeFileSync(path, zip.raw);243 const listing = execFileSync('/usr/bin/unzip', ['-Z1', path]).toString().trim().split('\n').sort();244 expect(listing).toEqual(['Export/Sous dossier/deux é.txt', 'Export/un.bin']);245 expect(execFileSync('/usr/bin/unzip', ['-p', path, 'Export/un.bin'], { maxBuffer: 1e8 }).equals(one)).toBe(true);246 });247});248249describe('isolation between accounts (IDOR)', () => {250 it('answers 404 for every route given another account\'s identifiers', async () => {251 const dir = await mkdir(a.client, a.rootId, 'Privé A');252 const f = (await a.client.upload(dir.id, 'privé.txt', Buffer.from('secret de A'))).body.file;253 const detail = (await a.client.get(`/api/v1/files/${f.id}`)).body;254 const up = (await a.client.post('/api/v1/uploads', { folderId: dir.id, name: 'x', size: 5 })).body;255 const tag = (await a.client.post('/api/v1/tags', { name: 'Privé' })).body;256 const mine = (await b.client.upload(b.rootId, 'à-moi.txt', Buffer.from('b'))).body.file;257 const tries: [string, string, unknown?][] = [258 ['GET', `/api/v1/folders/${dir.id}`], ['GET', `/api/v1/folders/${dir.id}/children`], ['PATCH', `/api/v1/folders/${dir.id}`, { name: 'pris' }],259 ['POST', '/api/v1/folders', { parentId: dir.id, name: 'intrus' }], ['GET', `/api/v1/files/${f.id}`], ['PATCH', `/api/v1/files/${f.id}`, { name: 'pris.txt' }],260 ['GET', `/api/v1/files/${f.id}/content`], ['GET', `/api/v1/files/${f.id}/content?download=1`], ['GET', `/api/v1/files/${f.id}/preview/thumb`],261 ['GET', `/api/v1/files/${mine.id}/content?v=${detail.versions[0].id}`], // own file, foreign version id262 ['POST', `/api/v1/files/${mine.id}/versions/${detail.versions[0].id}/restore`], ['GET', `/api/v1/zip?files=${f.id}`], ['GET', `/api/v1/zip?folders=${dir.id}`],263 ['POST', '/api/v1/move', { fileIds: [f.id], destinationId: b.rootId }], ['POST', '/api/v1/move', { fileIds: [mine.id], destinationId: dir.id }],264 ['POST', '/api/v1/copy', { fileIds: [f.id], destinationId: b.rootId }], ['POST', '/api/v1/copy', { folderIds: [dir.id], destinationId: b.rootId }],265 ['POST', '/api/v1/trash', { fileIds: [f.id] }], ['POST', '/api/v1/trash', { folderIds: [dir.id] }], ['POST', '/api/v1/restore', { fileIds: [f.id] }], ['POST', '/api/v1/purge', { fileIds: [f.id] }],266 ['POST', '/api/v1/uploads', { folderId: dir.id, name: 'intrus.txt', size: 1 }], ['POST', '/api/v1/uploads', { folderId: 'root', name: 'v.txt', size: 1, fileId: f.id }],267 ['GET', `/api/v1/uploads/${up.id}`], ['PATCH', `/api/v1/uploads/${up.id}?offset=0`, Buffer.from('12345')], ['POST', `/api/v1/uploads/${up.id}/complete`], ['DELETE', `/api/v1/uploads/${up.id}`],268 ['PATCH', `/api/v1/tags/${tag.id}`, { name: 'pris' }], ['DELETE', `/api/v1/tags/${tag.id}`],269 ['POST', '/api/v1/shares', { fileId: f.id }], ['POST', '/api/v1/shares', { folderId: dir.id }], ['POST', '/api/v1/requests', { folderId: dir.id, title: 'Vol' }],270 ];271 for (const [method, url, body] of tries) {272 const r = await b.client.call(method, url, body);273 expect([method, url, r.status]).toEqual([method, url, 404]);274 expect(JSON.stringify(r.body ?? '')).not.toContain('secret de A');275 }276 // nothing changed for A, nothing appeared for B277 expect((await ls(a.client, dir.id)).files.map((x: any) => x.name)).toEqual(['privé.txt']);278 expect((await a.client.get(`/api/v1/uploads/${up.id}`)).body.received).toBe(0);279 expect((await b.client.get('/api/v1/search?q=priv')).body).toEqual({ folders: [], files: [] });280 expect((await b.client.get('/api/v1/recent')).body.files.every((x: any) => x.id !== f.id)).toBe(true);281 // and anonymous callers get 401 everywhere282 const anon = new Client();283 for (const [method, url, body] of tries) expect([url, (await anon.call(method, url, body)).status]).toEqual([url, 401]);284 });285});286