#!/usr/bin/env node // End-to-end check against a running `docker compose up` stack. // Exercises: NIP-98 login, settings, podcast creation, BUD-02 blossom upload, // episode registration, RSS feed, stream keys + MediaMTX auth. RTMP publishing // is driven separately (see scripts/e2e.sh) since it needs ffmpeg. // // Usage: node scripts/e2e.mjs (uses nostr-tools from server/node_modules) import { createHash } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { finalizeEvent, generateSecretKey } from '../server/node_modules/nostr-tools/lib/esm/pure.js'; const BASE = process.env.PODSTEADR_URL ?? 'http://localhost:8095'; const BLOSSOM = process.env.BLOSSOM_URL ?? 'http://localhost:8098'; // Persist the test identity across runs — the first pubkey to log in becomes // admin, so reruns must reuse it for the settings test to pass. const KEY_FILE = '/tmp/podsteadr-e2e-key'; let sk; if (existsSync(KEY_FILE)) { sk = Uint8Array.from(Buffer.from(readFileSync(KEY_FILE, 'utf8').trim(), 'hex')); } else { sk = generateSecretKey(); writeFileSync(KEY_FILE, Buffer.from(sk).toString('hex'), { mode: 0o600 }); } let cookie = ''; let failures = 0; function ok(name, cond, extra = '') { const pass = !!cond; console.log(`${pass ? 'ok' : 'NOT OK'} - ${name}${extra ? ` (${extra})` : ''}`); if (!pass) failures++; return pass; } function nip98(url, method) { const event = finalizeEvent( { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]], }, sk, ); return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; } async function api(method, path, body, headers = {}) { const res = await fetch(`${BASE}${path}`, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(cookie ? { cookie } : {}), ...headers, }, body: body ? JSON.stringify(body) : undefined, }); const setCookie = res.headers.get('set-cookie'); if (setCookie) cookie = setCookie.split(';')[0]; let json = null; try { json = await res.json(); } catch { /* non-JSON */ } return { status: res.status, json }; } // ---- login ---- const login = await api('POST', '/api/auth/login', undefined, { authorization: nip98(`${BASE}/api/auth/login`, 'POST'), }); ok('NIP-98 login', login.status === 200 && login.json.pubkey, `pubkey ${login.json?.pubkey?.slice(0, 8)}…`); const badLogin = await api('POST', '/api/auth/login', undefined, { authorization: nip98(`http://evil.example/api/auth/login`, 'POST'), }); ok('login with wrong signed URL rejected', badLogin.status === 401); // ---- settings: silence relays so tests never publish to public nostr relays ---- const settings = await api('PUT', '/api/settings', { relays: [] }); ok('admin can clear relay list', settings.status === 200 && settings.json.relays.length === 0); // ---- podcast ---- const podcast = await api('POST', '/api/podcasts', { title: 'E2E Test Show', description: 'Automated test podcast', author: 'e2e', lightning_address: 'tester@getalby.com', }); ok('podcast created', podcast.status === 201 && podcast.json.id); const podcastId = podcast.json.id; // ---- blossom upload (BUD-02, signed like the browser would) ---- const media = readFileSync(process.env.TEST_MP4 ?? '/tmp/podsteadr-test.mp4'); const sha256 = createHash('sha256').update(media).digest('hex'); const now = Math.floor(Date.now() / 1000); const auth24242 = finalizeEvent( { kind: 24242, created_at: now, content: 'Upload e2e test', tags: [['t', 'upload'], ['x', sha256], ['expiration', String(now + 600)]], }, sk, ); const up = await fetch(`${BLOSSOM}/upload`, { method: 'PUT', headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(auth24242)).toString('base64')}`, 'content-type': 'video/mp4', }, body: media, }); ok('blossom BUD-02 upload', up.ok, `status ${up.status}`); // NOTE: blossom-server 4.x does not support range requests; check plain serving. const head = await fetch(`${BLOSSOM}/${sha256}.mp4`, { method: 'HEAD' }); ok('blossom serves blob by hash.ext', head.ok); // ---- episode registration + feed ---- const episode = await api('POST', `/api/podcasts/${podcastId}/episodes`, { title: 'E2E Episode', description: 'From the e2e script', sha256, size: media.length, mime: 'video/mp4', duration_secs: 5, }); ok('episode registered', episode.status === 201, episode.json?.error); const feed = await fetch(`${BASE}/feeds/${podcastId}/feed.xml`); const xml = await feed.text(); ok('feed served as rss+xml', feed.ok && feed.headers.get('content-type').includes('application/rss+xml')); ok('feed has lnaddress value block', xml.includes('method="lnaddress"') && xml.includes('tester@getalby.com')); ok('feed has enclosure', xml.includes(`${sha256}.mp4`)); ok('feed has podcast:guid', /[0-9a-f-]{36}<\/podcast:guid>/.test(xml)); // ---- stream + mediamtx auth ---- const stream = await api('POST', '/api/streams', { title: 'E2E Live', hashtags: ['test'] }); ok('stream created', stream.status === 201 && stream.json.streamKey); const { id: streamId, whipBearer } = stream.json; const authOk = await api('POST', '/api/mediamtx/auth', { action: 'publish', path: `live/${streamId}`, query: `key=${whipBearer}`, protocol: 'rtmp', }); ok('mediamtx auth accepts right key', authOk.status === 200); const authBad = await api('POST', '/api/mediamtx/auth', { action: 'publish', path: `live/${streamId}`, query: 'key=wrong', protocol: 'rtmp', }); ok('mediamtx auth rejects wrong key', authBad.status === 401); console.log(JSON.stringify({ streamId, streamKey: stream.json.streamKey, podcastId })); process.exit(failures === 0 ? 0 : 1);