SPB Git

Repository Public

prod-smoke.mts 9.2 KB 112 lines typescript

1// Smoke test against a deployed instance with a throw-away account: sign-up by passkey (virtual authenticator),2// upload, previews through the sandboxed converters, antivirus, share link, anonymous portal deposit.3// Usage: pnpm exec tsx scripts/prod-smoke.mts <base-url> <invite-url>     Prints one line per check; exits 1 on failure.4import { readFileSync } from 'node:fs';5import { chromium, type Page } from '@playwright/test';67const [base, invite] = process.argv.slice(2);8if (!base || !invite) { console.error('usage: prod-smoke.mts <base-url> <invite-url>'); process.exit(2); }9let failed = 0;10const check = (name: string, ok: boolean, detail = '') => { console.log(`${ok ? 'ok  ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); if (!ok) failed++; };11const fx = (n: string) => [...readFileSync(new URL(`../tests/e2e/fixtures/${n}`, import.meta.url))];12const EICAR = [...Buffer.from('X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*')];1314const browser = await chromium.launch();15const ctx = await browser.newContext({ baseURL: base });16await ctx.addInitScript('globalThis.__name = (f) => f'); // tsx keeps function names with a helper the page does not have17const page = await ctx.newPage();18const cdp = await ctx.newCDPSession(page);19await cdp.send('WebAuthn.enable');20await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });21await page.goto('/pwa/favicon.svg');22const call = (p: Page, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) => p.evaluate(async ([m, u, b, h]) => {23  const raw = Array.isArray(b);24  const r = await fetch(`/api/v1${u}`, { method: m as string, headers: { 'X-SPD-CSRF': '1', 'Content-Type': raw ? 'application/octet-stream' : 'application/json', ...(h as Record<string, string>) },25    body: m === 'GET' ? undefined : raw ? new Uint8Array(b as number[]) : JSON.stringify(b ?? {}) });26  const ct = r.headers.get('content-type') ?? '';27  return { status: r.status, body: ct.includes('json') ? await r.json() : (await r.arrayBuffer()).byteLength };28}, [method, path, body, headers] as const);29const upload = async (p: Page, folderId: string, name: string, data: number[]) => {30  const u = await call(p, 'POST', '/uploads', { folderId, name, size: data.length });31  await call(p, 'PATCH', `/uploads/${u.body.id}?offset=0`, data);32  return (await call(p, 'POST', `/uploads/${u.body.id}/complete`)).body.file;33};34const until = async (fn: () => Promise<boolean>, ms = 90_000) => { const t = Date.now(); while (Date.now() - t < ms) { if (await fn()) return true; await new Promise((r) => setTimeout(r, 1500)); } return false; };3536// --- account ---37const token = invite.split('/').pop()!;38const reg = await page.evaluate(async (inv) => {39  const b64 = (b: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');40  const un = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));41  const post = async (path: string, body: unknown) => { const r = await fetch(`/api/v1${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-SPD-CSRF': '1' }, body: JSON.stringify(body) }); return { status: r.status, body: await r.json() }; };42  const o = await post('/auth/register/options', { invite: inv, displayName: 'Smoke test' });43  if (o.status !== 200) return o;44  const pk = o.body.options;45  const cred = (await navigator.credentials.create({ publicKey: { ...pk, challenge: un(pk.challenge), user: { ...pk.user, id: un(pk.user.id) }, excludeCredentials: [] } })) as PublicKeyCredential;46  const r = cred.response as AuthenticatorAttestationResponse;47  return post('/auth/register/verify', { invite: inv, challengeId: o.body.challengeId, label: 'smoke', response: { id: cred.id, rawId: b64(cred.rawId), type: cred.type, clientExtensionResults: {}, response: { clientDataJSON: b64(r.clientDataJSON), attestationObject: b64(r.attestationObject), transports: ['internal'] } } });48}, token);49check('passkey registration on the real origin', reg.status === 200, JSON.stringify(reg.body).slice(0, 120));50const cookies = await ctx.cookies();51const sess = cookies.find((c) => c.name === '__Host-spd');52check('session cookie is __Host-, HttpOnly, Secure, SameSite=Lax', !!sess && sess.httpOnly && sess.secure && sess.sameSite === 'Lax');53const root = (await call(page, 'GET', '/folders/root')).body.folder.id;5455// --- files, previews, antivirus ---56const folder = (await call(page, 'POST', '/folders', { parentId: root, name: 'Smoke' })).body;57const docx = await upload(page, folder.id, 'sample.docx', fx('sample.docx'));58const png = await upload(page, folder.id, 'sample.png', fx('sample.png'));59const heic = await upload(page, folder.id, 'sample.heic', fx('sample.heic'));60const xlsx = await upload(page, folder.id, 'sample.xlsx', fx('sample.xlsx'));61const virus = await upload(page, folder.id, 'eicar.txt', EICAR);62check('uploads stored', [docx, png, heic, xlsx, virus].every((f) => f?.id));63const ready = async (id: string, kind: string) => (await call(page, 'GET', `/files/${id}/preview/${kind}`)).status;64check('thumbnail (sips, sandboxed)', await until(async () => (await ready(png.id, 'thumb')) === 200));65check('HEIC → JPEG', await until(async () => (await ready(heic.id, 'image')) === 200));66await until(async () => [200, 422].includes(await ready(docx.id, 'pdf')), 120_000);67const office = await call(page, 'GET', `/files/${docx.id}/preview/pdf`);68check('DOCX → PDF (LibreOffice, sandboxed)', office.status === 200, office.status === 200 ? '' : `status ${office.status}: ${office.body?.message ?? ''}`);69check('XLSX → sheets (child process, sandboxed)', await until(async () => [200, 422].includes(await ready(xlsx.id, 'sheet'))) && (await ready(xlsx.id, 'sheet')) === 200, `status ${await ready(xlsx.id, 'sheet')}`);70check('antivirus flags EICAR', await until(async () => (await call(page, 'GET', `/files/${virus.id}`)).body.file.scan === 'infected'));71check('clean file marked clean', (await call(page, 'GET', `/files/${png.id}`)).body.file.scan === 'clean');7273// --- share link, anonymous visitor ---74const share = (await call(page, 'POST', '/shares', { fileId: png.id })).body;75const anonCtx = await browser.newContext({ baseURL: base, extraHTTPHeaders: { 'X-Forwarded-For': '203.0.113.77' } });76await anonCtx.addInitScript('globalThis.__name = (f) => f');77const anon = await anonCtx.newPage();78await anon.goto('/pwa/favicon.svg');79const st = new URL(share.url).pathname.split('/').pop()!;80check('share opens for an anonymous visitor', (await call(anon, 'POST', `/public/shares/${st}/open`)).status === 200);81check('shared file downloads', (await call(anon, 'GET', `/public/shares/${st}/files/${png.id}/content?download=1`)).body === fx('sample.png').length);82check('visitor cannot reach the owner API', (await call(anon, 'GET', '/folders/root')).status === 401 && (await call(anon, 'GET', `/files/${docx.id}/content`)).status === 401);83const ev = (await call(page, 'GET', `/shares/${share.share.id}/events`)).body as { ip: string }[];84check('a forged X-Forwarded-For is not taken as the client address', ev.length > 0 && ev.every((e) => e.ip !== '203.0.113.77'), `recorded ${ev[0]?.ip}`);85await call(page, 'DELETE', `/shares/${share.share.id}`);86check('revoked share is dead at once', (await call(anon, 'GET', `/public/shares/${st}/files/${png.id}/content`)).status === 404);8788// --- deposit portal, anonymous ---89const portal = (await call(page, 'POST', '/requests', { folderId: folder.id, title: 'Smoke portal' })).body;90const pt = new URL(portal.url).pathname.split('/').pop()!;91const pdf = fx('sample.pdf');92const sub = (await call(anon, 'POST', `/public/requests/${pt}/submissions`, { name: 'Smoke', files: [{ name: 'ok.pdf', size: pdf.length }, { name: 'virus.pdf', size: EICAR.length }] })).body;93const auth = { Authorization: `Bearer ${sub.submissionToken}` };94await call(anon, 'PATCH', `/public/uploads/${sub.items[0].uploadId}?offset=0`, pdf, auth);95await call(anon, 'PATCH', `/public/uploads/${sub.items[1].uploadId}?offset=0`, EICAR, auth);96await call(anon, 'POST', '/public/submissions/finalize', {}, auth);97let status: any;98await until(async () => { status = (await call(anon, 'GET', '/public/submissions/status', undefined, auth)).body; return status.status !== 'processing'; });99check('portal deposit: clean file accepted, infected file refused', status?.status === 'completed' && status.items[0].status === 'accepted' && status.items[1].reason === 'infected', JSON.stringify(status?.items));100check('depositor token reads nothing', (await call(anon, 'GET', '/folders/root', undefined, auth)).status === 401);101await call(page, 'DELETE', `/requests/${portal.request.id}?purge=1`);102103// --- static shell ---104for (const p of ['/manifest.webmanifest', '/sw.js', '/pwa/icon-512.png', '/s/unknown-link', '/request/unknown-link']) { const r = await page.request.get(p); check(`GET ${p}`, r.status() === 200); }105await page.goto('/');106await page.waitForLoadState('networkidle');107check('application shell renders', await page.locator('#main').isVisible().catch(() => false));108const state = (await call(page, 'GET', '/auth/state')).body;109console.log(`account: ${state.user.email} (${state.user.id})`);110await browser.close();111process.exit(failed ? 1 : 0);112
Files main
Menu

Theme