Files
podsteadr/server/src/app.test.ts
T
ssmithxandClaude Sonnet 5 db8eb27e5f fix(podcasts): serialize explicit as a real boolean in API responses
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>
2026-09-10 15:42:02 +00:00

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);
});
});