Files
podsteadr/scripts/e2e.mjs
T

225 lines
8.7 KiB
JavaScript
Raw Normal View History

#!/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', /<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);
// ---- marketplace: paywalled episode + Cashu purchase (producer side + invoice request only —
// actually paying the invoice and confirming needs a real sat payment, which this script can't
// do headlessly; that step is a manual check, see docs/STATUS.md). Uses a fresh throwaway
// buyer identity so it never collides with the persisted admin/producer one above. ----
const pricedEpisode = await api('POST', `/api/podcasts/${podcastId}/episodes`, {
title: 'E2E Priced Episode',
description: 'Paywalled',
sha256,
size: media.length,
mime: 'video/mp4',
price_sats: 21,
});
ok('priced episode registered', pricedEpisode.status === 201 && pricedEpisode.json.price_sats === 21);
const pricedEpisodeId = pricedEpisode.json.id;
const buyerSk = generateSecretKey();
let buyerCookie = '';
async function apiAsBuyer(method, path, body, headers = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(buyerCookie ? { cookie: buyerCookie } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const setCookie = res.headers.get('set-cookie');
if (setCookie) buyerCookie = setCookie.split(';')[0];
let json = null;
try { json = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, json };
}
function nip98As(signingKey, 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)]],
},
signingKey,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
const buyerLogin = await apiAsBuyer('POST', '/api/auth/login', undefined, {
authorization: nip98As(buyerSk, `${BASE}/api/auth/login`, 'POST'),
});
ok('buyer identity logs in', buyerLogin.status === 200 && buyerLogin.json.isAdmin === false);
const gateBefore = await apiAsBuyer('GET', `/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/download-url`);
ok('download-url is gated before purchase', gateBefore.status === 402, `status ${gateBefore.status}`);
const sources = await fetch(`${BASE}/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/sources`);
const sourcesJson = await sources.json();
ok('sources lists the producer at the right price', sourcesJson.producer?.price_sats === 21);
const purchase = await apiAsBuyer('POST', `/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/purchase`, {
source: 'producer',
});
ok(
'purchase requests a real invoice from the configured Cashu mint',
purchase.status === 201 && purchase.json?.invoice?.startsWith('lnbc'),
purchase.json?.error ?? `invoice ${purchase.json?.invoice?.slice(0, 20)}…`,
);
console.log(
` -> to finish this check by hand: pay ${purchase.json?.invoice}, then ` +
`POST /api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/purchase/${purchase.json?.quoteId}/confirm ` +
`as the buyer and re-check download-url.`,
);
console.log(JSON.stringify({ streamId, streamKey: stream.json.streamKey, podcastId }));
process.exit(failures === 0 ? 0 : 1);