SPB Git

Repository Public

shares.test.ts 14.0 KB 190 lines typescript

1import { randomBytes } from 'node:crypto';2import { afterAll, beforeAll, describe, expect, it } from 'vitest';3import { sql } from '../src/db.ts';4import { redactUrl } from '../src/server.ts';5import { anon, Client, closeAll, sha, signup } from './harness.ts';67afterAll(closeAll);8let a: Awaited<ReturnType<typeof signup>>, b: Awaited<ReturnType<typeof signup>>;9let docs: string, sub: string, outside: string, f1: any, f2: any, secret: any;10const data1 = randomBytes(300_000);11beforeAll(async () => {12  a = await signup(); b = await signup();13  docs = (await a.client.post('/api/v1/folders', { parentId: a.rootId, name: 'Partagé' })).body.id;14  sub = (await a.client.post('/api/v1/folders', { parentId: docs, name: 'Sous' })).body.id;15  outside = (await a.client.post('/api/v1/folders', { parentId: a.rootId, name: 'Privé' })).body.id;16  f1 = (await a.client.upload(docs, 'contrat.pdf', data1)).body.file;17  f2 = (await a.client.upload(sub, 'notes.txt', Buffer.from('notes partagées'))).body.file;18  secret = (await a.client.upload(outside, 'secret.txt', Buffer.from('jamais partagé'))).body.file;19});20const tokenOf = (url: string) => url.split('/s/')[1]!;21const share = async (body: Record<string, unknown>) => { const r = await a.client.post('/api/v1/shares', body); expect(r.status).toBe(200); return { id: r.body.share.id as string, token: tokenOf(r.body.url) }; };22const open = async (token: string, password?: string) => { const c = anon(); const r = await c.post(`/api/v1/public/shares/${token}/open`, password ? { password } : {}); return { c, r }; };23const P = '/api/v1/public/shares';2425describe('share links', () => {26  it('stores only a hash of the token and never returns the link again', async () => {27    const s = await share({ fileId: f1.id });28    expect(s.token).toMatch(/^[A-Za-z0-9_-]{43}$/);29    const rows = await sql<{ token_hash: string }[]>`select token_hash from shares where id = ${s.id}`;30    expect(rows[0]!.token_hash).not.toContain(s.token);31    expect(JSON.stringify((await a.client.get('/api/v1/shares')).body)).not.toContain(s.token);32    expect(redactUrl(`/api/v1/public/shares/${s.token}/open`)).toBe('/api/v1/public/shares/[redacted]/open');33    expect(redactUrl(`/s/${s.token}`)).toBe('/s/[redacted]'); expect(redactUrl(`/request/${s.token}?x=1`)).toBe('/request/[redacted]?x=1');34  });35  it('serves a shared file to an anonymous visitor only after the link is opened', async () => {36    const s = await share({ fileId: f1.id });37    expect((await anon().get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(401); // no grant yet38    const { c, r } = await open(s.token);39    expect(r.body).toMatchObject({ type: 'file', canDownload: true, file: { name: 'contrat.pdf', size: data1.length } });40    expect(JSON.stringify(r.body)).not.toMatch(/storage_key|wrapped|owner_id|folder_id|sha256/);41    const dl = await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`);42    expect(dl.raw.equals(data1)).toBe(true);43    // the grant cookie is scoped and hardened44    const cookie = [...c.cookies.keys()][0]!;45    expect(cookie.startsWith('spd_g_')).toBe(true);46    // the same link gives access to nothing else, by any identifier47    for (const id of [f2.id, secret.id]) expect((await c.get(`${P}/${s.token}/files/${id}/content`)).status).toBe(404);48    expect((await c.get(`${P}/${s.token}/list`)).status).toBe(404);49    expect((await c.get(`${P}/${s.token}/zip`)).status).toBe(404);50    // and it does not make the visitor a user51    expect((await c.get(`/api/v1/files/${f1.id}/content`)).status).toBe(401);52    expect((await c.get('/api/v1/folders/root')).status).toBe(401);53    const events = (await a.client.get(`/api/v1/shares/${s.id}/events`)).body;54    expect(events.map((e: any) => `${e.kind}:${e.ok}`).sort()).toEqual(['download:true', 'open:true']);55    expect((await b.client.get(`/api/v1/shares/${s.id}/events`)).status).toBe(404);56  });57  it('revocation is immediate, even for a visitor in the middle of a session', async () => {58    const s = await share({ folderId: docs });59    const { c } = await open(s.token);60    expect((await c.get(`${P}/${s.token}/list`)).status).toBe(200);61    expect((await a.client.del(`/api/v1/shares/${s.id}`)).status).toBe(200);62    for (const url of [`${P}/${s.token}/list`, `${P}/${s.token}/files/${f1.id}/content`, `${P}/${s.token}/files/${f1.id}/content?download=1`, `${P}/${s.token}/zip`, `${P}/${s.token}`])63      expect([url, (await c.get(url)).status]).toEqual([url, 404]);64    expect((await open(s.token)).r.status).toBe(404);65  });66  it('confines a folder link to the folder: listing, files, breadcrumbs and ZIP', async () => {67    const s = await share({ folderId: docs });68    const { c, r } = await open(s.token);69    expect(r.body).toMatchObject({ type: 'folder', root: { name: 'Partagé' } });70    const top = (await c.get(`${P}/${s.token}/list`)).body;71    expect(top.files.map((f: any) => f.name)).toEqual(['contrat.pdf']); expect(top.folders.map((f: any) => f.name)).toEqual(['Sous']);72    const inner = (await c.get(`${P}/${s.token}/list?folder=${sub}`)).body;73    expect(inner.breadcrumbs.map((x: any) => x.name)).toEqual(['Partagé', 'Sous']); // never above the shared root74    expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).raw.toString()).toBe('notes partagées');75    // escapes76    expect((await c.get(`${P}/${s.token}/list?folder=${a.rootId}`)).status).toBe(404);77    expect((await c.get(`${P}/${s.token}/list?folder=${outside}`)).status).toBe(404);78    expect((await c.get(`${P}/${s.token}/files/${secret.id}/content`)).status).toBe(404);79    expect((await c.get(`${P}/${s.token}/zip?folder=${a.rootId}`)).status).toBe(404);80    const other = (await b.client.upload(b.rootId, 'b.txt', Buffer.from('b'))).body.file;81    expect((await c.get(`${P}/${s.token}/files/${other.id}/content`)).status).toBe(404);82    const zip = await c.get(`${P}/${s.token}/zip`);83    expect(zip.status).toBe(200); expect(zip.raw.includes(Buffer.from('Sous/notes.txt'))).toBe(true); expect(zip.raw.includes(Buffer.from('secret'))).toBe(false);84    // a file moved out of the shared folder leaves the share at once; trashed content too85    await a.client.post('/api/v1/move', { fileIds: [f2.id], destinationId: outside });86    expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).status).toBe(404);87    await a.client.post('/api/v1/move', { fileIds: [f2.id], destinationId: sub });88    await a.client.post('/api/v1/trash', { folderIds: [sub] });89    expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).status).toBe(404);90    expect((await c.get(`${P}/${s.token}/list?folder=${sub}`)).status).toBe(404);91    await a.client.post('/api/v1/restore', { folderIds: [sub] });92    expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).status).toBe(200);93    // trashing the shared folder itself kills the link until it is restored94    await a.client.post('/api/v1/trash', { folderIds: [docs] });95    expect((await c.get(`${P}/${s.token}/list`)).status).toBe(404);96    await a.client.post('/api/v1/restore', { folderIds: [docs] });97    expect((await c.get(`${P}/${s.token}/list`)).status).toBe(200);98  });99  it('protects with a hashed password and slows down guessing', async () => {100    const s = await share({ fileId: f1.id, password: 'correct horse' });101    const [row] = await sql<{ password_hash: string }[]>`select password_hash from shares where id = ${s.id}`;102    expect(row!.password_hash.startsWith('scrypt$')).toBe(true); expect(row!.password_hash).not.toContain('correct');103    expect((await anon().get(`${P}/${s.token}`)).body).toEqual({ needsPassword: true });104    expect((await open(s.token)).r.body.error).toBe('password_required');105    expect((await open(s.token, 'wrong')).r.body.error).toBe('password_invalid');106    const { c, r } = await open(s.token, 'correct horse');107    expect(r.status).toBe(200);108    expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(200);109    // changing the password invalidates grants already given110    await a.client.patch(`/api/v1/shares/${s.id}`, { password: 'another one' });111    expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(401);112    // brute force: after 20 failures the share refuses even the right password for a while, whatever the address113    await sql`insert into share_access_events (share_id, kind, ok) select ${s.id}, 'password', false from generate_series(1, 19)`;114    const late = anon(); late.ip = '10.7.7.7';115    expect((await late.post(`${P}/${s.token}/open`, { password: 'another one' })).status).toBe(429);116  });117  it('expires, including in the middle of a visit', async () => {118    const s = await share({ fileId: f1.id, expiresAt: new Date(Date.now() + 3600_000).toISOString() });119    const { c } = await open(s.token);120    expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(200);121    await sql`update shares set expires_at = now() - interval '1 second' where id = ${s.id}`;122    expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(404);123    expect((await open(s.token)).r.status).toBe(404);124    expect((await a.client.get('/api/v1/shares')).body.find((x: any) => x.id === s.id).status).toBe('expired');125    expect((await a.client.post('/api/v1/shares', { fileId: f1.id, expiresAt: new Date(Date.now() - 1000).toISOString() })).body.error).toBe('bad_expiry');126  });127  it('counts views and downloads atomically against their limits', async () => {128    const s = await share({ fileId: f1.id, maxViews: 3, maxDownloads: 2 });129    const opens = await Promise.all(Array.from({ length: 8 }, () => open(s.token)));130    expect(opens.filter((o) => o.r.status === 200)).toHaveLength(3);131    const c = opens.find((o) => o.r.status === 200)!.c;132    const dls = await Promise.all(Array.from({ length: 6 }, () => c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)));133    expect(dls.filter((d) => d.status === 200)).toHaveLength(2);134    expect(dls.filter((d) => d.status === 403)).toHaveLength(4);135    const st = (await a.client.get('/api/v1/shares')).body.find((x: any) => x.id === s.id);136    expect(st).toMatchObject({ viewCount: 3, downloadCount: 2, status: 'exhausted' });137    // resuming a download with Range does not burn another download138    const s2 = await share({ fileId: f1.id, maxDownloads: 1 });139    const v = (await open(s2.token)).c;140    expect((await v.get(`${P}/${s2.token}/files/${f1.id}/content?download=1`, { range: 'bytes=0-99' })).status).toBe(206);141    expect((await v.get(`${P}/${s2.token}/files/${f1.id}/content?download=1`, { range: 'bytes=100-199' })).status).toBe(206);142    expect((await v.get(`${P}/${s2.token}/files/${f1.id}/content?download=1`)).status).toBe(403);143  });144  it('single-use links open once', async () => {145    const s = await share({ fileId: f1.id, singleUse: true });146    const first = await open(s.token);147    expect(first.r.status).toBe(200);148    expect((await open(s.token)).r.status).toBe(404);149    expect((await first.c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(200); // the one visitor can still take the file150  });151  it('view-only links never send an attachment nor a format the viewer cannot display', async () => {152    const bin = (await a.client.upload(docs, 'données.bin', randomBytes(100))).body.file;153    const s = await share({ folderId: docs, canDownload: false });154    const { c, r } = await open(s.token);155    expect(r.body.canDownload).toBe(false);156    expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(403);157    expect((await c.get(`${P}/${s.token}/zip`)).status).toBe(403);158    const view = await c.get(`${P}/${s.token}/files/${f1.id}/content`);159    expect(view.status).toBe(200); expect(view.headers['content-disposition']).toContain('inline'); expect(view.headers['content-type']).toBe('application/pdf');160    expect((await c.get(`${P}/${s.token}/files/${bin.id}/content`)).status).toBe(404);161  });162  it('rotating a link kills the old one; a link cannot be made for the root, for foreign items or for infected files', async () => {163    const s = await share({ fileId: f1.id });164    const rotated = await a.client.post(`/api/v1/shares/${s.id}/rotate`);165    expect((await open(s.token)).r.status).toBe(404);166    expect((await open(tokenOf(rotated.body.url))).r.status).toBe(200);167    expect((await a.client.post('/api/v1/shares', { folderId: a.rootId })).status).toBe(400);168    expect((await a.client.post('/api/v1/shares', { fileId: f1.id, folderId: docs })).status).toBe(400);169    expect((await b.client.post(`/api/v1/shares/${s.id}/rotate`)).status).toBe(404);170    expect((await b.client.patch(`/api/v1/shares/${s.id}`, { label: 'x' })).status).toBe(404);171    expect((await b.client.del(`/api/v1/shares/${s.id}`)).status).toBe(404);172    // an infected file: existing links stop, new ones are refused173    const live = await open(tokenOf(rotated.body.url));174    await sql`update storage_objects set scan_status = 'infected' where sha256 = ${sha(data1)}`;175    expect((await live.c.get(`${P}/${tokenOf(rotated.body.url)}/files/${f1.id}/content?download=1`)).status).toBe(404);176    expect((await a.client.post('/api/v1/shares', { fileId: f1.id })).body.error).toBe('infected');177    expect((await a.client.get(`/api/v1/files/${f1.id}/content`)).status).toBe(451);178    expect((await a.client.get(`/api/v1/files/${f1.id}/content?download=1&infected=ack`)).status).toBe(200);179    await sql`update storage_objects set scan_status = 'clean' where sha256 = ${sha(data1)}`;180  });181  it('gives nothing to malformed or guessed tokens and does not log tokens', async () => {182    for (const t of ['x', 'a'.repeat(43), '../../etc/passwd', `${'a'.repeat(42)}%00`]) expect((await anon().get(`${P}/${encodeURIComponent(t)}`)).status).toBe(404);183    const rows = await sql<{ t: string }[]>`select row_to_json(e)::text as t from share_access_events e union all select row_to_json(x)::text from audit_events x`;184    const s = await share({ fileId: f1.id });185    await open(s.token);186    const all = (await sql<{ t: string }[]>`select row_to_json(e)::text as t from share_access_events e union all select row_to_json(x)::text from audit_events x`).map((r) => r.t).join('\n');187    expect(all).not.toContain(s.token); expect(rows.length).toBeGreaterThan(0);188  });189});190
Files main
Menu

Theme