mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
feat: add Apprise as a fourth notification channel (#1644)
* 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.
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
/**
|
||||
* Unit tests for notification channel helpers. Focused on maskWebhookUrl,
|
||||
* which must never let a channel's embedded auth token reach a log line.
|
||||
* Unit tests for notification channel helpers: masking, Apprise validation,
|
||||
* fail-closed stored parsing, and public DTO redaction.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maskWebhookUrl } from '../helpers/notificationChannels';
|
||||
import {
|
||||
classifyAppriseConfig,
|
||||
maskWebhookUrl,
|
||||
normalizeAppriseStoredJson,
|
||||
parseStoredAppriseConfig,
|
||||
serializePublicAgent,
|
||||
serializePublicNotificationRoute,
|
||||
validateNotificationChannel,
|
||||
} from '../helpers/notificationChannels';
|
||||
|
||||
describe('maskWebhookUrl', () => {
|
||||
it('redacts the token-bearing path of a Discord webhook URL', () => {
|
||||
@@ -47,3 +55,172 @@ describe('maskWebhookUrl', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user