This commit is contained in:
Zaid Hafeez 2024-11-21 16:04:48 +05:00 committed by GitHub
commit e48eb73394
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1988 additions and 18 deletions

21
config/vitest.config.js Normal file
View 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"),
},
},
});

1159
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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

View file

@ -0,0 +1,148 @@
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 - 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
};
try {
const dnsMonitor = new DnsMonitorType(monitor);
if (!testCase.valid) {
assert.fail(`Should not create monitor for ${testCase.description}`);
}
assert.ok(dnsMonitor, `Should create monitor for ${testCase.description}`);
} catch (error) {
if (testCase.valid) {
assert.fail(`Should create monitor for ${testCase.description}`);
}
assert.ok(error, `Should throw error for ${testCase.description}`);
}
}
});
test("DNS Monitor - Resolver Test", async (t) => {
const testCases = [
{
server: "8.8.8.8",
valid: true,
description: "Google DNS"
},
{
server: "1.1.1.1",
valid: true,
description: "Cloudflare DNS"
},
{
server: "malicious.com",
valid: false,
description: "Invalid DNS server hostname"
},
{
server: "javascript:alert(1)",
valid: false,
description: "Invalid protocol"
}
];
for (const testCase of testCases) {
const monitor = {
hostname: "test1.example.com",
dns_resolve_server: testCase.server,
port: 53,
dns_resolve_type: "A",
dns_resolve_server_port: 53,
maxretries: 1
};
try {
const dnsMonitor = new DnsMonitorType(monitor);
if (!testCase.valid) {
assert.fail(`Should not create monitor for ${testCase.description}`);
}
assert.ok(dnsMonitor, `Should create monitor for ${testCase.description}`);
} catch (error) {
if (testCase.valid) {
assert.fail(`Should create monitor for ${testCase.description}`);
}
assert.ok(error, `Should throw error for ${testCase.description}`);
}
}
});

View file

@ -0,0 +1,249 @@
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 Test", async (t) => {
const notification = new Notification();
// Test with various URL formats
const testCases = [
{
url: "https://test.mydomain.com",
valid: true,
description: "Basic HTTPS URL"
},
{
url: "http://sub.test.mydomain.com",
valid: true,
description: "Subdomain URL"
},
{
url: "https://test.mydomain.com/path",
valid: true,
description: "URL with path"
},
{
url: "https://malicious.test.mydomain.com/test.mydomain.com",
valid: true,
description: "URL with misleading path"
},
{
url: "javascript:alert(1)",
valid: false,
description: "JavaScript protocol"
},
{
url: "data:text/html,<script>alert(1)</script>",
valid: false,
description: "Data URL"
}
];
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) {
assert.ok(formatted.includes(testCase.url), `Should include ${testCase.description}`);
} else {
assert.ok(!formatted.includes(testCase.url), `Should not include ${testCase.description}`);
}
}
});
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");
});

View 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);
});
});

View 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
});
});

View 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
View 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()),
},
};