Add native local NAS and network service integrations
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleConfigFlow, PiHoleHttpMatcher, PiHoleManualMatcher, PiHoleCandidateValidator } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
tap.test('recognizes Pi-hole HTTP and manual discovery candidates', async () => {
|
||||
const httpMatch = await new PiHoleHttpMatcher().matches({
|
||||
url: 'http://192.168.1.2/admin/api.php?summaryRaw',
|
||||
headers: { server: 'lighttpd' },
|
||||
});
|
||||
const manualMatch = await new PiHoleManualMatcher().matches({
|
||||
host: 'pihole.local',
|
||||
name: 'Pi-hole',
|
||||
});
|
||||
const validation = await new PiHoleCandidateValidator().validate(httpMatch.candidate!);
|
||||
|
||||
expect(httpMatch.matched).toBeTrue();
|
||||
expect(httpMatch.candidate?.integrationDomain).toEqual('pi_hole');
|
||||
expect(httpMatch.candidate?.metadata?.apiVersion).toEqual(5);
|
||||
expect(httpMatch.candidate?.metadata?.location).toEqual('admin');
|
||||
expect(manualMatch.matched).toBeTrue();
|
||||
expect(manualMatch.candidate?.port).toEqual(80);
|
||||
expect(validation.matched).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('config flow parses Pi-hole URL and validates credentials shape', async () => {
|
||||
const step = await new PiHoleConfigFlow().start({ source: 'manual', name: 'Pi-hole' }, {});
|
||||
const missingKey = await step.submit!({ host: 'http://192.168.1.2:8080/admin/api.php' });
|
||||
const done = await step.submit!({
|
||||
host: 'http://192.168.1.2:8080/admin/api.php',
|
||||
apiKey: 'secret',
|
||||
apiVersion: '5',
|
||||
verifySsl: false,
|
||||
});
|
||||
|
||||
expect(missingKey.kind).toEqual('error');
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('192.168.1.2');
|
||||
expect(done.config?.port).toEqual(8080);
|
||||
expect(done.config?.location).toEqual('admin');
|
||||
expect(done.config?.apiVersion).toEqual(5);
|
||||
expect(done.config?.apiKey).toEqual('secret');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleMapper, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
const v5Snapshot: IPiHoleSnapshot = PiHoleMapper.toSnapshot({
|
||||
config: {
|
||||
host: '192.168.1.2',
|
||||
port: 80,
|
||||
name: 'Home DNS',
|
||||
apiVersion: 5,
|
||||
rawData: {
|
||||
v5Summary: {
|
||||
status: 'enabled',
|
||||
ads_blocked_today: 42,
|
||||
ads_percentage_today: 21.55,
|
||||
clients_ever_seen: 8,
|
||||
dns_queries_today: 200,
|
||||
domains_being_blocked: 150000,
|
||||
queries_cached: 50,
|
||||
queries_forwarded: 120,
|
||||
unique_clients: 5,
|
||||
unique_domains: 90,
|
||||
},
|
||||
v5Versions: {
|
||||
core_current: 'v5.18.3',
|
||||
core_latest: 'v5.18.4',
|
||||
core_update: true,
|
||||
web_current: 'v5.21',
|
||||
web_latest: 'v5.21',
|
||||
web_update: false,
|
||||
FTL_current: 'v5.25.2',
|
||||
FTL_latest: 'v5.25.2',
|
||||
FTL_update: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
tap.test('maps Pi-hole v5 status, statistics, updates, and switch control', async () => {
|
||||
const devices = PiHoleMapper.toDevices(v5Snapshot);
|
||||
const entities = PiHoleMapper.toEntities(v5Snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('pi_hole.service.192_168_1_2_80');
|
||||
expect(devices[0].online).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.home_dns')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.home_dns_status')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_blocked_today')?.state).toEqual(42);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_percentage_blocked_today')?.state).toEqual(21.6);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_dns_queries_today')?.state).toEqual(200);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.home_dns_core_update_available')?.attributes?.latestVersion).toEqual('v5.18.4');
|
||||
});
|
||||
|
||||
tap.test('models Pi-hole enable, disable, and refresh commands without secrets', async () => {
|
||||
const disableCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.home_dns' },
|
||||
data: { duration: '00:10:00' },
|
||||
});
|
||||
const enableCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'pi_hole',
|
||||
service: 'enable',
|
||||
target: { deviceId: 'pi_hole.service.192_168_1_2_80' },
|
||||
});
|
||||
const refreshCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'pi_hole',
|
||||
service: 'refresh',
|
||||
target: {},
|
||||
});
|
||||
|
||||
expect(Boolean(disableCommand && !('error' in disableCommand))).toBeTrue();
|
||||
if (disableCommand && !('error' in disableCommand)) {
|
||||
expect(disableCommand.type).toEqual('disable');
|
||||
expect(disableCommand.path).toEqual('/admin/api.php');
|
||||
expect(disableCommand.query).toEqual({ disable: 600 });
|
||||
expect(JSON.stringify(disableCommand).includes('auth')).toBeFalse();
|
||||
}
|
||||
expect(Boolean(enableCommand && !('error' in enableCommand))).toBeTrue();
|
||||
if (enableCommand && !('error' in enableCommand)) {
|
||||
expect(enableCommand.query).toEqual({ enable: 'True' });
|
||||
}
|
||||
expect(refreshCommand && !('error' in refreshCommand) ? refreshCommand.type : undefined).toEqual('refresh');
|
||||
});
|
||||
|
||||
tap.test('maps Pi-hole v6 nested summary and version payloads', async () => {
|
||||
const snapshot = PiHoleMapper.toSnapshot({
|
||||
config: {
|
||||
host: 'pihole.local',
|
||||
name: 'Family DNS',
|
||||
apiVersion: 6,
|
||||
rawData: {
|
||||
v6Blocking: { blocking: 'disabled' },
|
||||
v6Summary: {
|
||||
queries: { blocked: 12, percent_blocked: 24.123, total: 50, cached: 8, forwarded: 30, unique_domains: 40 },
|
||||
clients: { total: 6, active: 3 },
|
||||
gravity: { domains_being_blocked: 180000 },
|
||||
},
|
||||
v6Versions: {
|
||||
version: {
|
||||
core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.1', hash: 'b' } },
|
||||
web: { local: { version: 'v6.0', hash: 'c' }, remote: { version: 'v6.0', hash: 'c' } },
|
||||
ftl: { local: { version: 'v6.0', hash: 'd' }, remote: { version: 'v6.0', hash: 'd' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
const entities = PiHoleMapper.toEntities(snapshot);
|
||||
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.family_dns')?.state).toEqual('off');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_blocked')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_percentage_blocked')?.state).toEqual(24.12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_dns_queries')?.state).toEqual(50);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.family_dns_core_update_available')?.state).toEqual('on');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,112 @@
|
||||
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<void>((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<void>((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();
|
||||
Reference in New Issue
Block a user