133 lines
8.5 KiB
JavaScript
133 lines
8.5 KiB
JavaScript
// Actual packaged media stack; synthetic identity/media, no external network,
|
|
// payment or public relay. Never run against installed services.
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { spawn, execFileSync } from 'node:child_process';
|
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
|
const base = 'http://127.0.0.1:8095';
|
|
const key = n => new Uint8Array(32).fill(n);
|
|
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
async function call(path, method = 'GET', cookie, body) {
|
|
return fetch(base + path, { method, signal: AbortSignal.timeout(45000),
|
|
headers: { origin: base, ...(cookie ? { cookie } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
body: body === undefined ? undefined : JSON.stringify(body) });
|
|
}
|
|
async function json(path, method, cookie, body, expected = 200) {
|
|
const r = await call(path, method, cookie, body);
|
|
assert.equal(r.status, expected, `${method || 'GET'} ${path}: ${await r.clone().text()}`);
|
|
return r.json();
|
|
}
|
|
async function login(n) {
|
|
const event = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now()/1000), content: '',
|
|
tags: [['u', base+'/api/auth/login'], ['method', 'POST'], ['nonce', randomUUID()]] }, key(n));
|
|
const r = await fetch(base+'/api/auth/login', { method: 'POST',
|
|
headers: { authorization: 'Nostr '+Buffer.from(JSON.stringify(event)).toString('base64') } });
|
|
assert.equal(r.status, 200);
|
|
assert.equal((await r.json()).pubkey, getPublicKey(key(n)));
|
|
return r.headers.getSetCookie()[0].split(';')[0];
|
|
}
|
|
async function until(check, label, seconds = 20) {
|
|
const end = Date.now() + seconds*1000;
|
|
while (Date.now() < end) { const result = await check(); if (result) return result; await sleep(400); }
|
|
throw new Error(`Timed out: ${label}`);
|
|
}
|
|
function publisher(streamKey, duration) {
|
|
const proc = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-nostdin', '-re',
|
|
'-f', 'lavfi', '-i', 'testsrc2=size=160x120:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=44100',
|
|
'-t', String(duration), '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-threads', '1',
|
|
'-g', '10', '-bf', '0', '-b:v', '100k', '-c:a', 'aac', '-b:a', '32k', '-f', 'flv',
|
|
'rtmp://127.0.0.1:1935/live/'+streamKey], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let errors = '';
|
|
proc.stderr.on('data', b => { errors = (errors+b).slice(-3000); });
|
|
const done = new Promise((resolve, reject) => { proc.once('error', reject); proc.once('exit', (code, signal) => resolve({ code, signal, errors })); });
|
|
return { proc, done };
|
|
}
|
|
const owner = await login(1);
|
|
const outsider = await login(2);
|
|
const phase = process.env.PODSTEADR_MEDIA_PHASE;
|
|
if (phase === 'seed') {
|
|
const podcast = await json('/api/podcasts', 'POST', owner, { title: 'Synthetic recording qualification' }, 201);
|
|
const stream = await json('/api/streams', 'POST', owner, { title: 'Synthetic local test pattern' }, 201);
|
|
const path = '/api/streams/'+stream.id;
|
|
assert.equal((await call(path+'/recordings', 'GET', outsider)).status, 404);
|
|
assert.equal((await call(path+'/recordings/publish', 'POST', outsider, {})).status, 404);
|
|
const rotated = await json(path+'/rotate-key', 'POST', owner);
|
|
// MediaMTX discovers the tracks before its publish-auth callback. A one-second
|
|
// sender can close before discovery and return0 without attempting publish.
|
|
const refused = publisher(stream.streamKey, 8);
|
|
const refusal = await refused.done;
|
|
assert.notEqual(refusal.code, 0, 'Rotated old key must fail at actual RTMP ingest');
|
|
assert.notEqual((await json(path, 'GET', owner)).status, 'live');
|
|
const live = publisher(rotated.streamKey, 22);
|
|
try {
|
|
await until(async () => (await json(path, 'GET', owner)).status === 'live', 'actual poller detects publisher');
|
|
const hls = await until(async () => {
|
|
const r = await fetch(stream.hlsUrl, { signal: AbortSignal.timeout(3000) });
|
|
return r.ok && (await r.text()).includes('#EXTM3U');
|
|
}, 'actual HLS manifest');
|
|
assert.ok(hls);
|
|
// Decode the live HLS, not just a manifest or container-health response.
|
|
execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-nostdin', '-threads', '1',
|
|
'-i', stream.hlsUrl, '-t', '1', '-f', 'null', '-'], { timeout: 15000 });
|
|
const result = await live.done;
|
|
assert.equal(result.code, 0, result.errors);
|
|
} finally { if (live.proc.exitCode === null) live.proc.kill('SIGKILL'); }
|
|
await until(async () => (await json(path, 'GET', owner)).status === 'ended', 'poller detects ended publisher');
|
|
const recordings = await until(async () => {
|
|
const rows = await json(path+'/recordings', 'GET', owner);
|
|
return rows.length ? rows : null;
|
|
}, 'recorded MP4');
|
|
const recording = recordings[0];
|
|
const originalPath = '/recordings/'+recording.path;
|
|
const originalHash = hash(readFileSync(originalPath));
|
|
const request = { file: recording.file, podcast_id: podcast.id, title: 'Actual local recording' };
|
|
assert.equal((await call(path+'/recordings/publish', 'POST', owner, { ...request, file: '../escape.mp4' })).status, 400);
|
|
const otherPodcast = await json('/api/podcasts', 'POST', outsider, { title: 'Other account' }, 201);
|
|
assert.equal((await call(path+'/recordings/publish', 'POST', owner, { ...request, podcast_id: otherPodcast.id })).status, 404);
|
|
const episode = await json(path+'/recordings/publish', 'POST', owner, request, 201);
|
|
assert.equal(episode.source, 'recording');
|
|
assert.equal(episode.price_sats, null);
|
|
assert.ok(episode.duration_secs >= 15);
|
|
assert.equal(hash(readFileSync(originalPath)), originalHash, 'Publishing must preserve original recording');
|
|
const response = await fetch(episode.enclosure_url);
|
|
assert.equal(response.status, 200);
|
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
assert.equal(bytes.length, episode.enclosure_length);
|
|
assert.equal(hash(bytes), episode.sha256, 'Actual Blossom bytes must match episode digest');
|
|
writeFileSync('/data/qualification-recording.mp4', bytes, { mode: 0o600 });
|
|
execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-nostdin', '-threads', '1',
|
|
'-i', '/data/qualification-recording.mp4', '-f', 'null', '-'], { timeout: 15000 });
|
|
const feed = await call('/feeds/'+podcast.id+'/feed.xml');
|
|
assert.equal(feed.status, 200);
|
|
assert.ok((await feed.text()).includes(episode.enclosure_url));
|
|
assert.deepEqual(readdirSync('/data/tmp'), [], 'Remux temporary file cleaned');
|
|
writeFileSync('/data/media-qualification.json', JSON.stringify({ podcast, stream, episode, originalPath, originalHash,
|
|
serverKeyHash: hash(readFileSync('/data/config/server-nostr-key')) }), { mode: 0o600 });
|
|
console.log('PASS actual RTMP rotated-key denial, ingest, polled live/ended, HLS decode, MP4 recording→remux→signed Blossom upload→exact-byte download/decode→RSS; owner/path boundaries and original preservation');
|
|
} else if (phase === 'verify') {
|
|
const saved = JSON.parse(readFileSync('/data/media-qualification.json', 'utf8'));
|
|
assert.equal(hash(readFileSync('/data/config/server-nostr-key')), saved.serverKeyHash);
|
|
assert.equal(hash(readFileSync(saved.originalPath)), saved.originalHash);
|
|
const podcast = await json('/api/podcasts/'+saved.podcast.id, 'GET', owner);
|
|
if (process.env.PODSTEADR_MEDIA_BROWSER === '1') {
|
|
assert.equal(podcast.episodes.length, 2);
|
|
assert.deepEqual(podcast.episodes.find(e => e.id === saved.episode.id), saved.episode);
|
|
const browserEpisode = podcast.episodes.find(e => e.id !== saved.episode.id);
|
|
assert.equal(browserEpisode.title, saved.stream.title+' — recording');
|
|
assert.equal(browserEpisode.price_sats, null);
|
|
const browserBytes = await fetch(browserEpisode.enclosure_url);
|
|
assert.equal(browserBytes.status, 200);
|
|
assert.equal(hash(Buffer.from(await browserBytes.arrayBuffer())), browserEpisode.sha256);
|
|
} else {
|
|
assert.deepEqual(podcast.episodes, [saved.episode]);
|
|
}
|
|
assert.equal((await json('/api/streams/'+saved.stream.id, 'GET', owner)).status, 'ended');
|
|
assert.equal((await call('/api/podcasts/'+saved.podcast.id, 'GET', outsider)).status, 404);
|
|
const r = await fetch(saved.episode.enclosure_url);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(hash(Buffer.from(await r.arrayBuffer())), saved.episode.sha256);
|
|
console.log('PASS recreated Podsteadr/MediaMTX/Blossom stack preserves original recording, episode, identity and exact public enclosure; foreign account denied');
|
|
} else throw new Error('Unknown media qualification phase');
|