nostr-deploy-server
Version:
Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers
399 lines (323 loc) • 13.8 kB
text/typescript
import { nip19 } from 'nostr-tools';
import { NostrHelper } from '../../helpers/nostr';
import { CacheService } from '../../utils/cache';
// Mock CacheService
jest.mock('../../utils/cache', () => ({
CacheService: {
getRelaysForPubkey: jest.fn(),
setRelaysForPubkey: jest.fn(),
getBlossomServersForPubkey: jest.fn(),
setBlossomServersForPubkey: jest.fn(),
getBlobForPath: jest.fn(),
setBlobForPath: jest.fn(),
setNegativeCache: jest.fn(),
isNegativeCached: jest.fn(),
},
}));
// Mock the nostr-tools module
jest.mock('nostr-tools', () => ({
SimplePool: jest.fn().mockImplementation(() => ({
subscribeMany: jest.fn(),
querySync: jest.fn(),
close: jest.fn(),
})),
nip19: {
decode: jest.fn(),
encode: jest.fn(),
},
}));
describe('NostrHelper', () => {
let nostrHelper: NostrHelper;
const mockDecode = nip19.decode as jest.MockedFunction<typeof nip19.decode>;
const mockedCacheService = CacheService as jest.Mocked<typeof CacheService>;
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
// Reset cache service mocks
mockedCacheService.getRelaysForPubkey.mockResolvedValue(null);
mockedCacheService.getBlossomServersForPubkey.mockResolvedValue(null);
mockedCacheService.getBlobForPath.mockResolvedValue(null);
mockedCacheService.isNegativeCached.mockResolvedValue(false);
nostrHelper = new NostrHelper();
});
afterEach(() => {
nostrHelper.closeAllConnections();
});
describe('resolvePubkey', () => {
it('should resolve valid npub subdomain', () => {
const testPubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const testNpub = 'npub1yf5pr8xfy58058jxde48x4an905wnfzq28m54mex0pvsdcrxqsrq8ppkzc';
mockDecode.mockReturnValue({
type: 'npub',
data: testPubkey,
});
const result = nostrHelper.resolvePubkey(`${testNpub}.test.example.com`);
expect(result.isValid).toBe(true);
expect(result.pubkey).toBe(testPubkey);
expect(result.npub).toBe(testNpub);
expect(result.subdomain).toBe(testNpub);
});
it('should reject invalid npub subdomain', () => {
mockDecode.mockImplementation(() => {
throw new Error('Invalid npub');
});
const result = nostrHelper.resolvePubkey('invalid-npub.test.example.com');
expect(result.isValid).toBe(false);
expect(result.pubkey).toBe('');
});
it('should reject non-npub subdomain', () => {
const result = nostrHelper.resolvePubkey('regular-subdomain.test.example.com');
expect(result.isValid).toBe(false);
expect(result.pubkey).toBe('');
expect(result.subdomain).toBe('regular-subdomain');
});
});
describe('getRelayList', () => {
it('should return default relays when no relay list event found', async () => {
// Mock empty result from queryRelays
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const relays = await nostrHelper.getRelayList(pubkey);
expect(relays).toEqual([
'wss://relay.nostr.band',
'wss://nostrue.com',
'wss://purplerelay.com',
'wss://relay.primal.net',
'wss://nos.lol',
'wss://relay.damus.io',
'wss://relay.nsite.lol',
]);
});
it('should parse relay list from event', async () => {
const mockEvent = {
id: 'test-id',
pubkey: '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5',
created_at: Math.floor(Date.now() / 1000),
kind: 10002,
tags: [
['r', 'wss://custom-relay1.com', 'read'],
['r', 'wss://custom-relay2.com'],
['r', 'wss://custom-relay3.com', 'write'],
],
content: '',
sig: 'test-sig',
};
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([mockEvent]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const relays = await nostrHelper.getRelayList(pubkey);
expect(relays).toEqual(['wss://custom-relay1.com', 'wss://custom-relay2.com']);
});
});
describe('getBlossomServers', () => {
it('should return default servers when no server list event found', async () => {
// Mock relay list
const getRelayListSpy = jest.spyOn(nostrHelper, 'getRelayList');
getRelayListSpy.mockResolvedValue(['wss://relay.damus.io']);
// Mock empty result from queryRelays
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const servers = await nostrHelper.getBlossomServers(pubkey);
expect(servers).toEqual([
'https://cdn.hzrd149.com',
'https://blossom.primal.net',
'https://blossom.band',
'https://loratu.bitcointxoko.com',
'https://blossom.f7z.io',
'https://cdn.sovbit.host',
]);
});
it('should parse server list from event', async () => {
const mockEvent = {
id: 'test-id',
pubkey: '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5',
created_at: Math.floor(Date.now() / 1000),
kind: 10063,
tags: [
['server', 'https://custom-blossom1.com'],
['server', 'https://custom-blossom2.com'],
],
content: '',
sig: 'test-sig',
};
// Mock relay list
const getRelayListSpy = jest.spyOn(nostrHelper, 'getRelayList');
getRelayListSpy.mockResolvedValue(['wss://relay.damus.io']);
// Mock server list result
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([mockEvent]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const servers = await nostrHelper.getBlossomServers(pubkey);
expect(servers).toEqual(['https://custom-blossom1.com', 'https://custom-blossom2.com']);
});
});
describe('getStaticFileMapping', () => {
it('should return SHA256 hash for valid file mapping', async () => {
const testSha256 = '186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99';
const mockEvent = {
id: 'test-id',
pubkey: '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5',
created_at: Math.floor(Date.now() / 1000),
kind: 34128,
tags: [
['d', '/index.html'],
['x', testSha256],
],
content: '',
sig: 'test-sig',
};
// Mock relay list
const getRelayListSpy = jest.spyOn(nostrHelper, 'getRelayList');
getRelayListSpy.mockResolvedValue(['wss://relay.damus.io']);
// Mock file mapping result
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([mockEvent]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const sha256 = await nostrHelper.getStaticFileMapping(pubkey, '/index.html');
expect(sha256).toBe(testSha256);
});
it('should return null when no file mapping found', async () => {
// Mock relay list
const getRelayListSpy = jest.spyOn(nostrHelper, 'getRelayList');
getRelayListSpy.mockResolvedValue(['wss://relay.damus.io']);
// Mock empty result
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const sha256 = await nostrHelper.getStaticFileMapping(pubkey, '/nonexistent.html');
expect(sha256).toBeNull();
});
it('should fallback to /404.html when file not found', async () => {
const test404Sha256 = '404hash123';
const mock404Event = {
id: 'test-id',
pubkey: '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5',
created_at: Math.floor(Date.now() / 1000),
kind: 34128,
tags: [
['d', '/404.html'],
['x', test404Sha256],
],
content: '',
sig: 'test-sig',
};
// Mock relay list
const getRelayListSpy = jest.spyOn(nostrHelper, 'getRelayList');
getRelayListSpy.mockResolvedValue(['wss://relay.damus.io']);
// Mock query results - first call returns empty, second returns 404 event
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy
.mockResolvedValueOnce([]) // First call for /nonexistent.html
.mockResolvedValueOnce([mock404Event]); // Second call for /404.html
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
const sha256 = await nostrHelper.getStaticFileMapping(pubkey, '/nonexistent.html');
expect(sha256).toBe(test404Sha256);
});
});
describe('getStats', () => {
it('should return connection statistics', () => {
const stats = nostrHelper.getStats();
expect(stats).toHaveProperty('activeConnections');
expect(stats).toHaveProperty('connectedRelays');
expect(typeof stats.activeConnections).toBe('number');
expect(Array.isArray(stats.connectedRelays)).toBe(true);
});
});
describe('Connection Pooling', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should track active connections', async () => {
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
// Initially no connections
let stats = nostrHelper.getStats();
expect(stats.activeConnections).toBe(0);
// Make a request that should establish connections
await nostrHelper.getRelayList(pubkey);
// Should have connections now (mocked behavior)
stats = nostrHelper.getStats();
expect(stats).toHaveProperty('activeConnections');
expect(stats).toHaveProperty('connectedRelays');
});
it('should reuse existing connections for subsequent requests', async () => {
const ensureConnectionSpy = jest.spyOn(nostrHelper as any, 'ensureConnection');
ensureConnectionSpy.mockResolvedValue(undefined);
const getActiveRelaysSpy = jest.spyOn(nostrHelper as any, 'getActiveRelays');
getActiveRelaysSpy.mockResolvedValue(['wss://nos.lol']);
const queryRelaysSpy = jest.spyOn(nostrHelper as any, 'queryRelays');
queryRelaysSpy.mockResolvedValue([]);
const pubkey = '266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5';
// First request
await nostrHelper.getRelayList(pubkey);
const firstCallCount = ensureConnectionSpy.mock.calls.length;
// Second request - should reuse connections
await nostrHelper.getRelayList(pubkey);
const secondCallCount = ensureConnectionSpy.mock.calls.length;
// Connection establishment should be called for both requests
// but the second should reuse if within the timeout window
expect(secondCallCount).toBeGreaterThanOrEqual(firstCallCount);
});
it('should cleanup stale connections after timeout', async () => {
const poolCloseSpy = jest.fn();
const mockPool = {
subscribeMany: jest.fn(),
close: poolCloseSpy,
};
// Replace the pool
(nostrHelper as any).pool = mockPool;
// Simulate stale connections
const connections = (nostrHelper as any).connections;
connections.set('wss://stale-relay.com', {
url: 'wss://stale-relay.com',
lastUsed: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
isConnected: true,
});
// Trigger cleanup
const cleanupMethod = (nostrHelper as any).cleanupStaleConnections;
cleanupMethod.call(nostrHelper);
// Should have closed stale connections
expect(poolCloseSpy).toHaveBeenCalledWith(['wss://stale-relay.com']);
expect(connections.has('wss://stale-relay.com')).toBe(false);
});
it('should handle cleanup interval correctly', () => {
// Create a new instance to get a fresh cleanup interval
const freshNostrHelper = new NostrHelper();
const cleanupSpy = jest.spyOn(freshNostrHelper as any, 'cleanupStaleConnections');
// Fast forward time to trigger cleanup
jest.advanceTimersByTime(5 * 60 * 1000 + 1000); // 5 minutes + 1 second
expect(cleanupSpy).toHaveBeenCalled();
// Clean up
freshNostrHelper.closeAllConnections();
});
it('should close all connections on shutdown', () => {
const poolCloseSpy = jest.fn();
const mockPool = {
subscribeMany: jest.fn(),
close: poolCloseSpy,
};
// Replace the pool and add mock connections
(nostrHelper as any).pool = mockPool;
const connections = (nostrHelper as any).connections;
connections.set('wss://relay1.com', {
url: 'wss://relay1.com',
lastUsed: Date.now(),
isConnected: true,
});
connections.set('wss://relay2.com', {
url: 'wss://relay2.com',
lastUsed: Date.now(),
isConnected: true,
});
nostrHelper.closeAllConnections();
expect(poolCloseSpy).toHaveBeenCalledWith(['wss://relay1.com', 'wss://relay2.com']);
expect(connections.size).toBe(0);
});
});
});