330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
/**
|
|
* Notification System Tests
|
|
*
|
|
* Unit tests for notification event mapping and routing.
|
|
* Run with: bun test tests/notifications.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { escapeTelegramMarkdown, buildGotifyUrl, parsePushoverUrl } from '../src/lib/utils/notification-parsers';
|
|
|
|
// Test the event type mapping logic (mirrors notifications.ts)
|
|
const EVENT_TYPE_MAPPING: Record<string, string> = {
|
|
'start': 'container_started',
|
|
'stop': 'container_stopped',
|
|
'restart': 'container_restarted',
|
|
'die': 'container_exited',
|
|
'kill': 'container_exited',
|
|
'oom': 'container_oom',
|
|
'health_status: unhealthy': 'container_unhealthy',
|
|
'health_status: healthy': 'container_healthy',
|
|
'pull': 'image_pulled'
|
|
};
|
|
|
|
function mapActionToEventType(action: string): string | null {
|
|
return EVENT_TYPE_MAPPING[action] || null;
|
|
}
|
|
|
|
describe('Event Type Mapping', () => {
|
|
test('Maps start action to container_started', () => {
|
|
expect(mapActionToEventType('start')).toBe('container_started');
|
|
});
|
|
|
|
test('Maps stop action to container_stopped', () => {
|
|
expect(mapActionToEventType('stop')).toBe('container_stopped');
|
|
});
|
|
|
|
test('Maps restart action to container_restarted', () => {
|
|
expect(mapActionToEventType('restart')).toBe('container_restarted');
|
|
});
|
|
|
|
test('Maps die action to container_exited', () => {
|
|
expect(mapActionToEventType('die')).toBe('container_exited');
|
|
});
|
|
|
|
test('Maps kill action to container_exited', () => {
|
|
expect(mapActionToEventType('kill')).toBe('container_exited');
|
|
});
|
|
|
|
test('Maps oom action to container_oom', () => {
|
|
expect(mapActionToEventType('oom')).toBe('container_oom');
|
|
});
|
|
|
|
test('Maps health_status: unhealthy to container_unhealthy', () => {
|
|
expect(mapActionToEventType('health_status: unhealthy')).toBe('container_unhealthy');
|
|
});
|
|
|
|
test('Maps health_status: healthy to container_healthy', () => {
|
|
expect(mapActionToEventType('health_status: healthy')).toBe('container_healthy');
|
|
});
|
|
|
|
test('Maps pull action to image_pulled', () => {
|
|
expect(mapActionToEventType('pull')).toBe('image_pulled');
|
|
});
|
|
|
|
test('Returns null for unknown actions', () => {
|
|
expect(mapActionToEventType('create')).toBeNull();
|
|
expect(mapActionToEventType('destroy')).toBeNull();
|
|
expect(mapActionToEventType('unknown')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Scanner Image Detection', () => {
|
|
const SCANNER_IMAGE_PATTERNS = [
|
|
'anchore/grype',
|
|
'aquasec/trivy',
|
|
'ghcr.io/anchore/grype',
|
|
'ghcr.io/aquasecurity/trivy'
|
|
];
|
|
|
|
function isScannerContainer(image: string | null | undefined): boolean {
|
|
if (!image) return false;
|
|
const lowerImage = image.toLowerCase();
|
|
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
|
|
}
|
|
|
|
test('Detects Grype scanner images', () => {
|
|
expect(isScannerContainer('anchore/grype:latest')).toBe(true);
|
|
expect(isScannerContainer('ghcr.io/anchore/grype:v0.74.0')).toBe(true);
|
|
});
|
|
|
|
test('Detects Trivy scanner images', () => {
|
|
expect(isScannerContainer('aquasec/trivy:latest')).toBe(true);
|
|
expect(isScannerContainer('ghcr.io/aquasecurity/trivy:0.50.0')).toBe(true);
|
|
});
|
|
|
|
test('Does not match regular images', () => {
|
|
expect(isScannerContainer('nginx:latest')).toBe(false);
|
|
expect(isScannerContainer('alpine:3.18')).toBe(false);
|
|
expect(isScannerContainer('postgres:16')).toBe(false);
|
|
});
|
|
|
|
test('Handles null and undefined', () => {
|
|
expect(isScannerContainer(null)).toBe(false);
|
|
expect(isScannerContainer(undefined)).toBe(false);
|
|
});
|
|
|
|
test('Case insensitive matching', () => {
|
|
expect(isScannerContainer('ANCHORE/GRYPE:LATEST')).toBe(true);
|
|
expect(isScannerContainer('AquaSec/Trivy:Latest')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Apprise URL Parsing', () => {
|
|
function parseAppriseProtocol(url: string): string | null {
|
|
const match = url.match(/^([a-z]+):\/\//i);
|
|
return match ? match[1].toLowerCase() : null;
|
|
}
|
|
|
|
test('Parses Discord URL', () => {
|
|
expect(parseAppriseProtocol('discord://webhook_id/token')).toBe('discord');
|
|
expect(parseAppriseProtocol('discords://webhook_id/token')).toBe('discords');
|
|
});
|
|
|
|
test('Parses Slack URL', () => {
|
|
expect(parseAppriseProtocol('slack://token_a/token_b/token_c')).toBe('slack');
|
|
});
|
|
|
|
test('Parses Telegram URL', () => {
|
|
expect(parseAppriseProtocol('tgram://bot_token/chat_id')).toBe('tgram');
|
|
});
|
|
|
|
test('Parses Gotify URL', () => {
|
|
expect(parseAppriseProtocol('gotify://hostname/token')).toBe('gotify');
|
|
expect(parseAppriseProtocol('gotifys://hostname/token')).toBe('gotifys');
|
|
});
|
|
|
|
test('Parses ntfy URL', () => {
|
|
expect(parseAppriseProtocol('ntfy://topic')).toBe('ntfy');
|
|
expect(parseAppriseProtocol('ntfys://hostname/topic')).toBe('ntfys');
|
|
});
|
|
|
|
test('Parses Pushover URL', () => {
|
|
expect(parseAppriseProtocol('pushover://user_key/api_token')).toBe('pushover');
|
|
});
|
|
|
|
test('Parses JSON webhook URL', () => {
|
|
expect(parseAppriseProtocol('json://hostname/path')).toBe('json');
|
|
expect(parseAppriseProtocol('jsons://hostname/path')).toBe('jsons');
|
|
});
|
|
|
|
test('Returns null for invalid URLs', () => {
|
|
expect(parseAppriseProtocol('invalid')).toBeNull();
|
|
expect(parseAppriseProtocol('://missing-protocol')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Gotify URL Building', () => {
|
|
test('Builds URL without subpath', () => {
|
|
const result = buildGotifyUrl('gotify://myhost/AklJjLSi73nAXY4');
|
|
expect(result?.url).toBe('http://myhost/message?token=AklJjLSi73nAXY4');
|
|
expect(result?.priority).toBeUndefined();
|
|
});
|
|
|
|
test('Builds URL without subpath (HTTPS)', () => {
|
|
const result = buildGotifyUrl('gotifys://myhost/AklJjLSi73nAXY4');
|
|
expect(result?.url).toBe('https://myhost/message?token=AklJjLSi73nAXY4');
|
|
});
|
|
|
|
test('Builds URL with subpath', () => {
|
|
const result = buildGotifyUrl('gotifys://gotify.domain/gotify/AklJjLSi73nAXY4');
|
|
expect(result?.url).toBe('https://gotify.domain/gotify/message?token=AklJjLSi73nAXY4');
|
|
});
|
|
|
|
test('Builds URL with deeper subpath', () => {
|
|
const result = buildGotifyUrl('gotify://myhost/apps/gotify/MyToken123');
|
|
expect(result?.url).toBe('http://myhost/apps/gotify/message?token=MyToken123');
|
|
});
|
|
|
|
test('Returns null for invalid URL', () => {
|
|
expect(buildGotifyUrl('gotify://hostname-only')).toBeNull();
|
|
});
|
|
|
|
test('Parses priority from query param', () => {
|
|
const result = buildGotifyUrl('gotifys://myhost/token123?priority=3');
|
|
expect(result?.url).toBe('https://myhost/message?token=token123');
|
|
expect(result?.priority).toBe(3);
|
|
});
|
|
|
|
test('Parses priority with subpath', () => {
|
|
const result = buildGotifyUrl('gotifys://gotify.domain/gotify/token123?priority=7');
|
|
expect(result?.url).toBe('https://gotify.domain/gotify/message?token=token123');
|
|
expect(result?.priority).toBe(7);
|
|
});
|
|
|
|
test('Ignores invalid priority values', () => {
|
|
expect(buildGotifyUrl('gotify://myhost/token?priority=abc')?.priority).toBeUndefined();
|
|
expect(buildGotifyUrl('gotify://myhost/token?priority=-1')?.priority).toBeUndefined();
|
|
expect(buildGotifyUrl('gotify://myhost/token?priority=11')?.priority).toBeUndefined();
|
|
});
|
|
|
|
test('Priority 0 is valid', () => {
|
|
expect(buildGotifyUrl('gotify://myhost/token?priority=0')?.priority).toBe(0);
|
|
});
|
|
|
|
test('Ignores unknown query params', () => {
|
|
const result = buildGotifyUrl('gotify://myhost/token?foo=bar&priority=5&baz=qux');
|
|
expect(result?.url).toBe('http://myhost/message?token=token');
|
|
expect(result?.priority).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('Telegram URL Parsing', () => {
|
|
function parseTelegramUrl(url: string): { botToken: string; chatId: string } | null {
|
|
const match = url.match(/^tgram:\/\/([^/]+)\/(.+)/);
|
|
if (!match) return null;
|
|
return { botToken: match[1], chatId: match[2] };
|
|
}
|
|
|
|
test('Parses valid Telegram URL', () => {
|
|
const result = parseTelegramUrl('tgram://123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/-100123456789');
|
|
expect(result).not.toBeNull();
|
|
expect(result!.botToken).toBe('123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11');
|
|
expect(result!.chatId).toBe('-100123456789');
|
|
});
|
|
|
|
test('Returns null for invalid URLs', () => {
|
|
expect(parseTelegramUrl('tgram://only-token')).toBeNull();
|
|
expect(parseTelegramUrl('invalid://bot/chat')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Telegram Markdown Escaping', () => {
|
|
test('escapes underscores', () => {
|
|
expect(escapeTelegramMarkdown('my_container')).toBe('my\\_container');
|
|
});
|
|
|
|
test('escapes asterisks', () => {
|
|
expect(escapeTelegramMarkdown('*bold*')).toBe('\\*bold\\*');
|
|
});
|
|
|
|
test('escapes backticks', () => {
|
|
expect(escapeTelegramMarkdown('`code`')).toBe('\\`code\\`');
|
|
});
|
|
|
|
test('escapes opening brackets', () => {
|
|
expect(escapeTelegramMarkdown('[link')).toBe('\\[link');
|
|
});
|
|
|
|
test('does NOT escape closing brackets (#955)', () => {
|
|
expect(escapeTelegramMarkdown('text]')).toBe('text]');
|
|
});
|
|
|
|
test('does NOT escape backslashes (legacy Markdown)', () => {
|
|
expect(escapeTelegramMarkdown('path\\to')).toBe('path\\to');
|
|
});
|
|
|
|
test('plain text passes through unchanged', () => {
|
|
expect(escapeTelegramMarkdown('production')).toBe('production');
|
|
expect(escapeTelegramMarkdown('my-env-123')).toBe('my-env-123');
|
|
});
|
|
|
|
test('environment name in envTag format has no extra backslash', () => {
|
|
const envName = 'production';
|
|
const envTag = ` [${escapeTelegramMarkdown(envName)}]`;
|
|
expect(envTag).toBe(' [production]');
|
|
expect(envTag).not.toContain('\\]');
|
|
});
|
|
|
|
test('environment name with special chars in envTag', () => {
|
|
const envName = 'my_server';
|
|
const envTag = ` [${escapeTelegramMarkdown(envName)}]`;
|
|
expect(envTag).toBe(' [my\\_server]');
|
|
expect(envTag).not.toContain('\\]');
|
|
});
|
|
});
|
|
|
|
describe('Pushover URL Parsing', () => {
|
|
test('backward compatible: pushover://user/token has no device', () => {
|
|
expect(parsePushoverUrl('pushover://uKey123/aToken456')).toEqual({
|
|
userKey: 'uKey123',
|
|
apiToken: 'aToken456',
|
|
device: undefined
|
|
});
|
|
});
|
|
|
|
test('pushover:// with a single device', () => {
|
|
expect(parsePushoverUrl('pushover://uKey123/aToken456/phone')).toEqual({
|
|
userKey: 'uKey123',
|
|
apiToken: 'aToken456',
|
|
device: 'phone'
|
|
});
|
|
});
|
|
|
|
test('pushover:// with multiple devices -> comma-separated list', () => {
|
|
expect(parsePushoverUrl('pushover://uKey123/aToken456/phone/tablet/desktop')).toEqual({
|
|
userKey: 'uKey123',
|
|
apiToken: 'aToken456',
|
|
device: 'phone,tablet,desktop'
|
|
});
|
|
});
|
|
|
|
test('apprise-native pover://user@token form, no device', () => {
|
|
expect(parsePushoverUrl('pover://uKey123@aToken456')).toEqual({
|
|
userKey: 'uKey123',
|
|
apiToken: 'aToken456',
|
|
device: undefined
|
|
});
|
|
});
|
|
|
|
test('apprise-native pover://user@token/device1/device2', () => {
|
|
expect(parsePushoverUrl('pover://uKey123@aToken456/phone/tablet')).toEqual({
|
|
userKey: 'uKey123',
|
|
apiToken: 'aToken456',
|
|
device: 'phone,tablet'
|
|
});
|
|
});
|
|
|
|
test('trailing slash / empty segments are ignored', () => {
|
|
expect(parsePushoverUrl('pushover://uKey123/aToken456/')?.device).toBeUndefined();
|
|
expect(parsePushoverUrl('pover://uKey123@aToken456//phone')?.device).toBe('phone');
|
|
});
|
|
|
|
test('malformed URLs return null', () => {
|
|
expect(parsePushoverUrl('pushover://onlyuser')).toBeNull();
|
|
expect(parsePushoverUrl('pover://useronly')).toBeNull();
|
|
expect(parsePushoverUrl('invalid://user/token')).toBeNull();
|
|
expect(parsePushoverUrl('')).toBeNull();
|
|
});
|
|
});
|