import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import type { FastifyInstance } from 'fastify'; import { buildApp } from './app.js'; import { loadConfig } from './config.js'; const sk = generateSecretKey(); const pk = getPublicKey(sk); let app: FastifyInstance; let dataDir: string; let cookie: string; function nip98Header(url: string, method: string): string { const event = finalizeEvent( { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', // nonce keeps event ids unique when tests sign several events in one second tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]], }, sk, ); return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; } beforeAll(async () => { dataDir = mkdtempSync(join(tmpdir(), 'podsteadr-test-')); const config = loadConfig({ DATA_DIR: dataDir, PUBLIC_URL: 'http://localhost:8095', NOSTR_RELAYS: '', // no relay publishing in tests } as NodeJS.ProcessEnv); app = await buildApp({ config, dbPath: ':memory:', logger: false }); }); afterAll(async () => { await app.close(); rmSync(dataDir, { recursive: true, force: true }); }); describe('auth', () => { it('rejects unauthenticated /api/auth/me', async () => { const res = await app.inject({ method: 'GET', url: '/api/auth/me' }); expect(res.statusCode).toBe(401); }); it('logs in with a NIP-98 header and sets a session cookie', async () => { const res = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') }, }); expect(res.statusCode).toBe(200); expect(res.json().pubkey).toBe(pk); expect(res.json().isAdmin).toBe(true); // first login claims admin const setCookie = res.headers['set-cookie'] as string; expect(setCookie).toContain('podsteadr_session='); cookie = setCookie.split(';')[0]; }); it('rejects a replayed login header', async () => { const header = nip98Header('http://localhost:8095/api/auth/login', 'POST'); const first = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } }); expect(first.statusCode).toBe(200); const second = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } }); expect(second.statusCode).toBe(401); expect(second.json().error).toMatch(/already used/); }); it('serves /api/auth/me with the session cookie', async () => { const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } }); expect(res.statusCode).toBe(200); expect(res.json().pubkey).toBe(pk); }); }); describe('login allowlist', () => { const sk2 = generateSecretKey(); const pk2 = getPublicKey(sk2); function nip98Header2(url: string, method: string): string { 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)]], }, sk2, ); return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; } it('rejects a non-listed pubkey once the allowlist is enabled, admin still logs in', async () => { const enable = await app.inject({ method: 'PUT', url: '/api/settings', headers: { cookie, 'content-type': 'application/json' }, payload: { login_allowlist_enabled: true, login_allowlist: [] }, }); expect(enable.statusCode).toBe(200); const blocked = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') }, }); expect(blocked.statusCode).toBe(403); const adminStillIn = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') }, }); expect(adminStillIn.statusCode).toBe(200); expect(adminStillIn.json().isAdmin).toBe(true); }); it('allows a pubkey once it is added to the allowlist', async () => { const update = await app.inject({ method: 'PUT', url: '/api/settings', headers: { cookie, 'content-type': 'application/json' }, payload: { login_allowlist: [pk2] }, }); expect(update.statusCode).toBe(200); expect(update.json().login_allowlist).toEqual([pk2]); const res = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') }, }); expect(res.statusCode).toBe(200); expect(res.json().pubkey).toBe(pk2); }); afterAll(async () => { // Leave the allowlist disabled so later describe blocks aren't affected. await app.inject({ method: 'PUT', url: '/api/settings', headers: { cookie, 'content-type': 'application/json' }, payload: { login_allowlist_enabled: false }, }); }); }); describe('podcasts, episodes, feed', () => { let podcastId: string; let episodeId: string; const sha = 'c'.repeat(64); it('creates a podcast', async () => { const res = await app.inject({ method: 'POST', url: '/api/podcasts', headers: { cookie }, payload: { title: 'My Show', description: 'About things', author: 'Tester', lightning_address: 'tester@getalby.com', }, }); expect(res.statusCode).toBe(201); podcastId = res.json().id; expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/); expect(res.json().explicit).toBe(false); // not the raw SQLite 0/1 }); it('lets the edit form round-trip a fetched podcast unmodified (regression: explicit came back as 0/1, not a bool)', async () => { const fetched = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } }); expect(fetched.json().explicit).toBe(false); const { episodes: _episodes, feed_url: _feedUrl, ...editForm } = fetched.json(); const res = await app.inject({ method: 'PUT', url: `/api/podcasts/${podcastId}`, headers: { cookie }, payload: editForm, }); expect(res.statusCode).toBe(200); expect(res.json().lightning_address).toBe('tester@getalby.com'); expect(res.json().explicit).toBe(false); }); it('registers an episode after verifying the blob on blossom', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '1000' } }), ), ); try { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes`, headers: { cookie }, payload: { title: 'Ep 1', sha256: sha, size: 1000, mime: 'video/mp4' }, }); expect(res.statusCode).toBe(201); expect(res.json().enclosure_url).toContain(`${sha}.mp4`); episodeId = res.json().id; } finally { vi.unstubAllGlobals(); } }); it('rejects an episode whose blob size mismatches', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '999' } }), ), ); try { const res = await app.inject({ method: 'POST', url: `/api/podcasts/${podcastId}/episodes`, headers: { cookie }, payload: { title: 'Bad', sha256: 'd'.repeat(64), size: 1000 }, }); expect(res.statusCode).toBe(422); } finally { vi.unstubAllGlobals(); } }); it('serves a valid feed with the lnaddress value block', async () => { const res = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('application/rss+xml'); expect(res.body).toContain('method="lnaddress"'); expect(res.body).toContain('tester@getalby.com'); expect(res.body).toContain(`${sha}.mp4`); const etag = res.headers.etag as string; const cached = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml`, headers: { 'if-none-match': etag }, }); expect(cached.statusCode).toBe(304); }); it('removing an episode from the feed hides it from feed.xml but keeps it in the owner list', async () => { const unlist = await app.inject({ method: 'PUT', url: `/api/podcasts/${podcastId}/episodes/${episodeId}`, headers: { cookie }, payload: { unlisted: true }, }); expect(unlist.statusCode).toBe(200); expect(unlist.json().unlisted).toBe(1); const feed = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` }); expect(feed.body).not.toContain(`${sha}.mp4`); const owned = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } }); expect(owned.json().episodes.some((e: { id: string }) => e.id === episodeId)).toBe(true); const relist = await app.inject({ method: 'PUT', url: `/api/podcasts/${podcastId}/episodes/${episodeId}`, headers: { cookie }, payload: { unlisted: false }, }); expect(relist.statusCode).toBe(200); expect(relist.json().unlisted).toBe(0); const feedAgain = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` }); expect(feedAgain.body).toContain(`${sha}.mp4`); }); }); describe('streams + mediamtx auth webhook', () => { let streamId: string; let streamKey: string; it('creates a stream and returns the key once', async () => { const res = await app.inject({ method: 'POST', url: '/api/streams', headers: { cookie }, payload: { title: 'Live Test', summary: 'hi', hashtags: ['podsteadr'] }, }); expect(res.statusCode).toBe(201); const body = res.json(); streamId = body.id; streamKey = body.whipBearer; expect(body.streamKey).toBe(`${streamId}?key=${streamKey}`); expect(body.hlsUrl).toContain(`/live/${streamId}/index.m3u8`); expect(body.stream_key_hash).toBeUndefined(); // never leak the hash }); it('allows publish with the right key (query form, as OBS sends it)', async () => { const res = await app.inject({ method: 'POST', url: '/api/mediamtx/auth', payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}`, protocol: 'rtmp' }, }); expect(res.statusCode).toBe(200); }); it('allows publish with the key as WHIP bearer password', async () => { const res = await app.inject({ method: 'POST', url: '/api/mediamtx/auth', payload: { action: 'publish', path: `live/${streamId}`, password: streamKey, protocol: 'webrtc' }, }); expect(res.statusCode).toBe(200); }); it('denies publish with a wrong key', async () => { const res = await app.inject({ method: 'POST', url: '/api/mediamtx/auth', payload: { action: 'publish', path: `live/${streamId}`, query: 'key=wrong', protocol: 'rtmp' }, }); expect(res.statusCode).toBe(401); }); it('denies publish to an unknown path', async () => { const res = await app.inject({ method: 'POST', url: '/api/mediamtx/auth', payload: { action: 'publish', path: 'live/nosuchstream', query: 'key=x' }, }); expect(res.statusCode).toBe(401); }); it('allows reads without a key', async () => { const res = await app.inject({ method: 'POST', url: '/api/mediamtx/auth', payload: { action: 'read', path: `live/${streamId}` }, }); expect(res.statusCode).toBe(200); }); it('rotating the key invalidates the old one', async () => { const rot = await app.inject({ method: 'POST', url: `/api/streams/${streamId}/rotate-key`, headers: { cookie }, }); expect(rot.statusCode).toBe(200); const oldKey = await app.inject({ method: 'POST', url: '/api/mediamtx/auth', payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}` }, }); expect(oldKey.statusCode).toBe(401); const newKey = await app.inject({ method: 'POST', url: '/api/mediamtx/auth', payload: { action: 'publish', path: `live/${streamId}`, query: `key=${rot.json().whipBearer}` }, }); expect(newKey.statusCode).toBe(200); }); }); describe('settings', () => { it('exposes public settings without auth', async () => { const res = await app.inject({ method: 'GET', url: '/api/settings/public' }); expect(res.statusCode).toBe(200); expect(res.json().blossomUrl).toBeTruthy(); expect(res.json().serverPubkey).toMatch(/^[0-9a-f]{64}$/); }); it('lets the admin switch to an external blossom server', async () => { const res = await app.inject({ method: 'PUT', url: '/api/settings', headers: { cookie }, payload: { blossom_url: 'https://blossom.example.com' }, }); expect(res.statusCode).toBe(200); expect(res.json().blossom_url).toBe('https://blossom.example.com'); const pub = await app.inject({ method: 'GET', url: '/api/settings/public' }); expect(pub.json().blossomUrl).toBe('https://blossom.example.com'); }); it('blocks settings changes from non-admin users', async () => { const sk2 = generateSecretKey(); const event = finalizeEvent( { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', 'http://localhost:8095/api/auth/login'], ['method', 'POST']], }, sk2, ); const login = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` }, }); expect(login.statusCode).toBe(200); expect(login.json().isAdmin).toBe(false); const cookie2 = (login.headers['set-cookie'] as string).split(';')[0]; const res = await app.inject({ method: 'PUT', url: '/api/settings', headers: { cookie: cookie2 }, payload: { blossom_url: 'https://evil.example.com' }, }); expect(res.statusCode).toBe(403); }); });