Repository Public
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