mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-03-15 04:30:29 +03:00
Merge d59dcc7856
into ccede11e1c
This commit is contained in:
commit
a27e119cd1
12 changed files with 2733 additions and 73 deletions
21
config/vitest.config.js
Normal file
21
config/vitest.config.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [ vue() ],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: [ "./test/component/setup.js" ],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "../src"),
|
||||
},
|
||||
},
|
||||
});
|
1155
package-lock.json
generated
1155
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -25,11 +25,12 @@
|
|||
"start-server-dev": "cross-env NODE_ENV=development node server/server.js",
|
||||
"start-server-dev:watch": "cross-env NODE_ENV=development node --watch server/server.js",
|
||||
"build": "vite build --config ./config/vite.config.js",
|
||||
"test": "npm run test-backend && npm run test-e2e",
|
||||
"test": "npm run test-backend && npm run test-e2e && npm run test-component",
|
||||
"test-with-build": "npm run build && npm test",
|
||||
"test-backend": "cross-env TEST_BACKEND=1 node --test test/backend-test",
|
||||
"test-e2e": "playwright test --config ./config/playwright.config.js",
|
||||
"test-e2e-ui": "playwright test --config ./config/playwright.config.js --ui --ui-port=51063",
|
||||
"test-component": "vitest run --config ./config/vitest.config.js",
|
||||
"playwright-codegen": "playwright codegen localhost:3000 --save-storage=./private/e2e-auth.json",
|
||||
"playwright-show-report": "playwright show-report ./private/playwright-report",
|
||||
"tsc": "tsc",
|
||||
|
@ -152,12 +153,14 @@
|
|||
"@popperjs/core": "~2.10.2",
|
||||
"@testcontainers/hivemq": "^10.13.1",
|
||||
"@testcontainers/rabbitmq": "^10.13.2",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/bootstrap": "~5.1.9",
|
||||
"@types/node": "^20.8.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||
"@typescript-eslint/parser": "^6.7.5",
|
||||
"@vitejs/plugin-vue": "~5.0.1",
|
||||
"@vue/compiler-sfc": "~3.4.2",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vuepic/vue-datepicker": "~3.4.8",
|
||||
"aedes": "^0.46.3",
|
||||
"bootstrap": "5.1.3",
|
||||
|
@ -175,6 +178,7 @@
|
|||
"eslint-plugin-vue": "~8.7.1",
|
||||
"favico.js": "~0.3.10",
|
||||
"get-port-please": "^3.1.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"node-ssh": "~13.1.0",
|
||||
"postcss-html": "~1.5.0",
|
||||
"postcss-rtlcss": "~3.7.2",
|
||||
|
@ -193,6 +197,7 @@
|
|||
"vite": "~5.2.8",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "^7.0.15",
|
||||
"vitest": "^2.1.5",
|
||||
"vue": "~3.4.2",
|
||||
"vue-chartjs": "~5.2.0",
|
||||
"vue-confirm-dialog": "~1.0.2",
|
||||
|
|
|
@ -17,10 +17,45 @@ class DnsMonitorType extends MonitorType {
|
|||
new ConditionVariable("record", defaultStringOperators ),
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate hostname to ensure it's a valid domain without protocol or path
|
||||
* @param {string} hostname Hostname to validate
|
||||
* @returns {boolean} True if hostname is valid
|
||||
*/
|
||||
validateHostname(hostname) {
|
||||
try {
|
||||
// First check if hostname contains protocol or path
|
||||
if (hostname.includes("/") || hostname.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to construct a URL with a dummy protocol
|
||||
const url = new URL(`http://${hostname}`);
|
||||
|
||||
// Ensure there's no path or query parameters
|
||||
if (url.pathname !== "/" || url.search !== "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure the hostname matches the original input
|
||||
// This catches cases where the URL constructor might "fix" invalid hostnames
|
||||
return url.hostname === hostname;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async check(monitor, heartbeat, _server) {
|
||||
// Validate hostname before proceeding
|
||||
if (!this.validateHostname(monitor.hostname)) {
|
||||
heartbeat.msg = "Invalid hostname format";
|
||||
heartbeat.status = DOWN;
|
||||
return;
|
||||
}
|
||||
|
||||
let startTime = dayjs().valueOf();
|
||||
let dnsMessage = "";
|
||||
|
||||
|
|
|
@ -7,6 +7,81 @@ const successMessage = "Sent Successfully.";
|
|||
class FlashDuty extends NotificationProvider {
|
||||
name = "FlashDuty";
|
||||
|
||||
/**
|
||||
* Sanitize and validate a URL string
|
||||
* @param {string} urlStr URL to validate
|
||||
* @returns {string|null} Sanitized URL or null if invalid
|
||||
*/
|
||||
validateURL(urlStr) {
|
||||
try {
|
||||
const url = new URL(urlStr);
|
||||
// Only allow http and https protocols
|
||||
if (![ "http:", "https:" ].includes(url.protocol)) {
|
||||
return null;
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a monitor url from the monitors information
|
||||
* @param {object} monitorInfo Monitor details
|
||||
* @returns {string|undefined} Monitor URL
|
||||
*/
|
||||
genMonitorUrl(monitorInfo) {
|
||||
if (!monitorInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For port type monitors
|
||||
if (monitorInfo.type === "port" && monitorInfo.port) {
|
||||
// Validate port number
|
||||
const port = parseInt(monitorInfo.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Try to construct a valid URL
|
||||
try {
|
||||
// If hostname already includes protocol, use it
|
||||
const hasProtocol = /^[a-zA-Z]+:\/\//.test(monitorInfo.hostname);
|
||||
const urlStr = hasProtocol ?
|
||||
monitorInfo.hostname + ":" + port :
|
||||
"http://" + monitorInfo.hostname + ":" + port;
|
||||
|
||||
const url = new URL(urlStr);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// For hostname-based monitors
|
||||
if (monitorInfo.hostname != null) {
|
||||
try {
|
||||
// If hostname already includes protocol, use it
|
||||
const hasProtocol = /^[a-zA-Z]+:\/\//.test(monitorInfo.hostname);
|
||||
const urlStr = hasProtocol ?
|
||||
monitorInfo.hostname :
|
||||
"http://" + monitorInfo.hostname;
|
||||
|
||||
const url = new URL(urlStr);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// For URL-based monitors
|
||||
if (monitorInfo.url) {
|
||||
return this.validateURL(monitorInfo.url);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
@ -37,21 +112,6 @@ class FlashDuty extends NotificationProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a monitor url from the monitors infomation
|
||||
* @param {object} monitorInfo Monitor details
|
||||
* @returns {string|undefined} Monitor URL
|
||||
*/
|
||||
genMonitorUrl(monitorInfo) {
|
||||
if (monitorInfo.type === "port" && monitorInfo.port) {
|
||||
return monitorInfo.hostname + ":" + monitorInfo.port;
|
||||
}
|
||||
if (monitorInfo.hostname != null) {
|
||||
return monitorInfo.hostname;
|
||||
}
|
||||
return monitorInfo.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the message
|
||||
* @param {BeanModel} notification Message title
|
||||
|
|
493
test/backend-test/test-dns-monitor.js
Normal file
493
test/backend-test/test-dns-monitor.js
Normal file
|
@ -0,0 +1,493 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { DnsMonitorType } = require("../../server/monitor-types/dns");
|
||||
const { UP, DOWN } = require("../../src/util");
|
||||
const dayjs = require("dayjs");
|
||||
|
||||
test("DNSMonitor - Basic Creation Test", async (t) => {
|
||||
const monitor = new DnsMonitorType();
|
||||
assert.ok(monitor, "Should create monitor instance");
|
||||
});
|
||||
|
||||
test("DNSMonitor - Status Test", async (t) => {
|
||||
const monitor = new DnsMonitorType();
|
||||
|
||||
// Test UP status
|
||||
monitor.status = UP;
|
||||
assert.strictEqual(monitor.status, UP, "Should set UP status");
|
||||
|
||||
// Test DOWN status
|
||||
monitor.status = DOWN;
|
||||
assert.strictEqual(monitor.status, DOWN, "Should set DOWN status");
|
||||
});
|
||||
|
||||
test("DNSMonitor - Timestamp Test", async (t) => {
|
||||
const monitor = new DnsMonitorType();
|
||||
const now = dayjs();
|
||||
monitor.timestamp = now;
|
||||
assert.strictEqual(monitor.timestamp.valueOf(), now.valueOf(), "Should set timestamp correctly");
|
||||
});
|
||||
|
||||
test("DNS Monitor - Hostname Validation Test", async (t) => {
|
||||
const monitor = new DnsMonitorType();
|
||||
const testCases = [
|
||||
{
|
||||
hostname: "example.com",
|
||||
valid: true,
|
||||
description: "Simple valid domain"
|
||||
},
|
||||
{
|
||||
hostname: "sub1.sub2.example.com",
|
||||
valid: true,
|
||||
description: "Multiple subdomain levels"
|
||||
},
|
||||
{
|
||||
hostname: "xn--bcher-kva.example", // bücher.example
|
||||
valid: true,
|
||||
description: "Punycode domain"
|
||||
},
|
||||
{
|
||||
hostname: "example.com/path",
|
||||
valid: false,
|
||||
description: "Domain with path"
|
||||
},
|
||||
{
|
||||
hostname: "http://example.com",
|
||||
valid: false,
|
||||
description: "Domain with protocol"
|
||||
},
|
||||
{
|
||||
hostname: "example.com:80",
|
||||
valid: false,
|
||||
description: "Domain with port"
|
||||
},
|
||||
{
|
||||
hostname: "example.com?query=1",
|
||||
valid: false,
|
||||
description: "Domain with query"
|
||||
},
|
||||
{
|
||||
hostname: "example.com#fragment",
|
||||
valid: false,
|
||||
description: "Domain with fragment"
|
||||
},
|
||||
{
|
||||
hostname: "javascript:alert(1)",
|
||||
valid: false,
|
||||
description: "XSS attempt"
|
||||
},
|
||||
{
|
||||
hostname: "data:text/plain;base64,SGVsbG8=",
|
||||
valid: false,
|
||||
description: "Data URL"
|
||||
},
|
||||
{
|
||||
hostname: "file:///etc/passwd",
|
||||
valid: false,
|
||||
description: "File protocol"
|
||||
},
|
||||
{
|
||||
hostname: "localhost",
|
||||
valid: true,
|
||||
description: "Localhost"
|
||||
},
|
||||
{
|
||||
hostname: "-invalid.com",
|
||||
valid: false,
|
||||
description: "Invalid starting character"
|
||||
},
|
||||
{
|
||||
hostname: "example-.com",
|
||||
valid: false,
|
||||
description: "Invalid ending character"
|
||||
},
|
||||
{
|
||||
hostname: "exa mple.com",
|
||||
valid: false,
|
||||
description: "Contains spaces"
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const isValid = monitor.validateHostname(testCase.hostname);
|
||||
assert.strictEqual(isValid, testCase.valid, `${testCase.description}: ${testCase.hostname}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("DNS Monitor - Check Method Test", async (t) => {
|
||||
const monitor = new DnsMonitorType();
|
||||
const testCases = [
|
||||
{
|
||||
config: {
|
||||
hostname: "example.com",
|
||||
dns_resolve_type: "A",
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53
|
||||
},
|
||||
expectSuccess: true,
|
||||
description: "Valid A record lookup"
|
||||
},
|
||||
{
|
||||
config: {
|
||||
hostname: "invalid.hostname.thisdoesnotexist",
|
||||
dns_resolve_type: "A",
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53
|
||||
},
|
||||
expectSuccess: false,
|
||||
description: "Non-existent domain"
|
||||
},
|
||||
{
|
||||
config: {
|
||||
hostname: "example.com",
|
||||
dns_resolve_type: "MX",
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53
|
||||
},
|
||||
expectSuccess: true,
|
||||
description: "MX record lookup"
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const heartbeat = {};
|
||||
try {
|
||||
await monitor.check(testCase.config, heartbeat);
|
||||
if (!testCase.expectSuccess) {
|
||||
assert.fail(`Expected failure for ${testCase.description}`);
|
||||
}
|
||||
if (testCase.expectSuccess) {
|
||||
assert.ok(heartbeat.status === UP || heartbeat.status === DOWN,
|
||||
`Should set heartbeat status for ${testCase.description}`);
|
||||
assert.ok(heartbeat.msg,
|
||||
`Should set heartbeat message for ${testCase.description}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (testCase.expectSuccess) {
|
||||
assert.fail(`Expected success for ${testCase.description}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("DNS Monitor - Condition Evaluation Test", async (t) => {
|
||||
const monitor = new DnsMonitorType();
|
||||
const testCases = [
|
||||
{
|
||||
config: {
|
||||
hostname: "example.com",
|
||||
dns_resolve_type: "A",
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53,
|
||||
condition_expression_group: JSON.stringify({
|
||||
operator: "AND",
|
||||
expressions: [{
|
||||
variable: "record",
|
||||
operator: "contains",
|
||||
value: "93.184.216"
|
||||
}]
|
||||
})
|
||||
},
|
||||
expectUp: true,
|
||||
description: "IP address condition"
|
||||
},
|
||||
{
|
||||
config: {
|
||||
hostname: "example.com",
|
||||
dns_resolve_type: "MX",
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53,
|
||||
condition_expression_group: JSON.stringify({
|
||||
operator: "AND",
|
||||
expressions: [{
|
||||
variable: "record",
|
||||
operator: "contains",
|
||||
value: "aspmx"
|
||||
}]
|
||||
})
|
||||
},
|
||||
expectUp: true,
|
||||
description: "MX record condition"
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const heartbeat = {};
|
||||
try {
|
||||
await monitor.check(testCase.config, heartbeat);
|
||||
assert.strictEqual(heartbeat.status, testCase.expectUp ? UP : DOWN,
|
||||
`${testCase.description}: Expected status ${testCase.expectUp ? "UP" : "DOWN"}`);
|
||||
} catch (error) {
|
||||
assert.fail(`Test failed for ${testCase.description}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("DNS Monitor - Basic A Record Test", async (t) => {
|
||||
const monitor = {
|
||||
hostname: "test1.example.com",
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53,
|
||||
dns_resolve_type: "A",
|
||||
dns_resolve_server_port: 53,
|
||||
maxretries: 1,
|
||||
expected: JSON.stringify([ "93.184.216.34" ]) // example.com IP
|
||||
};
|
||||
|
||||
const dnsMonitor = new DnsMonitorType(monitor);
|
||||
assert.ok(dnsMonitor, "Should create DNS monitor instance");
|
||||
});
|
||||
|
||||
test("DNS Monitor - URL Validation Test", async (t) => {
|
||||
// Test various DNS hostnames
|
||||
const testCases = [
|
||||
{
|
||||
hostname: "test1.example.com",
|
||||
valid: true,
|
||||
description: "Valid domain"
|
||||
},
|
||||
{
|
||||
hostname: "sub.test2.example.com",
|
||||
valid: true,
|
||||
description: "Valid subdomain"
|
||||
},
|
||||
{
|
||||
hostname: "example.com/malicious.com",
|
||||
valid: false,
|
||||
description: "Invalid domain with path"
|
||||
},
|
||||
{
|
||||
hostname: "https://example.com",
|
||||
valid: false,
|
||||
description: "Invalid domain with protocol"
|
||||
},
|
||||
{
|
||||
hostname: "javascript:alert(1)",
|
||||
valid: false,
|
||||
description: "Invalid protocol"
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const monitor = {
|
||||
hostname: testCase.hostname,
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53,
|
||||
dns_resolve_type: "A",
|
||||
dns_resolve_server_port: 53,
|
||||
maxretries: 1
|
||||
};
|
||||
|
||||
const dnsMonitor = new DnsMonitorType(monitor);
|
||||
const isValid = dnsMonitor.validateHostname(testCase.hostname);
|
||||
assert.strictEqual(isValid, testCase.valid,
|
||||
`${testCase.description}: ${testCase.hostname} should be ${testCase.valid ? "valid" : "invalid"}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("DNS Monitor - Resolver Test", async (t) => {
|
||||
const testCases = [
|
||||
{
|
||||
server: "8.8.8.8",
|
||||
port: 53,
|
||||
valid: true,
|
||||
expectUp: true,
|
||||
description: "Google DNS"
|
||||
},
|
||||
{
|
||||
server: "1.1.1.1",
|
||||
port: 53,
|
||||
valid: true,
|
||||
expectUp: true,
|
||||
description: "Cloudflare DNS"
|
||||
},
|
||||
{
|
||||
server: "9.9.9.9",
|
||||
port: 53,
|
||||
valid: true,
|
||||
expectUp: true,
|
||||
description: "Quad9 DNS"
|
||||
},
|
||||
{
|
||||
server: "208.67.222.222",
|
||||
port: 53,
|
||||
valid: true,
|
||||
expectUp: true,
|
||||
description: "OpenDNS"
|
||||
},
|
||||
{
|
||||
server: "malicious.com",
|
||||
port: 53,
|
||||
valid: false,
|
||||
expectUp: false,
|
||||
description: "Invalid DNS server hostname"
|
||||
},
|
||||
{
|
||||
server: "javascript:alert(1)",
|
||||
port: 53,
|
||||
valid: false,
|
||||
expectUp: false,
|
||||
description: "Invalid protocol"
|
||||
},
|
||||
{
|
||||
server: "8.8.8.8",
|
||||
port: 5353,
|
||||
valid: true,
|
||||
expectUp: false,
|
||||
description: "Invalid port"
|
||||
},
|
||||
{
|
||||
server: "192.168.0.1",
|
||||
port: 53,
|
||||
valid: true,
|
||||
expectUp: false,
|
||||
description: "Private IP address"
|
||||
},
|
||||
{
|
||||
server: "256.256.256.256",
|
||||
port: 53,
|
||||
valid: false,
|
||||
expectUp: false,
|
||||
description: "Invalid IP address"
|
||||
}
|
||||
];
|
||||
|
||||
const monitor = new DnsMonitorType();
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const config = {
|
||||
hostname: "example.com",
|
||||
dns_resolve_server: testCase.server,
|
||||
port: testCase.port,
|
||||
dns_resolve_type: "A",
|
||||
dns_resolve_server_port: testCase.port,
|
||||
maxretries: 1
|
||||
};
|
||||
|
||||
// Test hostname validation first
|
||||
const isValidHostname = monitor.validateHostname(config.hostname);
|
||||
assert.ok(isValidHostname, "Monitor hostname should be valid");
|
||||
|
||||
// Test DNS resolver
|
||||
const heartbeat = {};
|
||||
try {
|
||||
await monitor.check(config, heartbeat);
|
||||
|
||||
if (!testCase.valid) {
|
||||
assert.strictEqual(heartbeat.status, DOWN,
|
||||
`${testCase.description}: Should set status to DOWN for invalid DNS server`);
|
||||
} else {
|
||||
assert.ok(heartbeat.status === UP || heartbeat.status === DOWN,
|
||||
`${testCase.description}: Should set valid heartbeat status`);
|
||||
assert.ok(heartbeat.msg,
|
||||
`${testCase.description}: Should set heartbeat message`);
|
||||
|
||||
if (testCase.expectUp) {
|
||||
assert.strictEqual(heartbeat.status, UP,
|
||||
`${testCase.description}: Should be UP for valid DNS server`);
|
||||
} else {
|
||||
assert.strictEqual(heartbeat.status, DOWN,
|
||||
`${testCase.description}: Should be DOWN for problematic DNS server`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (testCase.valid && testCase.expectUp) {
|
||||
assert.fail(`${testCase.description}: Unexpected error - ${error.message}`);
|
||||
} else {
|
||||
assert.ok(error,
|
||||
`${testCase.description}: Should handle error for invalid DNS server`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("DNS Monitor - Record Type Test", async (t) => {
|
||||
const testCases = [
|
||||
{
|
||||
type: "A",
|
||||
expectSuccess: true,
|
||||
description: "A record lookup"
|
||||
},
|
||||
{
|
||||
type: "AAAA",
|
||||
expectSuccess: true,
|
||||
description: "AAAA record lookup"
|
||||
},
|
||||
{
|
||||
type: "MX",
|
||||
expectSuccess: true,
|
||||
description: "MX record lookup"
|
||||
},
|
||||
{
|
||||
type: "TXT",
|
||||
expectSuccess: true,
|
||||
description: "TXT record lookup"
|
||||
},
|
||||
{
|
||||
type: "NS",
|
||||
expectSuccess: true,
|
||||
description: "NS record lookup"
|
||||
},
|
||||
{
|
||||
type: "CNAME",
|
||||
expectSuccess: true,
|
||||
description: "CNAME record lookup"
|
||||
},
|
||||
{
|
||||
type: "SOA",
|
||||
expectSuccess: true,
|
||||
description: "SOA record lookup"
|
||||
},
|
||||
{
|
||||
type: "CAA",
|
||||
expectSuccess: true,
|
||||
description: "CAA record lookup"
|
||||
},
|
||||
{
|
||||
type: "SRV",
|
||||
expectSuccess: true,
|
||||
description: "SRV record lookup"
|
||||
},
|
||||
{
|
||||
type: "INVALID",
|
||||
expectSuccess: false,
|
||||
description: "Invalid record type"
|
||||
}
|
||||
];
|
||||
|
||||
const monitor = new DnsMonitorType();
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const config = {
|
||||
hostname: "example.com",
|
||||
dns_resolve_server: "8.8.8.8",
|
||||
port: 53,
|
||||
dns_resolve_type: testCase.type,
|
||||
dns_resolve_server_port: 53,
|
||||
maxretries: 1
|
||||
};
|
||||
|
||||
const heartbeat = {};
|
||||
try {
|
||||
await monitor.check(config, heartbeat);
|
||||
|
||||
if (!testCase.expectSuccess) {
|
||||
assert.fail(`${testCase.description}: Should fail for invalid record type`);
|
||||
}
|
||||
|
||||
assert.ok(heartbeat.status === UP || heartbeat.status === DOWN,
|
||||
`${testCase.description}: Should set valid heartbeat status`);
|
||||
assert.ok(heartbeat.msg,
|
||||
`${testCase.description}: Should set heartbeat message`);
|
||||
assert.ok(heartbeat.ping > 0,
|
||||
`${testCase.description}: Should measure response time`);
|
||||
} catch (error) {
|
||||
if (testCase.expectSuccess) {
|
||||
assert.fail(`${testCase.description}: Unexpected error - ${error.message}`);
|
||||
} else {
|
||||
assert.ok(error,
|
||||
`${testCase.description}: Should handle error for invalid record type`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
|
@ -3,41 +3,62 @@ const assert = require("node:assert");
|
|||
const { HiveMQContainer } = require("@testcontainers/hivemq");
|
||||
const mqtt = require("mqtt");
|
||||
const { MqttMonitorType } = require("../../server/monitor-types/mqtt");
|
||||
const { UP, PENDING } = require("../../src/util");
|
||||
const { UP, DOWN, PENDING } = require("../../src/util");
|
||||
|
||||
/**
|
||||
* Runs an MQTT test with the
|
||||
* @param {string} mqttSuccessMessage the message that the monitor expects
|
||||
* @param {null|"keyword"|"json-query"} mqttCheckType the type of check we perform
|
||||
* @param {string} receivedMessage what message is recieved from the mqtt channel
|
||||
* @returns {Promise<Heartbeat>} the heartbeat produced by the check
|
||||
* Runs an MQTT test with the given parameters
|
||||
* @param {object} options Test configuration options
|
||||
* @param {string} options.mqttSuccessMessage The message that the monitor expects
|
||||
* @param {null|"keyword"|"json-query"} options.mqttCheckType The type of check to perform
|
||||
* @param {string} options.receivedMessage Message received from the MQTT channel
|
||||
* @param {string} options.jsonPath JSON path for json-query checks
|
||||
* @param {string} options.topic MQTT topic to subscribe to
|
||||
* @param {number} options.interval Monitor check interval
|
||||
* @param {string} options.username MQTT username
|
||||
* @param {string} options.password MQTT password
|
||||
* @returns {Promise<heartbeat>} The heartbeat produced by the check
|
||||
*/
|
||||
async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage) {
|
||||
async function testMqtt({
|
||||
mqttSuccessMessage,
|
||||
mqttCheckType,
|
||||
receivedMessage,
|
||||
jsonPath = "firstProp",
|
||||
topic = "test",
|
||||
interval = 20,
|
||||
username = null,
|
||||
password = null
|
||||
}) {
|
||||
const hiveMQContainer = await new HiveMQContainer().start();
|
||||
const connectionString = hiveMQContainer.getConnectionString();
|
||||
const mqttMonitorType = new MqttMonitorType();
|
||||
|
||||
const monitor = {
|
||||
jsonPath: "firstProp", // always return firstProp for the json-query monitor
|
||||
jsonPath,
|
||||
hostname: connectionString.split(":", 2).join(":"),
|
||||
mqttTopic: "test",
|
||||
mqttTopic: topic,
|
||||
port: connectionString.split(":")[2],
|
||||
mqttUsername: null,
|
||||
mqttPassword: null,
|
||||
interval: 20, // controls the timeout
|
||||
mqttSuccessMessage: mqttSuccessMessage, // for keywords
|
||||
expectedValue: mqttSuccessMessage, // for json-query
|
||||
mqttCheckType: mqttCheckType,
|
||||
mqttUsername: username,
|
||||
mqttPassword: password,
|
||||
interval,
|
||||
mqttSuccessMessage,
|
||||
expectedValue: mqttSuccessMessage,
|
||||
mqttCheckType,
|
||||
};
|
||||
|
||||
const heartbeat = {
|
||||
msg: "",
|
||||
status: PENDING,
|
||||
};
|
||||
|
||||
const testMqttClient = mqtt.connect(hiveMQContainer.getConnectionString());
|
||||
const testMqttClient = mqtt.connect(hiveMQContainer.getConnectionString(), {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
testMqttClient.on("connect", () => {
|
||||
testMqttClient.subscribe("test", (error) => {
|
||||
testMqttClient.subscribe(topic, (error) => {
|
||||
if (!error) {
|
||||
testMqttClient.publish("test", receivedMessage);
|
||||
testMqttClient.publish(topic, receivedMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -46,7 +67,7 @@ async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage) {
|
|||
await mqttMonitorType.check(monitor, heartbeat, {});
|
||||
} finally {
|
||||
testMqttClient.end();
|
||||
hiveMQContainer.stop();
|
||||
await hiveMQContainer.stop();
|
||||
}
|
||||
return heartbeat;
|
||||
}
|
||||
|
@ -55,48 +76,171 @@ describe("MqttMonitorType", {
|
|||
concurrency: true,
|
||||
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64")
|
||||
}, () => {
|
||||
test("valid keywords (type=default)", async () => {
|
||||
const heartbeat = await testMqtt("KEYWORD", null, "-> KEYWORD <-");
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.strictEqual(heartbeat.msg, "Topic: test; Message: -> KEYWORD <-");
|
||||
describe("Keyword Matching Tests", () => {
|
||||
test("should match exact keyword (type=default)", async () => {
|
||||
const heartbeat = await testMqtt({
|
||||
mqttSuccessMessage: "KEYWORD",
|
||||
mqttCheckType: null,
|
||||
receivedMessage: "KEYWORD"
|
||||
});
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.strictEqual(heartbeat.msg, "Topic: test; Message: KEYWORD");
|
||||
});
|
||||
|
||||
test("should match keyword within message (type=default)", async () => {
|
||||
const heartbeat = await testMqtt({
|
||||
mqttSuccessMessage: "KEYWORD",
|
||||
mqttCheckType: null,
|
||||
receivedMessage: "-> KEYWORD <-"
|
||||
});
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.strictEqual(heartbeat.msg, "Topic: test; Message: -> KEYWORD <-");
|
||||
});
|
||||
|
||||
test("should fail on missing keyword (type=default)", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt({
|
||||
mqttSuccessMessage: "NOT_PRESENT",
|
||||
mqttCheckType: null,
|
||||
receivedMessage: "-> KEYWORD <-"
|
||||
}),
|
||||
new Error("Message Mismatch - Topic: test; Message: -> KEYWORD <-")
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle special characters in keyword", async () => {
|
||||
const heartbeat = await testMqtt({
|
||||
mqttSuccessMessage: "特殊文字",
|
||||
mqttCheckType: "keyword",
|
||||
receivedMessage: "Message: 特殊文字"
|
||||
});
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
});
|
||||
});
|
||||
|
||||
test("valid keywords (type=keyword)", async () => {
|
||||
const heartbeat = await testMqtt("KEYWORD", "keyword", "-> KEYWORD <-");
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.strictEqual(heartbeat.msg, "Topic: test; Message: -> KEYWORD <-");
|
||||
});
|
||||
test("invalid keywords (type=default)", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt("NOT_PRESENT", null, "-> KEYWORD <-"),
|
||||
new Error("Message Mismatch - Topic: test; Message: -> KEYWORD <-"),
|
||||
);
|
||||
describe("JSON Query Tests", () => {
|
||||
test("should match simple JSON value", async () => {
|
||||
const heartbeat = await testMqtt({
|
||||
mqttSuccessMessage: "present",
|
||||
mqttCheckType: "json-query",
|
||||
receivedMessage: "{\"firstProp\":\"present\"}"
|
||||
});
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.strictEqual(heartbeat.msg, "Message received, expected value is found");
|
||||
});
|
||||
|
||||
test("should handle nested JSON paths", async () => {
|
||||
const heartbeat = await testMqtt({
|
||||
mqttSuccessMessage: "nested-value",
|
||||
mqttCheckType: "json-query",
|
||||
receivedMessage: "{\"parent\":{\"firstProp\":\"nested-value\"}}",
|
||||
jsonPath: "parent.firstProp"
|
||||
});
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
});
|
||||
|
||||
test("should fail on missing JSON path", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt({
|
||||
mqttSuccessMessage: "value",
|
||||
mqttCheckType: "json-query",
|
||||
receivedMessage: "{}",
|
||||
jsonPath: "nonexistent"
|
||||
}),
|
||||
/Message received but value is not equal to expected value/
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail on invalid JSON", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt({
|
||||
mqttSuccessMessage: "value",
|
||||
mqttCheckType: "json-query",
|
||||
receivedMessage: "invalid-json"
|
||||
}),
|
||||
/Unexpected token/
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle array values", async () => {
|
||||
const heartbeat = await testMqtt({
|
||||
mqttSuccessMessage: "item2",
|
||||
mqttCheckType: "json-query",
|
||||
receivedMessage: "{\"firstProp\":[\"item1\",\"item2\",\"item3\"]}",
|
||||
jsonPath: "firstProp[1]"
|
||||
});
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid keyword (type=keyword)", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt("NOT_PRESENT", "keyword", "-> KEYWORD <-"),
|
||||
new Error("Message Mismatch - Topic: test; Message: -> KEYWORD <-"),
|
||||
);
|
||||
describe("Authentication Tests", () => {
|
||||
test("should handle successful authentication", async () => {
|
||||
const heartbeat = await testMqtt({
|
||||
mqttSuccessMessage: "auth-success",
|
||||
mqttCheckType: "keyword",
|
||||
receivedMessage: "auth-success",
|
||||
username: "testuser",
|
||||
password: "testpass"
|
||||
});
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
});
|
||||
|
||||
test("should handle failed authentication", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt({
|
||||
mqttSuccessMessage: "irrelevant",
|
||||
mqttCheckType: "keyword",
|
||||
receivedMessage: "irrelevant",
|
||||
username: "invalid",
|
||||
password: "invalid"
|
||||
}),
|
||||
/Authentication failed/
|
||||
);
|
||||
});
|
||||
});
|
||||
test("valid json-query", async () => {
|
||||
// works because the monitors' jsonPath is hard-coded to "firstProp"
|
||||
const heartbeat = await testMqtt("present", "json-query", "{\"firstProp\":\"present\"}");
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.strictEqual(heartbeat.msg, "Message received, expected value is found");
|
||||
});
|
||||
test("invalid (because query fails) json-query", async () => {
|
||||
// works because the monitors' jsonPath is hard-coded to "firstProp"
|
||||
await assert.rejects(
|
||||
testMqtt("[not_relevant]", "json-query", "{}"),
|
||||
new Error("Message received but value is not equal to expected value, value was: [undefined]"),
|
||||
);
|
||||
});
|
||||
test("invalid (because successMessage fails) json-query", async () => {
|
||||
// works because the monitors' jsonPath is hard-coded to "firstProp"
|
||||
await assert.rejects(
|
||||
testMqtt("[wrong_success_messsage]", "json-query", "{\"firstProp\":\"present\"}"),
|
||||
new Error("Message received but value is not equal to expected value, value was: [present]")
|
||||
);
|
||||
|
||||
describe("Error Handling Tests", () => {
|
||||
test("should handle connection timeout", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt({
|
||||
mqttSuccessMessage: "timeout",
|
||||
mqttCheckType: "keyword",
|
||||
receivedMessage: "timeout",
|
||||
interval: 1
|
||||
}),
|
||||
/Timeout/
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle invalid topic format", async () => {
|
||||
await assert.rejects(
|
||||
testMqtt({
|
||||
mqttSuccessMessage: "invalid",
|
||||
mqttCheckType: "keyword",
|
||||
receivedMessage: "invalid",
|
||||
topic: "invalid/#/topic"
|
||||
}),
|
||||
/Invalid topic/
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle disconnection", async () => {
|
||||
const hiveMQContainer = await new HiveMQContainer().start();
|
||||
const heartbeat = { status: PENDING,
|
||||
msg: "" };
|
||||
const monitor = new MqttMonitorType();
|
||||
|
||||
try {
|
||||
await hiveMQContainer.stop();
|
||||
await monitor.check({
|
||||
hostname: hiveMQContainer.getConnectionString().split(":")[0],
|
||||
port: hiveMQContainer.getConnectionString().split(":")[2],
|
||||
mqttTopic: "test"
|
||||
}, heartbeat, {});
|
||||
assert.fail("Should have thrown an error");
|
||||
} catch (error) {
|
||||
assert.ok(error.message.includes("connect"));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
415
test/backend-test/test-notification.js
Normal file
415
test/backend-test/test-notification.js
Normal file
|
@ -0,0 +1,415 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Notification } = require("../../server/notification");
|
||||
const { UP, DOWN } = require("../../src/util");
|
||||
|
||||
test("Notification - Basic Creation Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
assert.ok(notification, "Should create notification instance");
|
||||
assert.ok(typeof notification.send === "function", "Should have send method");
|
||||
});
|
||||
|
||||
test("Notification - Format Message Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
|
||||
const monitor = {
|
||||
name: "Test Monitor",
|
||||
hostname: "test.mydomain.com",
|
||||
type: "http",
|
||||
url: "https://test.mydomain.com/status"
|
||||
};
|
||||
|
||||
const msg = {
|
||||
type: "down",
|
||||
monitor,
|
||||
msg: "Connection failed"
|
||||
};
|
||||
|
||||
const formatted = notification.format(msg);
|
||||
assert.ok(formatted.includes("Test Monitor"), "Should include monitor name");
|
||||
assert.ok(formatted.includes("https://test.mydomain.com"), "Should include full URL");
|
||||
assert.ok(formatted.includes("Connection failed"), "Should include error message");
|
||||
|
||||
// Test with potentially malicious URLs
|
||||
const maliciousMonitor = {
|
||||
name: "Test Monitor",
|
||||
hostname: "https://malicious.mydomain.com/test.mydomain.com",
|
||||
type: "http",
|
||||
url: "https://evil.mydomain.com/redirect/https://test.mydomain.com"
|
||||
};
|
||||
|
||||
const maliciousMsg = {
|
||||
type: "down",
|
||||
monitor: maliciousMonitor,
|
||||
msg: "Connection failed"
|
||||
};
|
||||
|
||||
const maliciousFormatted = notification.format(maliciousMsg);
|
||||
assert.ok(!maliciousFormatted.includes("test.mydomain.com"), "Should not include test.mydomain.com as substring");
|
||||
assert.ok(maliciousFormatted.includes("https://malicious.mydomain.com"), "Should include exact malicious URL");
|
||||
});
|
||||
|
||||
test("Notification - Status Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
|
||||
// Test UP status with secure URL
|
||||
const upMsg = {
|
||||
type: "up",
|
||||
monitor: {
|
||||
name: "Test1",
|
||||
url: "https://test1.mydomain.com",
|
||||
type: "http"
|
||||
},
|
||||
msg: "Service is up",
|
||||
status: UP
|
||||
};
|
||||
const upFormatted = notification.format(upMsg);
|
||||
assert.ok(upFormatted.includes("up"), "Should indicate UP status");
|
||||
assert.ok(upFormatted.includes("https://test1.mydomain.com"), "Should include complete URL");
|
||||
|
||||
// Test DOWN status with secure URL
|
||||
const downMsg = {
|
||||
type: "down",
|
||||
monitor: {
|
||||
name: "Test2",
|
||||
url: "https://test2.mydomain.com",
|
||||
type: "http"
|
||||
},
|
||||
msg: "Service is down",
|
||||
status: DOWN
|
||||
};
|
||||
const downFormatted = notification.format(downMsg);
|
||||
assert.ok(downFormatted.includes("down"), "Should indicate DOWN status");
|
||||
assert.ok(downFormatted.includes("https://test2.mydomain.com"), "Should include complete URL");
|
||||
});
|
||||
|
||||
test("Notification - Queue Management Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
|
||||
// Add items to queue with secure URLs
|
||||
notification.add({
|
||||
type: "down",
|
||||
monitor: {
|
||||
name: "Test1",
|
||||
url: "https://test1.mydomain.com",
|
||||
type: "http"
|
||||
},
|
||||
msg: "Error 1"
|
||||
});
|
||||
|
||||
notification.add({
|
||||
type: "up",
|
||||
monitor: {
|
||||
name: "Test2",
|
||||
url: "https://test2.mydomain.com",
|
||||
type: "http"
|
||||
},
|
||||
msg: "Recovered"
|
||||
});
|
||||
|
||||
assert.strictEqual(notification.queue.length, 2, "Queue should have 2 items");
|
||||
});
|
||||
|
||||
test("Notification - URL Validation and Sanitization Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
|
||||
// Test with various URL formats and edge cases
|
||||
const testCases = [
|
||||
// Valid URLs
|
||||
{
|
||||
url: "https://test.mydomain.com",
|
||||
valid: true,
|
||||
description: "Basic HTTPS URL",
|
||||
expectedOutput: "https://test.mydomain.com"
|
||||
},
|
||||
{
|
||||
url: "http://sub.test.mydomain.com",
|
||||
valid: true,
|
||||
description: "Subdomain URL",
|
||||
expectedOutput: "http://sub.test.mydomain.com"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/path",
|
||||
valid: true,
|
||||
description: "URL with path",
|
||||
expectedOutput: "https://test.mydomain.com/path"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com:8080",
|
||||
valid: true,
|
||||
description: "URL with port",
|
||||
expectedOutput: "https://test.mydomain.com:8080"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/path?query=1",
|
||||
valid: true,
|
||||
description: "URL with query parameters",
|
||||
expectedOutput: "https://test.mydomain.com/path?query=1"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/path#fragment",
|
||||
valid: true,
|
||||
description: "URL with fragment",
|
||||
expectedOutput: "https://test.mydomain.com/path#fragment"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/special%20chars",
|
||||
valid: true,
|
||||
description: "URL with encoded characters",
|
||||
expectedOutput: "https://test.mydomain.com/special%20chars"
|
||||
},
|
||||
|
||||
// Potentially malicious URLs
|
||||
{
|
||||
url: "javascript:alert(1)",
|
||||
valid: false,
|
||||
description: "JavaScript protocol",
|
||||
expectedOutput: ""
|
||||
},
|
||||
{
|
||||
url: "data:text/html,<script>alert(1)</script>",
|
||||
valid: false,
|
||||
description: "Data URL",
|
||||
expectedOutput: ""
|
||||
},
|
||||
{
|
||||
url: "file:///etc/passwd",
|
||||
valid: false,
|
||||
description: "File protocol",
|
||||
expectedOutput: ""
|
||||
},
|
||||
{
|
||||
url: "https://malicious.com?redirect=https://test.mydomain.com",
|
||||
valid: true,
|
||||
description: "URL with redirect parameter",
|
||||
expectedOutput: "https://malicious.com?redirect=https://test.mydomain.com"
|
||||
},
|
||||
{
|
||||
url: "https://malicious.com/https://test.mydomain.com",
|
||||
valid: true,
|
||||
description: "URL with embedded URL in path",
|
||||
expectedOutput: "https://malicious.com/https://test.mydomain.com"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com@malicious.com",
|
||||
valid: false,
|
||||
description: "URL with @ character",
|
||||
expectedOutput: ""
|
||||
},
|
||||
{
|
||||
url: "https://malicious.com\\@test.mydomain.com",
|
||||
valid: false,
|
||||
description: "URL with escaped @ character",
|
||||
expectedOutput: ""
|
||||
},
|
||||
{
|
||||
url: "https:\\\\test.mydomain.com",
|
||||
valid: false,
|
||||
description: "URL with backslashes",
|
||||
expectedOutput: ""
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/path/<script>alert(1)</script>",
|
||||
valid: true,
|
||||
description: "URL with XSS in path",
|
||||
expectedOutput: "https://test.mydomain.com/path/<script>alert(1)</script>"
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
url: "https://test.mydomain.com//double//slashes",
|
||||
valid: true,
|
||||
description: "URL with double slashes",
|
||||
expectedOutput: "https://test.mydomain.com//double//slashes"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/./path/../test",
|
||||
valid: true,
|
||||
description: "URL with dot segments",
|
||||
expectedOutput: "https://test.mydomain.com/./path/../test"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/%2e%2e%2f",
|
||||
valid: true,
|
||||
description: "URL with encoded dot segments",
|
||||
expectedOutput: "https://test.mydomain.com/%2e%2e%2f"
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/\u0000",
|
||||
valid: false,
|
||||
description: "URL with null byte",
|
||||
expectedOutput: ""
|
||||
},
|
||||
{
|
||||
url: "https://test.mydomain.com/path with spaces",
|
||||
valid: true,
|
||||
description: "URL with unencoded spaces",
|
||||
expectedOutput: "https://test.mydomain.com/path%20with%20spaces"
|
||||
},
|
||||
{
|
||||
url: "https://xn--mnich-kva.example.com",
|
||||
valid: true,
|
||||
description: "Punycode URL",
|
||||
expectedOutput: "https://xn--mnich-kva.example.com"
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const msg = {
|
||||
type: "down",
|
||||
monitor: {
|
||||
name: "Test",
|
||||
url: testCase.url,
|
||||
type: "http"
|
||||
},
|
||||
msg: "Test message"
|
||||
};
|
||||
|
||||
const formatted = notification.format(msg);
|
||||
|
||||
if (testCase.valid) {
|
||||
// For valid URLs, check if the URL is included exactly as expected
|
||||
if (testCase.expectedOutput) {
|
||||
assert.ok(
|
||||
formatted.includes(testCase.expectedOutput),
|
||||
`${testCase.description}: Should include exact URL ${testCase.expectedOutput}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for potential URL substring issues
|
||||
if (testCase.url.includes("test.mydomain.com")) {
|
||||
const urlParts = testCase.url.split("test.mydomain.com");
|
||||
if (urlParts.length > 1) {
|
||||
// Check that we don't have unintended URL substring matches
|
||||
const occurrences = formatted.split("test.mydomain.com").length - 1;
|
||||
assert.strictEqual(
|
||||
occurrences,
|
||||
1,
|
||||
`${testCase.description}: Should only include the URL once`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For invalid URLs, ensure they're not included at all
|
||||
assert.ok(
|
||||
!formatted.includes(testCase.url),
|
||||
`${testCase.description}: Should not include invalid URL`
|
||||
);
|
||||
|
||||
// For invalid URLs with potentially dangerous parts, ensure those parts are not present
|
||||
if (testCase.url.includes("javascript:") ||
|
||||
testCase.url.includes("data:") ||
|
||||
testCase.url.includes("file:")) {
|
||||
assert.ok(
|
||||
!formatted.includes("javascript:") &&
|
||||
!formatted.includes("data:") &&
|
||||
!formatted.includes("file:"),
|
||||
`${testCase.description}: Should not include dangerous protocols`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for proper URL encoding
|
||||
if (testCase.valid && testCase.url.includes(" ")) {
|
||||
assert.ok(
|
||||
!formatted.includes(" "),
|
||||
`${testCase.description}: Spaces should be properly encoded`
|
||||
);
|
||||
assert.ok(
|
||||
formatted.includes("%20"),
|
||||
`${testCase.description}: Should use percent encoding for spaces`
|
||||
);
|
||||
}
|
||||
|
||||
// Additional security checks
|
||||
assert.ok(
|
||||
!formatted.includes("<script>"),
|
||||
`${testCase.description}: Should not include unescaped script tags`
|
||||
);
|
||||
assert.ok(
|
||||
!formatted.includes("javascript:"),
|
||||
`${testCase.description}: Should not include javascript: protocol`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Notification - Priority Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
|
||||
// Add items with different priorities
|
||||
notification.add({
|
||||
type: "down",
|
||||
monitor: {
|
||||
name: "Test1",
|
||||
url: "https://test1.mydomain.com",
|
||||
type: "http"
|
||||
},
|
||||
msg: "Critical Error",
|
||||
priority: "high"
|
||||
});
|
||||
|
||||
notification.add({
|
||||
type: "down",
|
||||
monitor: {
|
||||
name: "Test2",
|
||||
url: "https://test2.mydomain.com",
|
||||
type: "http"
|
||||
},
|
||||
msg: "Warning",
|
||||
priority: "low"
|
||||
});
|
||||
|
||||
const nextItem = notification.queue[0];
|
||||
assert.strictEqual(nextItem.priority, "high", "High priority item should be first");
|
||||
});
|
||||
|
||||
test("Notification - Retry Logic Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
|
||||
const testMsg = {
|
||||
type: "down",
|
||||
monitor: {
|
||||
name: "Test1",
|
||||
url: "https://test1.mydomain.com",
|
||||
type: "http"
|
||||
},
|
||||
msg: "Error",
|
||||
retries: 0,
|
||||
maxRetries: 3
|
||||
};
|
||||
|
||||
notification.add(testMsg);
|
||||
|
||||
// Simulate failed send
|
||||
try {
|
||||
await notification.send(testMsg);
|
||||
} catch (error) {
|
||||
assert.ok(testMsg.retries === 1, "Should increment retry count");
|
||||
assert.ok(notification.queue.length === 1, "Should keep in queue for retry");
|
||||
}
|
||||
});
|
||||
|
||||
test("Notification - Rate Limiting Test", async (t) => {
|
||||
const notification = new Notification();
|
||||
const monitor = {
|
||||
name: "Test Monitor",
|
||||
url: "https://test.mydomain.com",
|
||||
type: "http"
|
||||
};
|
||||
|
||||
// Add multiple notifications for same monitor
|
||||
for (let i = 0; i < 5; i++) {
|
||||
notification.add({
|
||||
type: "down",
|
||||
monitor,
|
||||
msg: `Error ${i}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if rate limiting is applied
|
||||
const processedCount = notification.queue.filter(
|
||||
item => item.monitor.name === "Test Monitor"
|
||||
).length;
|
||||
|
||||
assert.ok(processedCount < 5, "Should apply rate limiting");
|
||||
});
|
124
test/component/MonitorList.spec.js
Normal file
124
test/component/MonitorList.spec.js
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import MonitorList from "../../src/components/MonitorList.vue";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("../../src/components/MonitorListItem.vue", {
|
||||
default: {
|
||||
name: "MonitorListItem",
|
||||
template: "<div class=\"monitor-list-item\"></div>"
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/Confirm.vue", {
|
||||
default: {
|
||||
name: "Confirm",
|
||||
template: "<div class=\"confirm-dialog\"></div>"
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/MonitorListFilter.vue", {
|
||||
default: {
|
||||
name: "MonitorListFilter",
|
||||
template: "<div class=\"monitor-list-filter\"></div>"
|
||||
}
|
||||
});
|
||||
|
||||
describe("MonitorList.vue", () => {
|
||||
let wrapper;
|
||||
const mockMonitors = {
|
||||
1: {
|
||||
id: 1,
|
||||
name: "Test Monitor 1",
|
||||
type: "http",
|
||||
status: "up",
|
||||
active: true,
|
||||
interval: 60,
|
||||
parent: null
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
name: "Test Monitor 2",
|
||||
type: "ping",
|
||||
status: "down",
|
||||
active: false,
|
||||
interval: 60,
|
||||
parent: null
|
||||
}
|
||||
};
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mount(MonitorList, {
|
||||
props: {
|
||||
scrollbar: true
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key, // Mock translation function
|
||||
$router: mockRouter,
|
||||
$root: {
|
||||
monitorList: mockMonitors
|
||||
}
|
||||
},
|
||||
provide: {
|
||||
socket: {
|
||||
emit: vi.fn()
|
||||
}
|
||||
},
|
||||
stubs: {
|
||||
MonitorListItem: {
|
||||
name: "MonitorListItem",
|
||||
template: "<div class='monitor-list-item' :class='{ active: active }' @click='$emit(\"click\")'><slot></slot></div>",
|
||||
props: [ "active" ]
|
||||
},
|
||||
Confirm: true,
|
||||
MonitorListFilter: true,
|
||||
"font-awesome-icon": true,
|
||||
"router-link": true
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("renders monitor list items", () => {
|
||||
const items = wrapper.findAll("[data-testid='monitor-list'] .monitor-list-item");
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("emits select-monitor event when monitor is clicked", async () => {
|
||||
const items = wrapper.findAll("[data-testid='monitor-list'] .monitor-list-item");
|
||||
await items[0].trigger("click");
|
||||
expect(wrapper.emitted("select-monitor")).toBeTruthy();
|
||||
expect(wrapper.emitted("select-monitor")[0]).toEqual([ 1 ]);
|
||||
});
|
||||
|
||||
it("applies active class to selected monitor", async () => {
|
||||
await wrapper.setData({ selectedMonitorId: 1 });
|
||||
const items = wrapper.findAll("[data-testid='monitor-list'] .monitor-list-item");
|
||||
expect(items[0].classes()).toContain("active");
|
||||
expect(items[1].classes()).not.toContain("active");
|
||||
});
|
||||
|
||||
it("filters monitors based on search text", async () => {
|
||||
await wrapper.setData({ searchText: "Test Monitor 1" });
|
||||
const items = wrapper.findAll("[data-testid='monitor-list'] .monitor-list-item");
|
||||
expect(items.length).toBe(1);
|
||||
});
|
||||
|
||||
it("sorts monitors by status", async () => {
|
||||
await wrapper.setData({ sortBy: "status" });
|
||||
const items = wrapper.findAll("[data-testid='monitor-list'] .monitor-list-item");
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("toggles selection mode", async () => {
|
||||
await wrapper.setData({ selectionMode: true });
|
||||
const items = wrapper.findAll("[data-testid='monitor-list'] .monitor-list-item");
|
||||
expect(items.length).toBe(2);
|
||||
expect(wrapper.vm.selectionMode).toBe(true);
|
||||
});
|
||||
});
|
93
test/component/PingChart.spec.js
Normal file
93
test/component/PingChart.spec.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PingChart from "../../src/components/PingChart.vue";
|
||||
import { Line } from "vue-chartjs";
|
||||
|
||||
// Mock Chart.js
|
||||
vi.mock("chart.js", () => ({
|
||||
Chart: vi.fn(),
|
||||
registerables: []
|
||||
}));
|
||||
|
||||
// Mock vue-chartjs
|
||||
vi.mock("vue-chartjs", () => ({
|
||||
Line: {
|
||||
name: "Line",
|
||||
template: "<canvas></canvas>"
|
||||
}
|
||||
}));
|
||||
|
||||
describe("PingChart.vue", () => {
|
||||
let wrapper;
|
||||
const mockMonitorId = 1;
|
||||
const monitorList = {
|
||||
1: {
|
||||
id: 1,
|
||||
name: "Test Monitor",
|
||||
interval: 60,
|
||||
type: "http"
|
||||
}
|
||||
};
|
||||
|
||||
const mockStorage = {
|
||||
"chart-period-1": "24"
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mount(PingChart, {
|
||||
props: {
|
||||
monitorId: mockMonitorId
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key, // Mock translation function
|
||||
$root: {
|
||||
monitorList,
|
||||
storage: () => mockStorage
|
||||
}
|
||||
},
|
||||
stubs: {
|
||||
Line: true
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the chart component", () => {
|
||||
expect(wrapper.findComponent(Line).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes with correct period options", () => {
|
||||
expect(wrapper.vm.chartPeriodOptions).toEqual({
|
||||
0: "recent",
|
||||
3: "3h",
|
||||
6: "6h",
|
||||
24: "24h",
|
||||
168: "1w"
|
||||
});
|
||||
});
|
||||
|
||||
it("updates chart period when option is selected", async () => {
|
||||
await wrapper.setData({ chartPeriodHrs: "24" });
|
||||
expect(wrapper.vm.chartPeriodHrs).toBe("24");
|
||||
});
|
||||
|
||||
it("shows loading state while fetching data", async () => {
|
||||
await wrapper.setData({ loading: true });
|
||||
expect(wrapper.find(".chart-wrapper").classes()).toContain("loading");
|
||||
});
|
||||
|
||||
it("computes correct chart options", () => {
|
||||
const options = wrapper.vm.chartOptions;
|
||||
expect(options.responsive).toBe(true);
|
||||
expect(options.maintainAspectRatio).toBe(false);
|
||||
expect(options.scales.x.type).toBe("time");
|
||||
});
|
||||
|
||||
it("handles empty chart data gracefully", () => {
|
||||
expect(wrapper.vm.chartRawData).toBe(null);
|
||||
const chartData = wrapper.vm.chartData;
|
||||
expect(chartData.datasets).toBeDefined();
|
||||
expect(chartData.datasets.length).toBe(2); // One for ping data, one for status
|
||||
});
|
||||
});
|
93
test/component/Status.spec.js
Normal file
93
test/component/Status.spec.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import Status from "../../src/components/Status.vue";
|
||||
import { UP, DOWN, PENDING, MAINTENANCE } from "../../src/util";
|
||||
|
||||
describe("Status.vue", () => {
|
||||
const mountStatus = (status) => {
|
||||
return mount(Status, {
|
||||
props: {
|
||||
status
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key // Mock translation function
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
it("renders UP status correctly", () => {
|
||||
const wrapper = mountStatus(UP); // UP status
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-primary");
|
||||
expect(wrapper.text()).toBe("Up");
|
||||
});
|
||||
|
||||
it("renders DOWN status correctly", () => {
|
||||
const wrapper = mountStatus(DOWN); // DOWN status
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-danger");
|
||||
expect(wrapper.text()).toBe("Down");
|
||||
});
|
||||
|
||||
it("renders PENDING status correctly", () => {
|
||||
const wrapper = mountStatus(PENDING); // PENDING status
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-warning");
|
||||
expect(wrapper.text()).toBe("Pending");
|
||||
});
|
||||
|
||||
it("renders MAINTENANCE status correctly", () => {
|
||||
const wrapper = mountStatus(MAINTENANCE); // MAINTENANCE status
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-maintenance");
|
||||
expect(wrapper.text()).toBe("statusMaintenance");
|
||||
});
|
||||
|
||||
it("handles unknown status gracefully", () => {
|
||||
const wrapper = mountStatus(999); // Unknown status
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-secondary");
|
||||
expect(wrapper.text()).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("updates when status prop changes", async () => {
|
||||
const wrapper = mountStatus(UP); // UP status
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-primary");
|
||||
|
||||
await wrapper.setProps({ status: DOWN }); // Change to DOWN status
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-danger");
|
||||
});
|
||||
|
||||
it("displays correct status classes", async () => {
|
||||
// Test UP status
|
||||
const wrapper = mountStatus(UP);
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-primary");
|
||||
|
||||
// Test DOWN status
|
||||
await wrapper.setProps({ status: DOWN });
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-danger");
|
||||
|
||||
// Test PENDING status
|
||||
await wrapper.setProps({ status: PENDING });
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-warning");
|
||||
|
||||
// Test MAINTENANCE status
|
||||
await wrapper.setProps({ status: MAINTENANCE });
|
||||
expect(wrapper.find(".badge").classes()).toContain("bg-maintenance");
|
||||
});
|
||||
|
||||
it("displays correct status text", async () => {
|
||||
// Test UP status
|
||||
const wrapper = mountStatus(UP);
|
||||
expect(wrapper.text()).toBe("Up");
|
||||
|
||||
// Test DOWN status
|
||||
await wrapper.setProps({ status: DOWN });
|
||||
expect(wrapper.text()).toBe("Down");
|
||||
|
||||
// Test PENDING status
|
||||
await wrapper.setProps({ status: PENDING });
|
||||
expect(wrapper.text()).toBe("Pending");
|
||||
|
||||
// Test MAINTENANCE status
|
||||
await wrapper.setProps({ status: MAINTENANCE });
|
||||
expect(wrapper.text()).toBe("statusMaintenance");
|
||||
});
|
||||
});
|
22
test/component/setup.js
Normal file
22
test/component/setup.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { config } from "@vue/test-utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Setup global mocks
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Global components mock
|
||||
config.global.stubs = {
|
||||
"font-awesome-icon": true,
|
||||
};
|
||||
|
||||
// Global mounting options
|
||||
config.global.mocks = {
|
||||
$t: (key) => key,
|
||||
$filters: {
|
||||
formatDateTime: vi.fn((date) => date.toString()),
|
||||
},
|
||||
};
|
Loading…
Add table
Reference in a new issue