mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
83b3d932e5
* feat: add Apprise as a fourth notification channel Support keyed and stateless Apprise endpoints with secret-safe public DTOs, fail-closed malformed config, and mode-specific Settings UI. Docs and screenshots updated for four-channel Channels and routing. * fix: harden Apprise secrets at rest and preserve-on-write saves Encrypt Apprise endpoint and config with CryptoService so a downgrade cannot leak via SELECT *. Align channel and routing saves so blank destination fields omit config on same-mode URL edits, enforce keyed notify IDs, and keep secrets_redacted truthful. * fix: harden Apprise route type changes and mixed-version config UI Require a raw channel_url when switching notification-route types so ciphertext cannot strand under Discord/Slack/webhook. Default missing remote apprise status, replace Channels state on node switch, and exercise the production config-column migrator. * fix: tolerate stub fleet configuration payloads without agents Normalize remote Apprise agent status only when notifications.agents is present so successful Pilot/stub fetches stay online instead of throwing into the offline catch path. * fix: correct TypeScript in configuration normalize tests * fix: ignore stale Channels agent bodies after node switch Compare the active node after response JSON parsing so a slow body cannot overwrite the newly selected node's channel state. * fix: isolate corrupt Apprise crypto and keep keyed Tags visible Decrypt failures on one Apprise row no longer 500 agent/route lists or suppress sibling channel dispatch. Treat public /notify/<redacted> as keyed so Tags remain editable after reload.
227 lines
9.1 KiB
TypeScript
227 lines
9.1 KiB
TypeScript
/**
|
|
* Unit tests for notification channel helpers: masking, Apprise validation,
|
|
* fail-closed stored parsing, and public DTO redaction.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
classifyAppriseConfig,
|
|
maskWebhookUrl,
|
|
normalizeAppriseStoredJson,
|
|
parseStoredAppriseConfig,
|
|
serializePublicAgent,
|
|
serializePublicNotificationRoute,
|
|
validateNotificationChannel,
|
|
} from '../helpers/notificationChannels';
|
|
|
|
describe('maskWebhookUrl', () => {
|
|
it('redacts the token-bearing path of a Discord webhook URL', () => {
|
|
const masked = maskWebhookUrl('https://discord.com/api/webhooks/123456789/SuP3rS3cr3tT0k3n');
|
|
expect(masked).toBe('https://discord.com/<redacted>');
|
|
expect(masked).not.toContain('SuP3rS3cr3tT0k3n');
|
|
});
|
|
|
|
it('redacts the path of a Slack webhook URL', () => {
|
|
const masked = maskWebhookUrl('https://hooks.slack.com/services/T000/B000/XXXXSECRET');
|
|
expect(masked).toBe('https://hooks.slack.com/<redacted>');
|
|
expect(masked).not.toContain('XXXXSECRET');
|
|
});
|
|
|
|
it('redacts a secret carried in the query string', () => {
|
|
const masked = maskWebhookUrl('https://example.com/?token=abc123secret');
|
|
expect(masked).toBe('https://example.com/<redacted>');
|
|
expect(masked).not.toContain('abc123secret');
|
|
});
|
|
|
|
it('returns the bare origin when there is no path or query to hide', () => {
|
|
expect(maskWebhookUrl('https://example.com')).toBe('https://example.com');
|
|
expect(maskWebhookUrl('https://example.com/')).toBe('https://example.com');
|
|
});
|
|
|
|
it('strips embedded userinfo credentials (origin omits user:pass@)', () => {
|
|
const masked = maskWebhookUrl('https://user:s3cr3t@example.com/');
|
|
expect(masked).toBe('https://example.com');
|
|
expect(masked).not.toContain('s3cr3t');
|
|
expect(masked).not.toContain('user');
|
|
});
|
|
|
|
it('returns a placeholder for empty or non-string input', () => {
|
|
expect(maskWebhookUrl('')).toBe('<no url>');
|
|
expect(maskWebhookUrl(undefined)).toBe('<no url>');
|
|
expect(maskWebhookUrl(null)).toBe('<no url>');
|
|
expect(maskWebhookUrl(42)).toBe('<no url>');
|
|
});
|
|
|
|
it('returns a placeholder for an unparseable URL', () => {
|
|
expect(maskWebhookUrl('not a url')).toBe('<invalid url>');
|
|
});
|
|
});
|
|
|
|
describe('Apprise channel helpers', () => {
|
|
it('accepts empty keyed configuration (missing, null, or {})', () => {
|
|
const endpoint = 'http://apprise.local/notify/key';
|
|
expect(validateNotificationChannel('apprise', endpoint)).toBeNull();
|
|
expect(validateNotificationChannel('apprise', endpoint, null)).toBeNull();
|
|
expect(validateNotificationChannel('apprise', endpoint, {})).toBeNull();
|
|
expect(classifyAppriseConfig(endpoint, null)).toEqual({ mode: 'keyed' });
|
|
expect(normalizeAppriseStoredJson(endpoint, null)).toBe('{}');
|
|
expect(normalizeAppriseStoredJson(endpoint, { tags: '' })).toBe('{}');
|
|
expect(normalizeAppriseStoredJson(endpoint, { tags: 'ops' })).toBe(JSON.stringify({ tags: 'ops' }));
|
|
});
|
|
|
|
it('validates and classifies keyed and stateless configurations', () => {
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops' })).toBeNull();
|
|
expect(classifyAppriseConfig('http://apprise.local/notify', { urls: 'discord://token slack://token' })).toEqual({
|
|
mode: 'stateless',
|
|
urls: ['discord://token', 'slack://token'],
|
|
});
|
|
expect(validateNotificationChannel('apprise', 'https://apprise.local/notify/key', { urls: 'discord://token' }))
|
|
.toMatch(/urls|unknown/);
|
|
});
|
|
|
|
it('rejects unknown config keys and scheme-less destination tokens', () => {
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops', extra: 1 }))
|
|
.toMatch(/unknown Apprise config field/);
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', mode: 'stateless' }))
|
|
.toMatch(/public summary|unknown/);
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'not-a-scheme-token' }))
|
|
.toMatch(/URI scheme/);
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', tags: 'x' }))
|
|
.toMatch(/tags/);
|
|
});
|
|
|
|
it('redacts Apprise endpoint keys and service URLs from public agents', () => {
|
|
const secret = 'discord://webhook-id/webhook-token?secret=query';
|
|
const agent = serializePublicAgent({
|
|
id: 1,
|
|
type: 'apprise',
|
|
url: 'https://apprise.local/notify/key-secret',
|
|
enabled: true,
|
|
config: JSON.stringify({ tags: 'ops' }),
|
|
});
|
|
|
|
expect(agent).toMatchObject({
|
|
type: 'apprise',
|
|
url: 'https://apprise.local/notify/<redacted>',
|
|
config: { mode: 'keyed', tags: 'ops', has_urls: false },
|
|
secrets_redacted: true,
|
|
});
|
|
expect(JSON.stringify(agent)).not.toContain(secret);
|
|
});
|
|
|
|
it('never leaks Apprise secrets from userinfo, host, path, or query in public DTOs', () => {
|
|
const secrets = [
|
|
'userinfo-secret',
|
|
'host-token.example',
|
|
'path-secret',
|
|
'query-secret',
|
|
];
|
|
const urls = [
|
|
'discord://userinfo-secret@host/path-secret?token=query-secret',
|
|
'feishu://host-token.example/path-secret',
|
|
'jira://APIKey:userinfo-secret@host/path-secret?token=query-secret',
|
|
].join(' ');
|
|
|
|
const agent = serializePublicAgent({
|
|
type: 'apprise',
|
|
url: 'http://apprise.local/notify',
|
|
enabled: true,
|
|
config: JSON.stringify({ urls }),
|
|
});
|
|
const route = serializePublicNotificationRoute({
|
|
id: 1,
|
|
name: 'r',
|
|
node_id: null,
|
|
stack_patterns: [],
|
|
label_ids: null,
|
|
categories: null,
|
|
channel_type: 'apprise',
|
|
channel_url: 'http://apprise.local/notify/path-secret',
|
|
config: JSON.stringify({ tags: 'ops' }),
|
|
priority: 0,
|
|
enabled: true,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
});
|
|
|
|
const blob = JSON.stringify({ agent, route });
|
|
for (const secret of secrets) {
|
|
expect(blob).not.toContain(secret);
|
|
}
|
|
expect(agent.config).toMatchObject({
|
|
mode: 'stateless',
|
|
has_urls: true,
|
|
providers: expect.arrayContaining(['discord', 'feishu', 'jira']),
|
|
url_count: 3,
|
|
});
|
|
expect(route.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
|
});
|
|
|
|
it('rejects public DTO config shapes on validate', () => {
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', {
|
|
mode: 'stateless',
|
|
has_urls: true,
|
|
providers: ['discord'],
|
|
url_count: 1,
|
|
})).toMatch(/raw Apprise config/);
|
|
});
|
|
|
|
it('fail-closes malformed stored JSON and never exposes raw tokens as providers', () => {
|
|
const keyed = parseStoredAppriseConfig('http://apprise.local/notify/key', '{not-json');
|
|
expect(keyed).toEqual({ ok: false, reason: 'Apprise configuration is missing or invalid' });
|
|
|
|
const badToken = parseStoredAppriseConfig(
|
|
'http://apprise.local/notify',
|
|
JSON.stringify({ urls: 'super-secret-token' }),
|
|
);
|
|
expect(badToken.ok).toBe(false);
|
|
|
|
const agent = serializePublicAgent({
|
|
type: 'apprise',
|
|
url: 'http://apprise.local/notify',
|
|
enabled: true,
|
|
config: JSON.stringify({ urls: 'super-secret-token' }),
|
|
});
|
|
expect(agent.config).toBeNull();
|
|
expect(JSON.stringify(agent)).not.toContain('super-secret-token');
|
|
});
|
|
|
|
it('rejects Apprise notify keys outside the official alphanumeric/underscore/dash charset', () => {
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad.key')).toMatch(/notify key/);
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/has%20space')).toMatch(/notify key|valid configuration/);
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad!key')).toMatch(/notify key/);
|
|
expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'a'.repeat(129)}`)).toMatch(/notify key/);
|
|
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/a')).toBeNull();
|
|
expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'A1_-'}${'b'.repeat(124)}`)).toBeNull();
|
|
});
|
|
|
|
it('sets secrets_redacted only for Apprise public DTOs', () => {
|
|
expect(serializePublicAgent({
|
|
type: 'discord',
|
|
url: 'https://discord.com/api/webhooks/1/token',
|
|
enabled: true,
|
|
}).secrets_redacted).toBe(false);
|
|
expect(serializePublicAgent({
|
|
type: 'slack',
|
|
url: 'https://hooks.slack.com/services/a/b/c',
|
|
enabled: true,
|
|
}).secrets_redacted).toBe(false);
|
|
expect(serializePublicAgent({
|
|
type: 'webhook',
|
|
url: 'https://example.com/hook',
|
|
enabled: true,
|
|
}).secrets_redacted).toBe(false);
|
|
expect(serializePublicAgent({
|
|
type: 'apprise',
|
|
url: 'http://apprise.local/notify/k',
|
|
enabled: true,
|
|
config: '{}',
|
|
}).secrets_redacted).toBe(true);
|
|
});
|
|
|
|
it('treats null/empty stored config as valid empty keyed', () => {
|
|
expect(parseStoredAppriseConfig('http://apprise.local/notify/key', null)).toEqual({ ok: true, mode: 'keyed' });
|
|
expect(parseStoredAppriseConfig('http://apprise.local/notify/key', '{}')).toEqual({ ok: true, mode: 'keyed' });
|
|
expect(parseStoredAppriseConfig('http://apprise.local/notify', null).ok).toBe(false);
|
|
});
|
|
});
|