Files
podsteadr/scripts/e2e.mjs
ssmithxandClaude Opus 5.5 bc393a3543 feat(blossom): upgrade to blossom-server 6.4.1, drop the nginx gateway
6.x serves Range requests natively, so the interim nginx gateway in
front of 4.4.1 is no longer needed and blossom publishes port 8098
again. 4.x is unmaintained; 6.4.1 also brings stricter auth and path
validation.

- New v6 config: one "*" rule with a 100-year expiration (v6 prunes
  for real and has no "never"), mirror/media/list/landing/dashboard/
  report disabled since podsteadr doesn't use them.
- BLOSSOM_REQUIRE_CONFIG=1 so a missing/invalid config fails startup
  instead of booting on defaults that prune video after a week.
- 6.x migrates a 4.x sqlite.db in place on first start and keeps the
  existing <sha256>.<ext> files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:26:53 +00:00

230 lines
9.0 KiB
JavaScript

#!/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}`);
const head = await fetch(`${BLOSSOM}/${sha256}.mp4`, { method: 'HEAD' });
ok('blossom serves blob by hash.ext', head.ok);
// blossom-server 4.x had no Range support; 6.x does — keep it that way.
const ranged = await fetch(`${BLOSSOM}/${sha256}.mp4`, { headers: { Range: 'bytes=0-9' } });
ok('blossom honours Range requests', ranged.status === 206
&& ranged.headers.get('content-range') === `bytes 0-9/${media.length}`,
`status ${ranged.status}, content-range ${ranged.headers.get('content-range')}`);
// ---- 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);