2026-07-10 19:21:03 +00:00
|
|
|
#!/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';
|
|
|
|
|
|
2026-07-25 01:11:42 +00:00
|
|
|
const BASE = process.env.PODSTEADR_URL ?? 'http://localhost:8095';
|
2026-07-10 19:21:03 +00:00
|
|
|
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.
|
2026-07-25 01:11:42 +00:00
|
|
|
const KEY_FILE = '/tmp/podsteadr-e2e-key';
|
2026-07-10 19:21:03 +00:00
|
|
|
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) ----
|
2026-07-25 01:11:42 +00:00
|
|
|
const media = readFileSync(process.env.TEST_MP4 ?? '/tmp/podsteadr-test.mp4');
|
2026-07-10 19:21:03 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-07-29 12:43:46 +00:00
|
|
|
// ---- 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.`,
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-10 19:21:03 +00:00
|
|
|
console.log(JSON.stringify({ streamId, streamKey: stream.json.streamKey, podcastId }));
|
|
|
|
|
process.exit(failures === 0 ? 0 : 1);
|