mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-04-18 10:34:06 +00:00
251
src/checks/dns.test.ts
Normal file
251
src/checks/dns.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { DnsCheckRequest } from '../types.js';
|
||||
import { executeDnsCheck } from './dns.js';
|
||||
|
||||
const createCheckRequest = (overrides: Partial<DnsCheckRequest> = {}): DnsCheckRequest => ({
|
||||
name: 'test-dns',
|
||||
type: 'dns',
|
||||
target: 'example.com',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('executeDnsCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns up status on successful DNS resolution', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.name).toBe('test-dns');
|
||||
expect(result.error).toBe('');
|
||||
expect(result.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('uses correct DoH URL with record type', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: 'mx.example.com' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ recordType: 'MX' });
|
||||
await executeDnsCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('type=MX'), expect.any(Object));
|
||||
});
|
||||
|
||||
it('defaults to A record type', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeDnsCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('type=A'), expect.any(Object));
|
||||
});
|
||||
|
||||
it('returns down status when DNS query fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('DNS query failed');
|
||||
});
|
||||
|
||||
it('validates expected values', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('returns down status when expected values do not match', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '1.2.3.4' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Expected 93.184.216.34');
|
||||
});
|
||||
|
||||
it('validates multiple expected values', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Answer: [{ data: '93.184.216.34' }, { data: '93.184.216.35' }],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34', '93.184.216.35'],
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('fails when not all expected values are found', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34', '93.184.216.35'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('retries on failure and eventually succeeds', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('retries on failure and eventually fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Network error');
|
||||
expect(result.attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('handles empty Answer array', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('handles missing Answer field', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 0,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('passes when no expected values specified', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedValues: undefined });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('handles unknown error types', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue('string error');
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('encodes target in URL', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ target: 'sub.example.com' });
|
||||
await executeDnsCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('name=sub.example.com'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('retries on wrong expected values then succeeds', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: 'wrong' }] }),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Answer: [{ data: '93.184.216.34' }] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
expectedValues: ['93.184.216.34'],
|
||||
retries: 2,
|
||||
retryDelayMs: 10,
|
||||
});
|
||||
const result = await executeDnsCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
114
src/checks/dns.ts
Normal file
114
src/checks/dns.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { DnsCheckRequest, CheckResult } from '../types.js';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
type DnsResponse = {
|
||||
Answer?: Array<{ data: string }>;
|
||||
};
|
||||
|
||||
export async function executeDnsCheck(check: DnsCheckRequest): Promise<CheckResult> {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
let lastError = '';
|
||||
|
||||
const recordType = check.recordType ?? 'A';
|
||||
const dohUrl = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(check.target)}&type=${recordType}`;
|
||||
|
||||
for (let i = 0; i <= check.retries; i++) {
|
||||
attempts++;
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, check.timeoutMs);
|
||||
|
||||
const response = await fetch(dohUrl, {
|
||||
headers: { Accept: 'application/dns-json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = `DNS query failed: ${response.status}`;
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
const data: DnsResponse = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
const { expectedValues } = check;
|
||||
if (!expectedValues || expectedValues.length === 0) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: responseTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedValues = data.Answer?.map(a => a.data) ?? [];
|
||||
const allExpectedFound = expectedValues.every(expected => resolvedValues.includes(expected));
|
||||
|
||||
if (allExpectedFound) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: responseTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
lastError = `Expected ${expectedValues.join(', ')}, got ${resolvedValues.join(', ')}`;
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: responseTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
}
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
188
src/checks/http.test.ts
Normal file
188
src/checks/http.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { HttpCheckRequest } from '../types.js';
|
||||
import { executeHttpCheck } from './http.js';
|
||||
|
||||
const createCheckRequest = (overrides: Partial<HttpCheckRequest> = {}): HttpCheckRequest => ({
|
||||
name: 'test-http',
|
||||
type: 'http',
|
||||
target: 'https://example.com',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('executeHttpCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns up status on successful response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.name).toBe('test-http');
|
||||
expect(result.error).toBe('');
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(result.responseTimeMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('returns down status on wrong expected status code', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedStatus: 201 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Expected status 201, got 200');
|
||||
});
|
||||
|
||||
it('matches expected status code when correct', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
} as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedStatus: 201 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
});
|
||||
|
||||
it('retries on failure and eventually fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Network error');
|
||||
expect(result.attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('retries on failure and eventually succeeds', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('uses correct HTTP method', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ method: 'POST' });
|
||||
await executeHttpCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to GET method', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeHttpCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('sends default User-Agent header', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeHttpCheck(check);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'User-Agent': 'atalaya-uptime' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('merges custom headers with defaults', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
headers: { Authorization: 'Bearer token123' },
|
||||
});
|
||||
await executeHttpCheck(check);
|
||||
|
||||
const callHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(callHeaders['User-Agent']).toBe('atalaya-uptime');
|
||||
expect(callHeaders['Authorization']).toBe('Bearer token123');
|
||||
});
|
||||
|
||||
it('allows monitor headers to override defaults', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({
|
||||
headers: { 'User-Agent': 'custom-agent' },
|
||||
});
|
||||
await executeHttpCheck(check);
|
||||
|
||||
const callHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(callHeaders['User-Agent']).toBe('custom-agent');
|
||||
});
|
||||
|
||||
it('handles abort signal timeout', async () => {
|
||||
vi.mocked(fetch).mockImplementation(
|
||||
async () =>
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('The operation was aborted'));
|
||||
}, 100);
|
||||
})
|
||||
);
|
||||
|
||||
const check = createCheckRequest({ timeoutMs: 50, retries: 0 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('retries on wrong status code', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce({ ok: false, status: 500 } as unknown as Response)
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response);
|
||||
|
||||
const check = createCheckRequest({ expectedStatus: 200, retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('handles unknown error types', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue('string error');
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeHttpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
82
src/checks/http.ts
Normal file
82
src/checks/http.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { HttpCheckRequest, CheckResult } from '../types.js';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
const DEFAULT_HEADERS: Record<string, string> = {
|
||||
'User-Agent': 'atalaya-uptime',
|
||||
};
|
||||
|
||||
export async function executeHttpCheck(check: HttpCheckRequest): Promise<CheckResult> {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
let lastError = '';
|
||||
const headers = { ...DEFAULT_HEADERS, ...check.headers };
|
||||
|
||||
for (let i = 0; i <= check.retries; i++) {
|
||||
attempts++;
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, check.timeoutMs);
|
||||
|
||||
const response = await fetch(check.target, {
|
||||
method: check.method ?? 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (check.expectedStatus && response.status !== check.expectedStatus) {
|
||||
lastError = `Expected status ${check.expectedStatus}, got ${response.status}`;
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: responseTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: responseTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
}
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
197
src/checks/tcp.test.ts
Normal file
197
src/checks/tcp.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
import type { TcpCheckRequest } from '../types.js';
|
||||
import { executeTcpCheck } from './tcp.js';
|
||||
|
||||
type Socket = ReturnType<typeof connect>;
|
||||
|
||||
vi.mock('cloudflare:sockets', () => ({
|
||||
connect: vi.fn(),
|
||||
}));
|
||||
|
||||
const createCheckRequest = (overrides: Partial<TcpCheckRequest> = {}): TcpCheckRequest => ({
|
||||
name: 'test-tcp',
|
||||
type: 'tcp',
|
||||
target: 'example.com:443',
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type MockSocket = {
|
||||
opened: Promise<unknown>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createMockSocket(options: { shouldOpen?: boolean; openDelay?: number } = {}): MockSocket {
|
||||
const { shouldOpen = true, openDelay = 0 } = options;
|
||||
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
let mockOpened: Promise<unknown>;
|
||||
if (shouldOpen) {
|
||||
mockOpened = new Promise(resolve => {
|
||||
setTimeout(resolve, openDelay);
|
||||
});
|
||||
} else {
|
||||
mockOpened = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Connection refused'));
|
||||
}, openDelay);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
opened: mockOpened,
|
||||
close: mockClose,
|
||||
};
|
||||
}
|
||||
|
||||
describe('executeTcpCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns down status for invalid target format', async () => {
|
||||
const check = createCheckRequest({ target: 'invalid-target' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid target format');
|
||||
expect(result.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('returns down status for invalid port number (NaN)', async () => {
|
||||
const check = createCheckRequest({ target: 'example.com:abc' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid port number');
|
||||
});
|
||||
|
||||
it('returns down status for port out of range (0)', async () => {
|
||||
const check = createCheckRequest({ target: 'example.com:0' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid port number');
|
||||
});
|
||||
|
||||
it('returns down status for port out of range (65536)', async () => {
|
||||
const check = createCheckRequest({ target: 'example.com:65536' });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Invalid port number');
|
||||
});
|
||||
|
||||
it('returns up status on successful connection', async () => {
|
||||
vi.mocked(connect).mockReturnValue(createMockSocket() as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest();
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.name).toBe('test-tcp');
|
||||
expect(result.error).toBe('');
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(connect).toHaveBeenCalledWith({ hostname: 'example.com', port: 443 });
|
||||
});
|
||||
|
||||
it('returns down status on connection failure', async () => {
|
||||
vi.mocked(connect).mockReturnValue(
|
||||
createMockSocket({ shouldOpen: false }) as unknown as Socket
|
||||
);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('retries on failure and eventually succeeds', async () => {
|
||||
vi.mocked(connect)
|
||||
.mockReturnValueOnce(createMockSocket({ shouldOpen: false }) as unknown as Socket)
|
||||
.mockReturnValueOnce(createMockSocket({ shouldOpen: true }) as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('retries on failure and eventually fails', async () => {
|
||||
vi.mocked(connect).mockReturnValue(
|
||||
createMockSocket({ shouldOpen: false }) as unknown as Socket
|
||||
);
|
||||
|
||||
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('handles connection timeout', async () => {
|
||||
vi.mocked(connect).mockReturnValue({
|
||||
opened: new Promise(() => {
|
||||
// Never resolves
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ timeoutMs: 50, retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toContain('timeout');
|
||||
});
|
||||
|
||||
it('closes socket after successful connection', async () => {
|
||||
const mockSocket = createMockSocket();
|
||||
vi.mocked(connect).mockReturnValue(mockSocket as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest();
|
||||
await executeTcpCheck(check);
|
||||
|
||||
expect(mockSocket.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles socket close error gracefully in finally block', async () => {
|
||||
const mockSocket = {
|
||||
opened: Promise.reject(new Error('Connection failed')),
|
||||
close: vi.fn().mockRejectedValue(new Error('Close error')),
|
||||
};
|
||||
vi.mocked(connect).mockReturnValue(mockSocket as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(mockSocket.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles unknown error types', async () => {
|
||||
vi.mocked(connect).mockReturnValue({
|
||||
opened: Promise.reject(new Error('string error')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ retries: 0 });
|
||||
const result = await executeTcpCheck(check);
|
||||
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('string error');
|
||||
});
|
||||
|
||||
it('parses port correctly', async () => {
|
||||
vi.mocked(connect).mockReturnValue(createMockSocket() as unknown as Socket);
|
||||
|
||||
const check = createCheckRequest({ target: 'db.example.com:5432' });
|
||||
await executeTcpCheck(check);
|
||||
|
||||
expect(connect).toHaveBeenCalledWith({ hostname: 'db.example.com', port: 5432 });
|
||||
});
|
||||
});
|
||||
94
src/checks/tcp.ts
Normal file
94
src/checks/tcp.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
import type { TcpCheckRequest, CheckResult } from '../types.js';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
export async function executeTcpCheck(check: TcpCheckRequest): Promise<CheckResult> {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
let lastError = '';
|
||||
|
||||
const parts = check.target.split(':');
|
||||
if (parts.length !== 2) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'Invalid target format (expected host:port)',
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const [hostname, portString] = parts;
|
||||
const port = Number.parseInt(portString, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port > 65_535) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: 0,
|
||||
error: 'Invalid port number',
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i <= check.retries; i++) {
|
||||
attempts++;
|
||||
let socket: ReturnType<typeof connect> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
socket = connect({ hostname, port });
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('Connection timeout'));
|
||||
}, check.timeoutMs);
|
||||
});
|
||||
|
||||
await Promise.race([socket.opened, timeoutPromise]);
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
await socket.close();
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'up',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: '',
|
||||
attempts,
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (i < check.retries) {
|
||||
await sleep(check.retryDelayMs);
|
||||
}
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
try {
|
||||
await socket.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: check.name,
|
||||
status: 'down',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
error: lastError,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
27
src/checks/utils.test.ts
Normal file
27
src/checks/utils.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
describe('sleep', () => {
|
||||
it('resolves after specified time', async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = sleep(1000);
|
||||
vi.advanceTimersByTime(1000);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits approximately the specified time', async () => {
|
||||
const start = Date.now();
|
||||
await sleep(50);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(40);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('handles zero delay', async () => {
|
||||
const start = Date.now();
|
||||
await sleep(0);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
5
src/checks/utils.ts
Normal file
5
src/checks/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user