import { createServer } from 'node:http'; import { expect, tap } from '@git.zone/tstest/tapbundle'; import { PiHoleClient, PiHoleIntegration, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js'; const snapshot: IPiHoleSnapshot = { online: true, apiVersion: 6, status: 'enabled', statistics: { adsBlocked: 1, adsPercentage: 10, clientsSeen: 1, dnsQueries: 10, domainsBlocked: 100, queriesCached: 2, queriesForwarded: 7, uniqueClients: 1, uniqueDomains: 8, }, versions: { core: {}, web: {}, ftl: {} }, name: 'Pi-hole', }; tap.test('reads Pi-hole v6 local HTTP API and executes disable through HTTP', async () => { const requests: Array<{ method?: string; url?: string; sid?: string; csrf?: string; body?: string }> = []; const server = createServer((requestArg, responseArg) => { let body = ''; requestArg.on('data', (chunkArg) => { body += String(chunkArg); }); requestArg.on('end', () => { requests.push({ method: requestArg.method, url: requestArg.url, sid: requestArg.headers['x-ftl-sid'] as string | undefined, csrf: requestArg.headers['x-ftl-csrf'] as string | undefined, body, }); responseArg.setHeader('content-type', 'application/json'); if (requestArg.method === 'POST' && requestArg.url === '/api/auth') { responseArg.end(JSON.stringify({ session: { valid: true, sid: 'sid-1', csrf: 'csrf-1', validity: 300 } })); return; } if (requestArg.method === 'GET' && requestArg.url === '/api/stats/summary') { responseArg.end(JSON.stringify({ queries: { blocked: 5, percent_blocked: 25, total: 20, cached: 4, forwarded: 10, unique_domains: 12 }, clients: { total: 3, active: 2 }, gravity: { domains_being_blocked: 100000 } })); return; } if (requestArg.method === 'GET' && requestArg.url === '/api/dns/blocking') { responseArg.end(JSON.stringify({ blocking: 'enabled' })); return; } if (requestArg.method === 'GET' && requestArg.url === '/api/info/version') { responseArg.end(JSON.stringify({ version: { core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.0', hash: 'a' } }, web: {}, ftl: {} } })); return; } if (requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking') { responseArg.end(JSON.stringify({ blocking: false, timer: 15 })); return; } responseArg.statusCode = 404; responseArg.end(JSON.stringify({ error: 'not found' })); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); try { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; const runtime = await new PiHoleIntegration().setup({ host: '127.0.0.1', port, apiKey: 'secret', apiVersion: 6, timeoutMs: 1000 }, {}); const entities = await runtime.entities(); const result = await runtime.callService?.({ domain: 'switch', service: 'turn_off', target: { entityId: entities.find((entityArg) => entityArg.platform === 'switch')?.id }, data: { duration: '00:00:15' }, }); const disableRequest = requests.find((requestArg) => requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking'); expect(entities.find((entityArg) => entityArg.id === 'sensor.pi_hole_dns_queries')?.state).toEqual(20); expect(result?.success).toBeTrue(); expect(disableRequest?.sid).toEqual('sid-1'); expect(disableRequest?.csrf).toEqual('csrf-1'); expect(JSON.parse(disableRequest?.body || '{}')).toEqual({ blocking: false, timer: 15 }); await runtime.destroy(); } finally { await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); } }); tap.test('does not report snapshot-only Pi-hole commands as successful', async () => { const runtime = await new PiHoleIntegration().setup({ snapshot }, {}); const switchEntity = (await runtime.entities()).find((entityArg) => entityArg.platform === 'switch'); const result = await runtime.callService?.({ domain: 'switch', service: 'turn_off', target: { entityId: switchEntity?.id }, }); expect(result?.success).toBeFalse(); expect(result?.error).toEqual('Pi-hole live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.'); await runtime.destroy(); }); tap.test('does not fake refresh success without HTTP endpoint or snapshot', async () => { const client = new PiHoleClient({}); const snapshotResult = await client.getSnapshot(); const refreshResult = await client.refresh(); expect(snapshotResult.online).toBeFalse(); expect(refreshResult.success).toBeFalse(); expect(refreshResult.error).toContain('endpoint'); }); export default tap.start();