SQLite has no boolean type, so raw rows return explicit as 0/1. The podcast edit form round-trips whatever GET /api/podcasts/:id sends it, and the update schema requires z.boolean() — so saving any change without also touching the explicit checkbox failed validation with "Expected boolean, received number". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|