SPB Git

Repository Public

public.share.spec.ts 14.9 KB 238 lines typescript

1// Share links as an anonymous visitor uses them (Chromium desktop, Chromium phone, WebKit iPhone). The owner is a real2// account driven through the API from a separate Chromium context.3import { type Browser, type BrowserContext, chromium, expect, type Page, test } from '@playwright/test';4import { mkdirSync } from 'node:fs';5import { apiSignUp } from './helpers.ts';67test.use({ locale: 'fr-CA' });8const BASE = process.env.SPD_E2E_BASE ?? 'http://localhost:5183';9const SHOTS = process.env.SPD_SHOTS ? '/tmp/spd-shots/sharing' : '';10// 1×1 PNG11const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');1213let ownerBrowser: Browser, ownerCtx: BrowserContext, owner: Page;14async function call<T = any>(method: string, path: string, body?: unknown): Promise<T> {15  return owner.evaluate(async ([m, p, b]) => {16    const r = await fetch(`/api/v1${p}`, { method: m as string, headers: { 'X-SPD-CSRF': '1', ...(b !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: b !== undefined ? JSON.stringify(b) : m === 'GET' ? undefined : '{}' });17    const j = await r.json().catch(() => null);18    if (!r.ok) throw new Error(`${m} ${p}: ${r.status} ${JSON.stringify(j)}`);19    return j;20  }, [method, path, body] as const);21}22/** Upload one file into a folder of the owner through the real upload endpoints. */23async function upload(folderId: string, name: string, content: Buffer | string): Promise<{ id: string; name: string }> {24  const bytes = [...(typeof content === 'string' ? Buffer.from(content) : content)];25  const r = await owner.evaluate(async ([folder, n, b]) => {26    const h = { 'X-SPD-CSRF': '1' };27    const u = await (await fetch('/api/v1/uploads', { method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, body: JSON.stringify({ folderId: folder, name: n, size: (b as number[]).length }) })).json();28    await fetch(`/api/v1/uploads/${u.id}?offset=0`, { method: 'PATCH', headers: { ...h, 'Content-Type': 'application/octet-stream' }, body: new Uint8Array(b as number[]) });29    const done = await fetch(`/api/v1/uploads/${u.id}/complete`, { method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, body: '{}' });30    if (!done.ok) throw new Error(await done.text());31    return done.json();32  }, [folderId, name, bytes] as const);33  return r.file;34}35let n = 0;36const folder = (name: string, parentId = 'root') => call<{ id: string; name: string }>('POST', '/folders', { parentId, name: parentId === 'root' ? `${name} ${Date.now()}-${++n}` : name });37/** Create a link; returns its id and its public path. */38async function share(target: { fileId?: string; folderId?: string }, settings: Record<string, unknown> = {}): Promise<{ id: string; path: string; token: string }> {39  const r = await call('POST', '/shares', { ...target, ...settings });40  const token = new URL(r.url).pathname.split('/').pop()!;41  return { id: r.share.id, path: `/s/${token}`, token };42}43const shot = async (page: Page, name: string, tag: string) => { if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: `${SHOTS}/share-${name}-${tag}.png`, fullPage: true }); } };44const unavailable = (page: Page) => page.getByRole('heading', { name: 'Ce lien n’est pas disponible' });4546test.beforeAll(async () => {47  ownerBrowser = await chromium.launch();48  ownerCtx = await ownerBrowser.newContext({ baseURL: BASE });49  owner = await ownerCtx.newPage();50  await apiSignUp(ownerCtx, owner);51});52test.afterAll(async () => { await ownerBrowser?.close(); });53// The API limits anonymous calls per address and every browser here comes from 127.0.0.1: give each test its own54// client address (the development proxy is a trusted hop, as the production gateway is).55const clientIp = () => `10.${Math.floor(Math.random() * 250) + 1}.${Math.floor(Math.random() * 250) + 1}.${Math.floor(Math.random() * 250) + 1}`;56test.beforeEach(async ({ context }) => { await context.setExtraHTTPHeaders({ 'X-Forwarded-For': clientIp() }); });5758test('a file link with a password: wrong, then right, then download', async ({ page }, info) => {59  const d = await folder('Partage fichier');60  const f = await upload(d.id, 'Rapport annuel.txt', 'Contenu du rapport.\n');61  const s = await share({ fileId: f.id }, { password: 'mésange-7', label: 'Pour la comptable' });62  await page.goto(s.path);63  await expect(page.getByRole('heading', { name: 'Ce contenu est protégé' })).toBeVisible();64  expect(await page.content()).not.toContain(s.token);65  await shot(page, 'password', info.project.name);66  await page.getByLabel('Mot de passe').fill('faux');67  await page.getByRole('button', { name: 'Ouvrir' }).click();68  await expect(page.getByText('Mot de passe incorrect.')).toBeVisible();69  await expect(page.getByTestId('file-share')).toHaveCount(0);70  await page.getByLabel('Mot de passe').fill('mésange-7');71  await page.getByRole('button', { name: 'Ouvrir' }).click();72  await expect(page.getByRole('heading', { level: 1, name: 'Rapport annuel.txt' })).toBeVisible();73  await expect(page.getByText('Pour la comptable')).toBeVisible();74  await expect(page).toHaveTitle('Pour la comptable');75  await shot(page, 'file', info.project.name);76  const download = page.waitForEvent('download');77  await page.getByRole('button', { name: 'Télécharger', exact: true }).click();78  expect((await download).suggestedFilename()).toBe('Rapport annuel.txt');79  // one successful view, one refused password, one download in the owner's journal80  const events = await call('GET', `/shares/${s.id}/events`);81  expect(events.map((e: { kind: string; ok: boolean }) => `${e.kind}:${e.ok}`).sort()).toEqual(['download:true', 'open:true', 'password:false']);82  // a reload within the hour does not count a second view83  await page.reload();84  await expect(page.getByRole('heading', { level: 1, name: 'Rapport annuel.txt' })).toBeVisible();85  expect((await call('GET', `/shares?fileId=${f.id}`))[0].viewCount).toBe(1);86  // the preview opens in the viewer87  await page.getByRole('button', { name: 'Aperçu', exact: true }).click();88  await expect(page.getByText('Contenu du rapport.')).toBeVisible();89});9091test('a view-only link offers no download and the server refuses one', async ({ page }) => {92  const d = await folder('Lecture seule');93  const f = await upload(d.id, 'photo.png', PNG);94  const s = await share({ fileId: f.id }, { canDownload: false });95  await page.goto(s.path);96  await expect(page.getByRole('heading', { level: 1, name: 'photo.png' })).toBeVisible();97  await expect(page.getByText('Lecture seule', { exact: true })).toBeVisible();98  await expect(page.getByRole('button', { name: /Télécharger/ })).toHaveCount(0);99  await expect(page.getByRole('link', { name: /Télécharger/ })).toHaveCount(0);100  const base = `/api/v1/public/shares/${s.token}/files/${f.id}/content`;101  const refused = await page.request.get(`${base}?download=1`);102  expect(refused.status()).toBe(403);103  expect((await refused.json()).error).toBe('download_forbidden');104  expect((await page.request.get(base)).status()).toBe(200); // viewing inline stays possible105});106107test('a folder link: navigation stays inside the shared folder', async ({ page }, info) => {108  const parent = await folder('Parent privé');109  await upload(parent.id, 'secret.txt', 'ne pas voir');110  const shared = await folder('Dossier client', parent.id);111  const sub = await folder('Factures', shared.id);112  await upload(shared.id, 'Lisez-moi.txt', 'Bienvenue');113  await upload(shared.id, 'logo.png', PNG);114  await upload(sub.id, 'facture-001.txt', 'Total : 100 $');115  const s = await share({ folderId: shared.id });116  await page.goto(s.path);117  await expect(page.getByRole('heading', { level: 1, name: 'Dossier client' })).toBeVisible();118  const entries = page.getByTestId('entries');119  await expect(entries.locator('[data-entry="file"]')).toHaveCount(2);120  await expect(entries.locator('[data-entry="folder"]')).toHaveCount(1);121  await expect(page.getByTestId('crumbs').getByRole('listitem')).toHaveCount(1); // the path starts at the shared folder122  await expect(page.getByText('Parent privé')).toHaveCount(0);123  await shot(page, 'folder', info.project.name);124125  await entries.getByRole('button', { name: 'Factures' }).click();126  await expect(page.getByTestId('crumbs').getByRole('listitem')).toHaveCount(2);127  await expect(page.getByRole('button', { name: 'Ouvrir facture-001.txt' })).toBeVisible();128  await page.getByRole('button', { name: 'Ouvrir facture-001.txt' }).click();129  await expect(page.getByText('Total : 100 $')).toBeVisible();130  await page.keyboard.press('Escape');131  await page.getByTestId('crumbs').getByRole('button', { name: 'Dossier client' }).click();132  await expect(entries.locator('[data-entry="file"]')).toHaveCount(2);133134  // the server refuses the parent, a sibling of the share and any file outside it135  const api = `/api/v1/public/shares/${s.token}`;136  expect((await page.request.get(`${api}/list?folder=${parent.id}`)).status()).toBe(404);137  expect((await page.request.get(`${api}/list?folder=${sub.id}`)).status()).toBe(200);138  expect((await page.request.get(`${api}/zip?folder=${parent.id}`)).status()).toBe(404);139  const outside = (await call('GET', `/folders/${parent.id}`)).files[0];140  expect((await page.request.get(`${api}/files/${outside.id}/content`)).status()).toBe(404);141  // a hand-made address pointing above the share falls back to the shared folder142  await page.goto(`${s.path}#${parent.id}`);143  await expect(page.getByTestId('share-alert')).toContainText('Ce dossier n’existe plus');144  await expect(page.getByTestId('crumbs').getByRole('listitem')).toHaveCount(1);145146  // per-file download and the ZIP of the whole folder147  const one = page.waitForEvent('download');148  await page.getByRole('button', { name: 'Télécharger Lisez-moi.txt' }).click();149  expect((await one).suggestedFilename()).toBe('Lisez-moi.txt');150  const zip = page.waitForEvent('download');151  await page.getByTestId('zip').click();152  expect((await zip).suggestedFilename()).toBe('Dossier client.zip');153});154155test('a link revoked while the page is open: the next action shows the unavailable state', async ({ page }) => {156  const shared = await folder('À révoquer');157  await folder('Sous-dossier', shared.id);158  await upload(shared.id, 'note.txt', 'note');159  const s = await share({ folderId: shared.id });160  await page.goto(s.path);161  await expect(page.getByRole('heading', { level: 1, name: /À révoquer/ })).toBeVisible();162  await call('DELETE', `/shares/${s.id}`);163  await page.getByTestId('entries').getByRole('button', { name: 'Sous-dossier' }).click();164  await expect(unavailable(page)).toBeVisible();165  await expect(page.getByTestId('entries')).toHaveCount(0);166  await page.reload();167  await expect(unavailable(page)).toBeVisible();168169  // same for a file link: opening the preview or downloading after the revocation170  const f = await upload(shared.id, 'autre.txt', 'autre');171  const s2 = await share({ fileId: f.id });172  await page.goto(s2.path);173  await expect(page.getByRole('heading', { level: 1, name: 'autre.txt' })).toBeVisible();174  await call('DELETE', `/shares/${s2.id}`);175  await page.getByRole('button', { name: 'Télécharger', exact: true }).click();176  await expect(unavailable(page)).toBeVisible();177});178179test('a single-use link works once: a second browser gets the unavailable state', async ({ page, browser }, info) => {180  const d = await folder('Usage unique');181  const f = await upload(d.id, 'unique.txt', 'une seule fois');182  const s = await share({ fileId: f.id }, { singleUse: true });183  await page.goto(s.path);184  await expect(page.getByRole('heading', { level: 1, name: 'unique.txt' })).toBeVisible();185  const ctx = await browser.newContext({ ...info.project.use, locale: 'fr-CA', baseURL: BASE, extraHTTPHeaders: { 'X-Forwarded-For': clientIp() } });186  const second = await ctx.newPage();187  await second.goto(s.path);188  await expect(unavailable(second)).toBeVisible();189  await expect(second.getByText('unique.txt')).toHaveCount(0);190  await ctx.close();191  const events = await call('GET', `/shares/${s.id}/events`);192  expect(events.filter((e: { kind: string; ok: boolean }) => e.kind === 'open').map((e: { ok: boolean }) => e.ok).sort()).toEqual([false, true]);193});194195test('the download limit is explained instead of a raw server answer', async ({ page }) => {196  const d = await folder('Limite');197  const f = await upload(d.id, 'limité.txt', 'deux fois non');198  const s = await share({ fileId: f.id }, { maxDownloads: 1 });199  await page.goto(s.path);200  const first = page.waitForEvent('download');201  await page.getByRole('button', { name: 'Télécharger', exact: true }).click();202  await first;203  await page.getByRole('button', { name: 'Télécharger', exact: true }).click();204  await expect(page.getByTestId('share-alert')).toContainText('La limite de téléchargements de ce lien est atteinte');205  await expect(page.getByRole('heading', { level: 1, name: 'limité.txt' })).toBeVisible(); // still on the page, no raw JSON206  expect(page.url()).toContain('/s/');207});208209test('an unknown link shows one neutral state, in French or in English', async ({ page }) => {210  await page.goto(`/s/${'B'.repeat(43)}`);211  await expect(unavailable(page)).toBeVisible();212  await expect(page.locator('a[href]')).toHaveCount(0);213  await page.getByRole('button', { name: 'English' }).click();214  await expect(page.getByRole('heading', { name: 'This link is not available' })).toBeVisible();215  await page.getByRole('button', { name: 'Français' }).click();216});217218test('screenshots (SPD_SHOTS=1): light and dark', async ({ browser }, info) => {219  test.skip(!SHOTS, 'set SPD_SHOTS=1');220  const shared = await folder('Dossier client Tremblay');221  await folder('Factures 2026', shared.id); await folder('Contrats', shared.id);222  await upload(shared.id, 'Lisez-moi.txt', 'Bienvenue'); await upload(shared.id, 'logo.png', PNG); await upload(shared.id, 'Budget.csv', 'poste,montant\nloyer,1200\n');223  const f = await upload(shared.id, 'Rapport annuel 2025.txt', 'Rapport');224  const withPw = await share({ fileId: f.id }, { password: 'abcd', label: 'Rapport pour le conseil' });225  const fileLink = await share({ fileId: f.id }, { label: 'Rapport pour le conseil', expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() });226  const folderLink = await share({ folderId: shared.id });227  for (const scheme of ['light', 'dark'] as const) {228    const ctx = await browser.newContext({ ...info.project.use, colorScheme: scheme, locale: 'fr-CA', baseURL: BASE, extraHTTPHeaders: { 'X-Forwarded-For': clientIp() } });229    const page = await ctx.newPage();230    const tag = `${info.project.name}-${scheme}`;231    await page.goto(withPw.path); await expect(page.getByTestId('gate')).toBeVisible(); await shot(page, 'password', tag);232    await page.goto(fileLink.path); await expect(page.getByTestId('file-share')).toBeVisible(); await page.waitForTimeout(500); await shot(page, 'file', tag);233    await page.goto(folderLink.path); await expect(page.getByTestId('entries')).toBeVisible(); await page.waitForTimeout(800); await shot(page, 'folder', tag);234    await page.goto(`/s/${'C'.repeat(43)}`); await expect(unavailable(page)).toBeVisible(); await shot(page, 'unavailable', tag);235    await ctx.close();236  }237});238
Files main
Menu

Theme