mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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:
@@ -124,3 +124,122 @@ describe('POST /api/agents', () => {
|
||||
expect(Boolean(agents[0].enabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Apprise agents - redaction and preserve-on-write', () => {
|
||||
const keyedUrl = 'http://apprise.local/notify/key-secret-value';
|
||||
const serviceUrl = 'discord://webhook-id/webhook-token?token=query-secret';
|
||||
|
||||
it('GET redacts keyed endpoint and never returns destination secrets', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/agents').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0].url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(res.body[0].config).toMatchObject({ mode: 'keyed', tags: 'ops', has_urls: false });
|
||||
expect(res.body[0].secrets_redacted).toBe(true);
|
||||
expect(JSON.stringify(res.body)).not.toContain('key-secret-value');
|
||||
});
|
||||
|
||||
it('GET for stateless mode exposes providers and url_count only', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls: serviceUrl }),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/agents').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0].url).toBe('http://apprise.local/notify');
|
||||
expect(res.body[0].config).toMatchObject({
|
||||
mode: 'stateless',
|
||||
has_urls: true,
|
||||
providers: ['discord'],
|
||||
url_count: 1,
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain('webhook-token');
|
||||
expect(JSON.stringify(res.body)).not.toContain('query-secret');
|
||||
});
|
||||
|
||||
it('rejects posting a redacted endpoint URL', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/<redacted>',
|
||||
enabled: true,
|
||||
config: { tags: 'ops' },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects public DTO config shapes on write', async () => {
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: { mode: 'stateless', has_urls: true, providers: ['discord'], url_count: 1 },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('preserves stored secrets when url and config are omitted', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
enabled: false,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const agents = db.getAgents(1);
|
||||
expect(agents[0].url).toBe(keyedUrl);
|
||||
expect(agents[0].config).toBe(JSON.stringify({ tags: 'ops' }));
|
||||
expect(Boolean(agents[0].enabled)).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a keyed agent with no config and persists {}', async () => {
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/new-key',
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const agent = DatabaseService.getInstance().getAgents(1).find(a => a.type === 'apprise');
|
||||
expect(agent?.url).toBe('http://apprise.local/notify/new-key');
|
||||
expect(agent?.config).toBe('{}');
|
||||
});
|
||||
|
||||
it('rejects preserve-on-write when stored Apprise config is malformed', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: '{not-json',
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
enabled: false,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid/i);
|
||||
expect(JSON.stringify(res.body)).not.toContain('key-secret-value');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('POST /api/notifications/test', () => {
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'telegram', url: 'https://example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('discord, slack, webhook');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
});
|
||||
|
||||
it('rejects missing type with 400', async () => {
|
||||
@@ -277,4 +277,77 @@ describe('POST /api/notifications/test', () => {
|
||||
.send({ type: 'discord', url: 'https://' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('dispatches keyed Apprise test with exact payload', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify/test-key', config: { tags: 'ops' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://apprise.local/notify/test-key',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: '🔌 Test Notification from Sencho!',
|
||||
type: 'info',
|
||||
tag: 'ops',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('dispatches stateless Apprise test with urls and rejects scheme-less tokens', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const bad = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'no-scheme' } });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const ok = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'discord://token' } });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://apprise.local/notify',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: '🔌 Test Notification from Sencho!',
|
||||
type: 'info',
|
||||
urls: 'discord://token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns sanitized details on Apprise 204 without leaking secrets', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify/secret-key', config: {} });
|
||||
expect(res.status).toBe(500);
|
||||
expect(JSON.stringify(res.body)).toContain('HTTP 204');
|
||||
expect(JSON.stringify(res.body)).not.toContain('secret-key');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Downgrade-safety: Apprise url/config must be encrypted at rest so a
|
||||
* pre-Apprise binary's SELECT * + unsanitized GET cannot return raw secrets.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { serializePublicAgent, serializePublicNotificationRoute } from '../helpers/notificationChannels';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM agents').run();
|
||||
db.prepare('DELETE FROM notification_routes').run();
|
||||
});
|
||||
|
||||
describe('Apprise secrets at rest (downgrade-safe)', () => {
|
||||
it('stores keyed agent secrets as ciphertext; SELECT * does not leak the notify key', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const keySecret = 'SuperSecretKey99';
|
||||
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: `http://apprise.local/notify/${keySecret}`,
|
||||
enabled: true,
|
||||
config: '{}',
|
||||
});
|
||||
|
||||
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as {
|
||||
url: string;
|
||||
config: string | null;
|
||||
};
|
||||
expect(raw.url).toMatch(/^enc:/);
|
||||
expect(raw.url).not.toContain(keySecret);
|
||||
expect(raw.url).not.toContain('apprise.local');
|
||||
expect(raw.config).toMatch(/^enc:/);
|
||||
|
||||
const apprise = db.getAgents(1).find(a => a.type === 'apprise')!;
|
||||
expect(apprise.url).toBe(`http://apprise.local/notify/${keySecret}`);
|
||||
const pub = serializePublicAgent(apprise);
|
||||
expect(pub.secrets_redacted).toBe(true);
|
||||
expect(pub.url).toContain('<redacted>');
|
||||
expect(JSON.stringify(pub)).not.toContain(keySecret);
|
||||
});
|
||||
|
||||
it('stores stateless destination URLs as ciphertext; SELECT * does not leak credentials', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const destSecret = 'mailto://user:smtp-password@example.com';
|
||||
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls: destSecret }),
|
||||
});
|
||||
|
||||
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as {
|
||||
url: string;
|
||||
config: string | null;
|
||||
};
|
||||
expect(raw.url).toMatch(/^enc:/);
|
||||
expect(raw.config).toMatch(/^enc:/);
|
||||
expect(raw.config).not.toContain(destSecret);
|
||||
expect(raw.config).not.toContain('smtp-password');
|
||||
|
||||
const apprise = db.getAgents(1).find(a => a.type === 'apprise')!;
|
||||
expect(apprise.url).toBe('http://apprise.local/notify');
|
||||
expect(apprise.config).toBe(JSON.stringify({ urls: destSecret }));
|
||||
const pub = serializePublicAgent(apprise);
|
||||
expect(pub.secrets_redacted).toBe(true);
|
||||
expect(JSON.stringify(pub)).not.toContain(destSecret);
|
||||
});
|
||||
|
||||
it('encrypts Apprise route channel_url and config the same way', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const keySecret = 'RouteKey_01';
|
||||
const now = Date.now();
|
||||
const route = db.createNotificationRoute({
|
||||
name: 'apprise-route',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: `http://apprise.local/notify/${keySecret}`,
|
||||
config: '{}',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
const raw = db.getDb().prepare('SELECT * FROM notification_routes WHERE id = ?').get(route.id) as {
|
||||
channel_url: string;
|
||||
config: string | null;
|
||||
};
|
||||
expect(raw.channel_url).toMatch(/^enc:/);
|
||||
expect(raw.channel_url).not.toContain(keySecret);
|
||||
expect(raw.config).toMatch(/^enc:/);
|
||||
|
||||
const loaded = db.getNotificationRoute(route.id)!;
|
||||
expect(loaded.channel_url).toBe(`http://apprise.local/notify/${keySecret}`);
|
||||
const pub = serializePublicNotificationRoute(loaded);
|
||||
expect(pub.secrets_redacted).toBe(true);
|
||||
expect(pub.channel_url).toContain('<redacted>');
|
||||
expect(JSON.stringify(pub)).not.toContain(keySecret);
|
||||
});
|
||||
|
||||
it('does not encrypt Discord agent URLs (unchanged channel behavior)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const url = 'https://discord.com/api/webhooks/123/plaintext-token';
|
||||
db.upsertAgent(1, { type: 'discord', url, enabled: true });
|
||||
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('discord') as { url: string };
|
||||
expect(raw.url).toBe(url);
|
||||
expect(raw.url.startsWith('enc:')).toBe(false);
|
||||
const pub = serializePublicAgent(db.getAgents(1).find(a => a.type === 'discord')!);
|
||||
expect(pub.secrets_redacted).toBe(false);
|
||||
expect(pub.url).toBe(url);
|
||||
});
|
||||
|
||||
it('isolates corrupt Apprise ciphertext so sibling channels still load and Apprise can be repaired', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const discordUrl = 'https://discord.com/api/webhooks/123/plaintext-token';
|
||||
db.upsertAgent(1, { type: 'discord', url: discordUrl, enabled: true });
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/good-key',
|
||||
enabled: true,
|
||||
config: '{}',
|
||||
});
|
||||
|
||||
// Simulate backup-restore / key-rotation damage: enc: prefix with invalid payload.
|
||||
db.getDb().prepare('UPDATE agents SET url = ?, config = ? WHERE type = ?').run(
|
||||
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
|
||||
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
|
||||
'apprise',
|
||||
);
|
||||
|
||||
const agents = db.getAgents(1);
|
||||
expect(agents.find(a => a.type === 'discord')!.url).toBe(discordUrl);
|
||||
const broken = agents.find(a => a.type === 'apprise')!;
|
||||
expect(broken.url).toBe('');
|
||||
expect(broken.config).toBeNull();
|
||||
|
||||
const enabled = db.getEnabledAgents(1);
|
||||
expect(enabled.some(a => a.type === 'discord')).toBe(true);
|
||||
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/repaired-key',
|
||||
enabled: true,
|
||||
config: '{}',
|
||||
});
|
||||
expect(db.getAgents(1).find(a => a.type === 'apprise')!.url).toBe(
|
||||
'http://apprise.local/notify/repaired-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('isolates corrupt Apprise route ciphertext from the routes list', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
db.createNotificationRoute({
|
||||
name: 'discord-ok',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/token',
|
||||
config: null,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
const apprise = db.createNotificationRoute({
|
||||
name: 'apprise-broken',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/route-key',
|
||||
config: '{}',
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
db.getDb().prepare('UPDATE notification_routes SET channel_url = ? WHERE id = ?').run(
|
||||
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
|
||||
apprise.id,
|
||||
);
|
||||
|
||||
const routes = db.getNotificationRoutes();
|
||||
expect(routes.find(r => r.name === 'discord-ok')!.channel_url).toContain('discord.com');
|
||||
expect(routes.find(r => r.name === 'apprise-broken')!.channel_url).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeConfigurationAgents, normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
|
||||
|
||||
describe('normalizeConfigurationAgents', () => {
|
||||
it('defaults missing apprise to disabled/unconfigured', () => {
|
||||
const normalized = normalizeConfigurationAgents({
|
||||
discord: { configured: true, enabled: true },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: true, enabled: false },
|
||||
});
|
||||
expect(normalized.apprise).toEqual({ configured: false, enabled: false });
|
||||
expect(normalized.discord.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves an explicit apprise slot', () => {
|
||||
const normalized = normalizeConfigurationAgents({
|
||||
discord: { configured: false, enabled: false },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: true, enabled: true },
|
||||
});
|
||||
expect(normalized.apprise).toEqual({ configured: true, enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRemoteConfigurationStatus', () => {
|
||||
it('passes through stub payloads without notifications.agents', () => {
|
||||
const stub = { ssoConfigured: false, alertsConfigured: true };
|
||||
expect(normalizeRemoteConfigurationStatus(stub)).toEqual(stub);
|
||||
});
|
||||
|
||||
it('fills missing apprise when an agents block is present', () => {
|
||||
const raw = {
|
||||
notifications: {
|
||||
agents: {
|
||||
discord: { configured: true, enabled: true },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const normalized = normalizeRemoteConfigurationStatus(raw);
|
||||
expect(normalized.notifications.agents).toEqual({
|
||||
discord: { configured: true, enabled: true },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: false, enabled: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,12 @@ describe('GET /api/dashboard/configuration', () => {
|
||||
expect(res.body).toMatchObject({
|
||||
tier: expect.any(String),
|
||||
notifications: {
|
||||
agents: { discord: { configured: expect.any(Boolean) }, slack: { configured: expect.any(Boolean) }, webhook: { configured: expect.any(Boolean) } },
|
||||
agents: {
|
||||
discord: { configured: expect.any(Boolean) },
|
||||
slack: { configured: expect.any(Boolean) },
|
||||
webhook: { configured: expect.any(Boolean) },
|
||||
apprise: { configured: expect.any(Boolean) },
|
||||
},
|
||||
alertRules: expect.any(Number),
|
||||
routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) },
|
||||
},
|
||||
|
||||
@@ -108,4 +108,29 @@ describe('conditionalJsonParser remote-proxy bypass', () => {
|
||||
expect(lastUpstreamAuth).toBeNull();
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('forwards Apprise agent config bodies intact to the remote', async () => {
|
||||
lastUpstreamBody = null;
|
||||
lastUpstreamAuth = null;
|
||||
|
||||
const payload = {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: { urls: 'discord://webhook-id/webhook-token?token=query-secret' },
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/agents')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId))
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(payload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpstreamBody).not.toBeNull();
|
||||
expect(JSON.parse(lastUpstreamBody!.toString('utf-8'))).toEqual(payload);
|
||||
expect(lastUpstreamAuth).toBe('Bearer bypass-test-token');
|
||||
expect(JSON.stringify(res.body)).not.toContain('query-secret');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Apprise delivery through NotificationService: payloads, status classes,
|
||||
* fail-closed malformed config, and dispatch_error recording.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledNotificationSuppressionRules,
|
||||
mockGetEnabledAgents,
|
||||
mockGetStackLabelIds,
|
||||
mockAddNotificationHistory,
|
||||
mockUpdateNotificationDispatchError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||
id: 99,
|
||||
level: 'info',
|
||||
message: 'test',
|
||||
timestamp: Date.now(),
|
||||
is_read: 0,
|
||||
}),
|
||||
mockUpdateNotificationDispatchError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
getStackLabelIds: mockGetStackLabelIds,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/app/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/StackActivityMetricsService', () => ({
|
||||
StackActivityMetricsService: {
|
||||
getInstance: () => ({
|
||||
record: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { NotificationService, NotificationDeliveryError } from '../services/NotificationService';
|
||||
|
||||
const KEYED = 'http://apprise.local/notify/key-secret';
|
||||
const STATELESS = 'http://apprise.local/notify';
|
||||
|
||||
function makeAppriseRoute(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Apprise route',
|
||||
node_id: null,
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'apprise' as const,
|
||||
channel_url: KEYED,
|
||||
config: '{}',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationService - Apprise delivery', () => {
|
||||
let svc: NotificationService;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
|
||||
svc = NotificationService.getInstance();
|
||||
mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
mockUpdateNotificationDispatchError.mockClear();
|
||||
mockAddNotificationHistory.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends keyed payload without tag when tags are empty', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute({ config: '{}' })]);
|
||||
await svc.dispatchAlert('warning', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [WARNING]',
|
||||
body: 'boom',
|
||||
type: 'warning',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends keyed tag and maps error to failure', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeAppriseRoute({ config: JSON.stringify({ tags: 'ops' }) }),
|
||||
]);
|
||||
await svc.dispatchAlert('error', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [ERROR]',
|
||||
body: 'boom',
|
||||
type: 'failure',
|
||||
tag: 'ops',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends stateless urls from stored config via agent fallback', async () => {
|
||||
mockGetEnabledAgents.mockReturnValue([{
|
||||
type: 'apprise',
|
||||
url: STATELESS,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls: 'discord://token mailto://a@b.com' }),
|
||||
}]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok');
|
||||
expect(mockFetch).toHaveBeenCalledWith(STATELESS, expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: 'ok',
|
||||
type: 'info',
|
||||
urls: 'discord://token mailto://a@b.com',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('treats HTTP 204 as non-retryable failure and records dispatch_error', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute()]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
||||
99,
|
||||
expect.stringContaining('HTTP 204'),
|
||||
);
|
||||
const errText = String(mockUpdateNotificationDispatchError.mock.calls[0][1]);
|
||||
expect(errText).not.toContain('key-secret');
|
||||
});
|
||||
|
||||
it('classifies 4xx as non-retryable and 5xx as retryable via testDispatch', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 400 });
|
||||
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
||||
message: expect.stringContaining('HTTP 400'),
|
||||
retryable: false,
|
||||
} satisfies Partial<NotificationDeliveryError>);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
||||
message: expect.stringContaining('HTTP 503'),
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps network failures as retryable sanitized errors', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
||||
message: 'Apprise request failed',
|
||||
retryable: true,
|
||||
status: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fetch when stored config is malformed and records a sanitized error', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeAppriseRoute({ config: '{broken', channel_url: KEYED }),
|
||||
]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
||||
99,
|
||||
expect.stringContaining('Apprise configuration is missing or invalid'),
|
||||
);
|
||||
expect(String(mockUpdateNotificationDispatchError.mock.calls[0][1])).not.toContain('key-secret');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Additive `config` columns on agents and notification_routes.
|
||||
* Exercises production DatabaseService startup against a pre-config schema.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
function resetDatabaseSingleton(): void {
|
||||
const holder = DatabaseService as unknown as { instance?: DatabaseService };
|
||||
const existing = holder.instance;
|
||||
if (existing) {
|
||||
try {
|
||||
existing.getDb().close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
holder.instance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('notification channel config column migration', () => {
|
||||
let scratchDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
resetDatabaseSingleton();
|
||||
if (scratchDir) {
|
||||
try {
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
scratchDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds config columns via DatabaseService startup and preserves legacy row values', () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-apprise-mig-'));
|
||||
const dbPath = path.join(scratchDir, 'sencho.db');
|
||||
const seed = new Database(dbPath);
|
||||
try {
|
||||
seed.exec(`
|
||||
CREATE TABLE agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 0
|
||||
);
|
||||
INSERT INTO agents (node_id, type, url, enabled)
|
||||
VALUES (1, 'discord', 'https://discord.example/webhook/legacy', 1);
|
||||
|
||||
CREATE TABLE notification_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
channel_type TEXT NOT NULL,
|
||||
channel_url TEXT NOT NULL,
|
||||
stack_patterns TEXT NOT NULL DEFAULT '[]',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO notification_routes
|
||||
(name, channel_type, channel_url, stack_patterns, priority, enabled, created_at, updated_at)
|
||||
VALUES ('Legacy', 'slack', 'https://hooks.slack.com/services/legacy', '[]', 0, 1, 1, 1);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const agentCols = db.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>;
|
||||
const routeCols = db.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>;
|
||||
expect(agentCols.filter(c => c.name === 'config')).toHaveLength(1);
|
||||
expect(routeCols.filter(c => c.name === 'config')).toHaveLength(1);
|
||||
|
||||
const agent = db.getDb().prepare('SELECT type, url, config FROM agents WHERE type = ?').get('discord') as {
|
||||
type: string; url: string; config: string | null;
|
||||
};
|
||||
expect(agent.url).toBe('https://discord.example/webhook/legacy');
|
||||
expect(agent.config).toBeNull();
|
||||
|
||||
const route = db.getDb().prepare('SELECT name, channel_url, config FROM notification_routes WHERE name = ?').get('Legacy') as {
|
||||
name: string; channel_url: string; config: string | null;
|
||||
};
|
||||
expect(route.channel_url).toBe('https://hooks.slack.com/services/legacy');
|
||||
expect(route.config).toBeNull();
|
||||
|
||||
// Idempotent reopen: columns stay singular and values stay intact.
|
||||
resetDatabaseSingleton();
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
const db2 = DatabaseService.getInstance();
|
||||
const agentCols2 = db2.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>;
|
||||
expect(agentCols2.filter(c => c.name === 'config')).toHaveLength(1);
|
||||
const agent2 = db2.getDb().prepare('SELECT url, config FROM agents WHERE type = ?').get('discord') as {
|
||||
url: string; config: string | null;
|
||||
};
|
||||
expect(agent2.url).toBe('https://discord.example/webhook/legacy');
|
||||
expect(agent2.config).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('POST /api/agents - validation', () => {
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'telegram', url: 'https://example.com/hook', enabled: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('discord, slack, webhook');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
});
|
||||
|
||||
it('rejects non-HTTPS url', async () => {
|
||||
@@ -346,7 +346,7 @@ describe('POST /api/notification-routes - validation', () => {
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'test', stack_patterns: ['app'], channel_type: 'telegram', channel_url: 'https://example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('discord, slack, webhook');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
});
|
||||
|
||||
it('rejects non-HTTPS channel_url', async () => {
|
||||
@@ -464,6 +464,282 @@ describe('POST /api/notification-routes/:id/test', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Apprise notification routes - redaction and preserve-on-write', () => {
|
||||
const keyedUrl = 'http://apprise.local/notify/route-key-secret';
|
||||
const serviceUrl = 'mailto://user:pass@smtp.example.com?to=ops@example.com';
|
||||
let routeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise route',
|
||||
stack_patterns: ['app'],
|
||||
channel_type: 'apprise',
|
||||
channel_url: keyedUrl,
|
||||
config: { tags: 'ops' },
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
routeId = res.body.id;
|
||||
});
|
||||
|
||||
it('POST 201 and GET redact the keyed endpoint path', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise create redact',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: keyedUrl,
|
||||
config: { tags: 'night' },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(created.body.config).toMatchObject({ mode: 'keyed', tags: 'night', has_urls: false });
|
||||
expect(JSON.stringify(created.body)).not.toContain('route-key-secret');
|
||||
|
||||
const listed = await request(app).get('/api/notification-routes').set('Cookie', authCookie);
|
||||
const row = listed.body.find((r: { id: number }) => r.id === created.body.id);
|
||||
expect(row.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(JSON.stringify(row)).not.toContain('route-key-secret');
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${created.body.id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('POST 201 for stateless mode returns providers and url_count only', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise stateless',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify',
|
||||
config: { urls: serviceUrl },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.channel_url).toBe('http://apprise.local/notify');
|
||||
expect(res.body.config).toMatchObject({
|
||||
mode: 'stateless',
|
||||
has_urls: true,
|
||||
providers: ['mailto'],
|
||||
url_count: 1,
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain('pass@');
|
||||
expect(JSON.stringify(res.body)).not.toContain('ops@example.com');
|
||||
|
||||
const stored = DatabaseService.getInstance().getNotificationRoute(res.body.id);
|
||||
expect(stored?.channel_url).toBe('http://apprise.local/notify');
|
||||
expect(stored?.config).toContain(serviceUrl);
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${res.body.id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('PUT preserves secrets when channel_url and config are omitted', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'Apprise route renamed', enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Apprise route renamed');
|
||||
expect(res.body.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(JSON.stringify(res.body)).not.toContain('route-key-secret');
|
||||
|
||||
const stored = DatabaseService.getInstance().getNotificationRoute(routeId);
|
||||
expect(stored?.channel_url).toBe(keyedUrl);
|
||||
expect(stored?.config).toBe(JSON.stringify({ tags: 'ops' }));
|
||||
});
|
||||
|
||||
it('PUT rejects channel_type change without a new channel_url and leaves the row unchanged', async () => {
|
||||
const before = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(routeId) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(before.channel_type).toBe('apprise');
|
||||
expect(before.channel_url.startsWith('enc:')).toBe(true);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ channel_type: 'discord' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/channel_url is required when changing channel_type/i);
|
||||
|
||||
const after = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(routeId) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it('PUT Apprise-to-Discord with a raw URL stores plaintext Discord credentials', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise leave',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/leave-key',
|
||||
config: { tags: 'ops' },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const discordUrl = 'https://discord.com/api/webhooks/1/token-secret';
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ channel_type: 'discord', channel_url: discordUrl });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.channel_type).toBe('discord');
|
||||
|
||||
const raw = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(id) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(raw.channel_type).toBe('discord');
|
||||
expect(raw.channel_url).toBe(discordUrl);
|
||||
expect(raw.channel_url.startsWith('enc:')).toBe(false);
|
||||
expect(raw.config).toBeNull();
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('PUT webhook-to-Apprise seals both channel_url and config as enc: ciphertext', async () => {
|
||||
const notifyUrl = 'https://apprise.example/notify/promote-key';
|
||||
const created = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Webhook promote',
|
||||
stack_patterns: [],
|
||||
channel_type: 'webhook',
|
||||
channel_url: notifyUrl,
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const before = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_url FROM notification_routes WHERE id = ?')
|
||||
.get(id) as { channel_url: string };
|
||||
expect(before.channel_url).toBe(notifyUrl);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
channel_type: 'apprise',
|
||||
channel_url: notifyUrl,
|
||||
config: { tags: 'fleet' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.channel_type).toBe('apprise');
|
||||
expect(res.body.channel_url).toBe('https://apprise.example/notify/<redacted>');
|
||||
|
||||
const raw = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(id) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(raw.channel_type).toBe('apprise');
|
||||
expect(raw.channel_url.startsWith('enc:')).toBe(true);
|
||||
expect(raw.channel_url).not.toContain('promote-key');
|
||||
expect(raw.config?.startsWith('enc:')).toBe(true);
|
||||
expect(raw.config).not.toContain('fleet');
|
||||
|
||||
const stored = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
expect(stored?.channel_url).toBe(notifyUrl);
|
||||
expect(stored?.config).toBe(JSON.stringify({ tags: 'fleet' }));
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('PUT rejects a redacted channel_url', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ channel_url: 'http://apprise.local/notify/<redacted>' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PUT rejects public DTO config shapes', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
channel_url: keyedUrl,
|
||||
config: { mode: 'keyed', has_urls: false, providers: [] },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('stored-route test uses raw config and returns sanitized 204 errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const ok = await request(app)
|
||||
.post(`/api/notification-routes/${routeId}/test`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
keyedUrl,
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: '🔌 Test Notification from Sencho!',
|
||||
type: 'info',
|
||||
tag: 'ops',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||
try {
|
||||
const fail = await request(app)
|
||||
.post(`/api/notification-routes/${routeId}/test`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(fail.status).toBe(500);
|
||||
expect(JSON.stringify(fail.body)).toContain('HTTP 204');
|
||||
expect(JSON.stringify(fail.body)).not.toContain('route-key-secret');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('stored-route test fails closed on malformed config without fetch', async () => {
|
||||
const broken = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Broken Apprise',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/broken-key',
|
||||
config: { tags: 'x' },
|
||||
});
|
||||
expect(broken.status).toBe(201);
|
||||
const id = broken.body.id as number;
|
||||
DatabaseService.getInstance().updateNotificationRoute(id, { config: '{not-json' });
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/notification-routes/${id}/test`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(res.body)).not.toContain('broken-key');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- DELETE /api/notifications/:id NaN guard ---
|
||||
|
||||
describe('DELETE /api/notifications/:id - validation', () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Agent slot on dashboard/fleet configuration responses. */
|
||||
export interface AgentStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ConfigurationAgents = {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise: AgentStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Older remotes omit `apprise`. Default the slot so mixed-version hubs do not
|
||||
* treat the response as malformed or crash UI consumers.
|
||||
*/
|
||||
export function normalizeConfigurationAgents(agents: {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise?: AgentStatus;
|
||||
}): ConfigurationAgents {
|
||||
return {
|
||||
discord: agents.discord,
|
||||
slack: agents.slack,
|
||||
webhook: agents.webhook,
|
||||
apprise: agents.apprise ?? { configured: false, enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a remote `/dashboard/configuration` body when it carries an agents
|
||||
* block. Stub or partial JSON without `notifications.agents` is returned as-is
|
||||
* so a successful fetch still marks the node online.
|
||||
*/
|
||||
export function normalizeRemoteConfigurationStatus<T extends object>(raw: T): T {
|
||||
const notifications = (raw as { notifications?: { agents?: Parameters<typeof normalizeConfigurationAgents>[0] } }).notifications;
|
||||
if (!notifications?.agents) return raw;
|
||||
return {
|
||||
...raw,
|
||||
notifications: {
|
||||
...notifications,
|
||||
agents: normalizeConfigurationAgents(notifications.agents),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,54 @@
|
||||
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const;
|
||||
import type { Agent, NotificationRoute } from '../services/DatabaseService';
|
||||
|
||||
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const;
|
||||
export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number];
|
||||
|
||||
/** Write payload for Apprise agents/routes (never public DTO fields). */
|
||||
export type AppriseWriteConfig = { tags?: string } | { urls: string };
|
||||
|
||||
/** Persisted JSON shape after normalize (keyed empty is `{}`). */
|
||||
export type AppriseStoredConfig = { tags?: string } | { urls: string };
|
||||
|
||||
export type PublicAppriseConfig = {
|
||||
mode: 'keyed' | 'stateless';
|
||||
tags?: string;
|
||||
has_urls: boolean;
|
||||
providers?: string[];
|
||||
url_count?: number;
|
||||
};
|
||||
|
||||
export type PublicAgent = Omit<Agent, 'config' | 'type'> & {
|
||||
type: NotificationChannelType;
|
||||
config: PublicAppriseConfig | null;
|
||||
/** True only when Apprise masking was applied; Discord/Slack/webhook still return raw URLs. */
|
||||
secrets_redacted: boolean;
|
||||
};
|
||||
|
||||
export type PublicNotificationRoute = Omit<NotificationRoute, 'config' | 'channel_type'> & {
|
||||
channel_type: NotificationChannelType;
|
||||
config: PublicAppriseConfig | null;
|
||||
secrets_redacted: boolean;
|
||||
};
|
||||
|
||||
/** Apprise notify key: 1–128 alphanumeric, underscore, or dash (official API notes). */
|
||||
export const APPRISE_NOTIFY_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
|
||||
export type ParsedAppriseConfig =
|
||||
| { ok: true; mode: 'keyed'; tags?: string }
|
||||
| { ok: true; mode: 'stateless'; urls: string[]; urlsJoined: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const APPRISE_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
||||
const KEYED_KEYS = new Set(['tags']);
|
||||
const STATELESS_KEYS = new Set(['urls']);
|
||||
const INVALID_STORED = 'Apprise configuration is missing or invalid';
|
||||
|
||||
/** Path segment after `/notify/` when the path is `/notify/{key}` (key not yet validated). */
|
||||
function notifyKeyFromPath(path: string): string | null {
|
||||
const match = path.match(/^\/notify\/([^/]+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export const cleanStackPatterns = (patterns: string[]): string[] =>
|
||||
[...new Set(patterns.map(p => p.trim()).filter(Boolean))];
|
||||
|
||||
@@ -10,6 +58,260 @@ export function validateHttpsUrl(value: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyAppriseEndpoint(endpoint: string): 'keyed' | 'stateless' | null {
|
||||
try {
|
||||
const path = new URL(endpoint).pathname.replace(/\/$/, '');
|
||||
const key = notifyKeyFromPath(path);
|
||||
if (key !== null) return APPRISE_NOTIFY_KEY.test(key) ? 'keyed' : null;
|
||||
if (path === '/notify') return 'stateless';
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thin classifier for tests; write validation uses validateNotificationChannel. */
|
||||
export function classifyAppriseConfig(endpoint: string, config: unknown): { mode: 'keyed' | 'stateless'; urls?: string[] } | null {
|
||||
const mode = classifyAppriseEndpoint(endpoint);
|
||||
if (!mode) return null;
|
||||
if (mode === 'keyed') {
|
||||
if (config !== undefined && config !== null && (typeof config !== 'object' || Array.isArray(config))) return null;
|
||||
return { mode: 'keyed' };
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return null;
|
||||
const urls = typeof (config as { urls?: unknown }).urls === 'string'
|
||||
? splitServiceUrls((config as { urls: string }).urls)
|
||||
: [];
|
||||
return urls.length > 0 ? { mode: 'stateless', urls } : null;
|
||||
}
|
||||
|
||||
export function isPublicAppriseConfigShape(config: unknown): boolean {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
|
||||
const record = config as Record<string, unknown>;
|
||||
return 'has_urls' in record || 'providers' in record || 'url_count' in record || 'mode' in record || 'secrets_redacted' in record;
|
||||
}
|
||||
|
||||
function unknownKeyError(record: Record<string, unknown>, allowed: Set<string>): string | null {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!allowed.has(key)) return `unknown Apprise config field: ${key}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitServiceUrls(urls: string): string[] {
|
||||
return urls.split(/[\s,]+/).map(url => url.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateServiceUrlToken(token: string): string | null {
|
||||
if (token.length > 2000) return 'Apprise service URLs must be 2000 characters or fewer';
|
||||
if (!APPRISE_SCHEME.test(token)) return 'each Apprise service URL must include a URI scheme';
|
||||
return null;
|
||||
}
|
||||
|
||||
function asRecord(config: unknown): Record<string, unknown> | null {
|
||||
if (config === undefined || config === null) return {};
|
||||
if (typeof config !== 'object' || Array.isArray(config)) return null;
|
||||
return config as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function validateNotificationChannel(type: unknown, url: unknown, config?: unknown): string | null {
|
||||
if (typeof type !== 'string' || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
|
||||
return `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}`;
|
||||
}
|
||||
if (type !== 'apprise') return validateHttpsUrl(url);
|
||||
if (typeof url !== 'string') return 'must be a valid Apprise URL';
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { return 'is not a valid URL'; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || parsed.hash) {
|
||||
return 'must be an HTTP or HTTPS Apprise URL without credentials or a fragment';
|
||||
}
|
||||
if (isPublicAppriseConfigShape(config)) {
|
||||
return 'must use raw Apprise config ({ urls } or { tags }), not a public summary';
|
||||
}
|
||||
const path = parsed.pathname.replace(/\/$/, '');
|
||||
const notifyKey = notifyKeyFromPath(path);
|
||||
if (notifyKey !== null && !APPRISE_NOTIFY_KEY.test(notifyKey)) {
|
||||
return 'Apprise notify key must be 1–128 characters of letters, digits, underscore, or dash';
|
||||
}
|
||||
const mode = classifyAppriseEndpoint(url);
|
||||
if (!mode) return 'must use /notify or /notify/{key} with valid configuration';
|
||||
const record = asRecord(config);
|
||||
if (!record) return 'must use /notify or /notify/{key} with valid configuration';
|
||||
|
||||
if (mode === 'keyed') {
|
||||
const keyErr = unknownKeyError(record, KEYED_KEYS);
|
||||
if (keyErr) return keyErr;
|
||||
if (record.urls !== undefined) return 'keyed Apprise endpoints cannot include urls';
|
||||
if (record.tags !== undefined && typeof record.tags !== 'string') return 'Apprise tags must be a string';
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyErr = unknownKeyError(record, STATELESS_KEYS);
|
||||
if (keyErr) return keyErr;
|
||||
if (record.tags !== undefined) return 'stateless Apprise endpoints cannot include tags';
|
||||
if (typeof record.urls !== 'string' || !record.urls.trim()) {
|
||||
return 'stateless Apprise endpoints require a nonempty urls string';
|
||||
}
|
||||
const urls = splitServiceUrls(record.urls);
|
||||
if (urls.length === 0) return 'stateless Apprise endpoints require a nonempty urls string';
|
||||
for (const token of urls) {
|
||||
const tokenErr = validateServiceUrlToken(token);
|
||||
if (tokenErr) return tokenErr;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Persist shape: keyed empty → `{}`; omit empty tags; stateless → `{ urls }`. */
|
||||
export function normalizeAppriseStoredJson(endpoint: string, config: unknown): string {
|
||||
const mode = classifyAppriseEndpoint(endpoint);
|
||||
if (mode === 'keyed') {
|
||||
const record = asRecord(config) ?? {};
|
||||
const tags = typeof record.tags === 'string' ? record.tags.trim() : '';
|
||||
return tags ? JSON.stringify({ tags }) : '{}';
|
||||
}
|
||||
const record = asRecord(config) ?? {};
|
||||
const urls = typeof record.urls === 'string' ? record.urls.trim() : '';
|
||||
return JSON.stringify({ urls });
|
||||
}
|
||||
|
||||
export function parseStoredAppriseConfig(endpoint: string, configJson: string | null | undefined): ParsedAppriseConfig {
|
||||
const mode = classifyAppriseEndpoint(endpoint);
|
||||
if (!mode) return { ok: false, reason: INVALID_STORED };
|
||||
|
||||
if (configJson == null || configJson === '') {
|
||||
if (mode === 'keyed') return { ok: true, mode: 'keyed' };
|
||||
return { ok: false, reason: INVALID_STORED };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(configJson);
|
||||
} catch {
|
||||
return { ok: false, reason: INVALID_STORED };
|
||||
}
|
||||
|
||||
const err = validateNotificationChannel('apprise', endpoint, parsed);
|
||||
if (err) return { ok: false, reason: INVALID_STORED };
|
||||
|
||||
// validateNotificationChannel already confirmed parsed is a plain object
|
||||
// whose only fields match the mode (tags for keyed, urls for stateless),
|
||||
// so no further shape narrowing is needed here.
|
||||
if (mode === 'keyed') {
|
||||
const tags = typeof (parsed as { tags?: string }).tags === 'string' ? (parsed as { tags: string }).tags.trim() : '';
|
||||
return tags ? { ok: true, mode: 'keyed', tags } : { ok: true, mode: 'keyed' };
|
||||
}
|
||||
|
||||
const urlsJoined = (parsed as { urls: string }).urls.trim();
|
||||
const urls = splitServiceUrls(urlsJoined);
|
||||
return { ok: true, mode: 'stateless', urls, urlsJoined };
|
||||
}
|
||||
|
||||
export function storedAppriseToWriteConfig(parsed: Extract<ParsedAppriseConfig, { ok: true }>): AppriseWriteConfig {
|
||||
if (parsed.mode === 'keyed') {
|
||||
return parsed.tags ? { tags: parsed.tags } : {};
|
||||
}
|
||||
return { urls: parsed.urlsJoined };
|
||||
}
|
||||
|
||||
/** Reconstruct the write config to preserve on an agent/route write that omits `config`. */
|
||||
export function resolvePreservedAppriseConfig(
|
||||
endpoint: string,
|
||||
existingConfigJson: string | null | undefined,
|
||||
): { ok: true; config: AppriseWriteConfig } | { ok: false; error: string } {
|
||||
const stored = parseStoredAppriseConfig(endpoint, existingConfigJson);
|
||||
if (!stored.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Stored Apprise configuration is invalid for this endpoint; provide a complete replacement',
|
||||
};
|
||||
}
|
||||
return { ok: true, config: storedAppriseToWriteConfig(stored) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared preserve-on-write guard for agent/route Apprise writes: blocks a
|
||||
* write that echoes a redacted public URL, config, or DTO shape back as raw
|
||||
* input. Identical across `agents.ts` POST and `notification-routes` PUT.
|
||||
*/
|
||||
export function redactedChannelWriteError(
|
||||
type: string,
|
||||
effectiveUrl: unknown,
|
||||
effectiveConfig: unknown,
|
||||
rawConfig: unknown,
|
||||
): string | null {
|
||||
const configText = effectiveConfig === undefined ? '' : JSON.stringify(effectiveConfig) ?? '';
|
||||
if (
|
||||
(typeof effectiveUrl === 'string' && effectiveUrl.includes('<redacted>'))
|
||||
|| configText.includes('<redacted>')
|
||||
|| (type === 'apprise' && rawConfig !== undefined && isPublicAppriseConfigShape(rawConfig))
|
||||
) {
|
||||
return 'Provide raw channel credentials to replace redacted values';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function providerSchemes(urls: string[]): string[] {
|
||||
const schemes: string[] = [];
|
||||
for (const value of urls) {
|
||||
if (!APPRISE_SCHEME.test(value)) continue;
|
||||
const scheme = value.split(':', 1)[0].toLowerCase();
|
||||
if (scheme && !schemes.includes(scheme)) schemes.push(scheme);
|
||||
}
|
||||
return schemes;
|
||||
}
|
||||
|
||||
function publicAppriseConfig(url: string, config: string | null): PublicAppriseConfig | null {
|
||||
const parsed = parseStoredAppriseConfig(url, config);
|
||||
if (!parsed.ok) {
|
||||
console.warn(`[apprise] stored config unreadable for public DTO (${parsed.reason})`);
|
||||
return null;
|
||||
}
|
||||
if (parsed.mode === 'keyed') {
|
||||
return { mode: 'keyed', tags: parsed.tags, has_urls: false };
|
||||
}
|
||||
const providers = providerSchemes(parsed.urls);
|
||||
return {
|
||||
mode: 'stateless',
|
||||
has_urls: parsed.urls.length > 0,
|
||||
...(providers.length > 0 ? { providers } : {}),
|
||||
url_count: parsed.urls.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function maskAppriseEndpoint(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
const path = parsed.pathname.replace(/\/$/, '');
|
||||
const key = notifyKeyFromPath(path);
|
||||
if (key !== null && APPRISE_NOTIFY_KEY.test(key)) return `${parsed.origin}/notify/<redacted>`;
|
||||
return `${parsed.origin}/notify`;
|
||||
} catch {
|
||||
return '<invalid url>';
|
||||
}
|
||||
}
|
||||
|
||||
export function serializePublicAgent(agent: Agent): PublicAgent {
|
||||
const isApprise = agent.type === 'apprise';
|
||||
return {
|
||||
...agent,
|
||||
type: agent.type,
|
||||
url: isApprise ? maskAppriseEndpoint(agent.url) : agent.url,
|
||||
config: isApprise ? publicAppriseConfig(agent.url, agent.config ?? null) : null,
|
||||
secrets_redacted: isApprise,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePublicNotificationRoute(route: NotificationRoute): PublicNotificationRoute {
|
||||
const isApprise = route.channel_type === 'apprise';
|
||||
return {
|
||||
...route,
|
||||
channel_type: route.channel_type,
|
||||
channel_url: isApprise ? maskAppriseEndpoint(route.channel_url) : route.channel_url,
|
||||
config: isApprise ? publicAppriseConfig(route.channel_url, route.config ?? null) : null,
|
||||
secrets_redacted: isApprise,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a channel webhook URL for logging. Discord/Slack/custom webhook URLs
|
||||
* embed their auth token in the path (and sometimes the query), so only the
|
||||
@@ -20,8 +322,6 @@ export function maskWebhookUrl(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0) return '<no url>';
|
||||
try {
|
||||
const { origin, pathname, search } = new URL(value);
|
||||
// pathname is normalized to '/' for an origin-only URL, so anything else
|
||||
// (or any query string) is a token-bearing segment we must not log.
|
||||
const hasSecret = pathname !== '/' || search !== '';
|
||||
return hasSecret ? `${origin}/<redacted>` : origin;
|
||||
} catch {
|
||||
|
||||
@@ -4,7 +4,14 @@ import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl } from '../helpers/notificationChannels';
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_TYPES,
|
||||
normalizeAppriseStoredJson,
|
||||
redactedChannelWriteError,
|
||||
resolvePreservedAppriseConfig,
|
||||
serializePublicAgent,
|
||||
validateNotificationChannel,
|
||||
} from '../helpers/notificationChannels';
|
||||
|
||||
export const agentsRouter = Router();
|
||||
|
||||
@@ -12,7 +19,7 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const agents = DatabaseService.getInstance().getAgents(nodeId);
|
||||
res.json(agents);
|
||||
res.json(agents.map(serializePublicAgent));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agents:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch agents' });
|
||||
@@ -22,19 +29,36 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { type, url, enabled } = req.body;
|
||||
const { type, url, enabled, config } = req.body;
|
||||
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
|
||||
res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const urlErr = validateHttpsUrl(url);
|
||||
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled });
|
||||
const existing = DatabaseService.getInstance().getAgents(nodeId).find(agent => agent.type === type);
|
||||
const effectiveUrl = url === undefined ? existing?.url : url;
|
||||
|
||||
let effectiveConfig: unknown = config ?? null;
|
||||
if (type === 'apprise' && config === undefined && existing) {
|
||||
const resolved = resolvePreservedAppriseConfig(typeof effectiveUrl === 'string' ? effectiveUrl : existing.url, existing.config);
|
||||
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
|
||||
effectiveConfig = resolved.config;
|
||||
}
|
||||
|
||||
const redactedErr = redactedChannelWriteError(type, effectiveUrl, effectiveConfig, config);
|
||||
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
|
||||
const channelErr = validateNotificationChannel(type, effectiveUrl, effectiveConfig);
|
||||
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
|
||||
DatabaseService.getInstance().upsertAgent(nodeId, {
|
||||
type,
|
||||
url: effectiveUrl.trim(),
|
||||
enabled,
|
||||
config: type === 'apprise' ? normalizeAppriseStoredJson(effectiveUrl.trim(), effectiveConfig) : null,
|
||||
});
|
||||
console.log('[Agents] Agent %s updated', sanitizeForLog(type));
|
||||
if (isDebugEnabled()) console.log('[Agents:diag] Agent %s upsert: enabled=%s', sanitizeForLog(type), sanitizeForLog(enabled));
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -16,7 +16,7 @@ interface AgentStatus {
|
||||
export interface ConfigurationStatus {
|
||||
tier: LicenseTier;
|
||||
notifications: {
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
|
||||
alertRules: number;
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean };
|
||||
suppressionRules: { total: number; enabledCount: number };
|
||||
@@ -57,7 +57,7 @@ export function buildLocalConfigurationStatus(
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const agents = db.getAgents(nodeId);
|
||||
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
|
||||
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise'): AgentStatus => {
|
||||
const a = agents.find(ag => ag.type === type);
|
||||
return { configured: !!a?.url, enabled: a?.enabled ?? false };
|
||||
};
|
||||
@@ -96,6 +96,7 @@ export function buildLocalConfigurationStatus(
|
||||
discord: agentByType('discord'),
|
||||
slack: agentByType('slack'),
|
||||
webhook: agentByType('webhook'),
|
||||
apprise: agentByType('apprise'),
|
||||
},
|
||||
alertRules,
|
||||
// Notification routing is available on every tier.
|
||||
|
||||
@@ -50,6 +50,7 @@ import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
|
||||
import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign';
|
||||
import { MAX_ASSIGNMENTS } from '../helpers/constants';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
|
||||
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
|
||||
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
|
||||
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
|
||||
@@ -687,7 +688,8 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
);
|
||||
const configuration = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
|
||||
const raw = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
|
||||
const configuration = raw ? normalizeRemoteConfigurationStatus(raw) : null;
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
|
||||
@@ -7,9 +7,15 @@ import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requireNodeProxy } from '../middleware/tierGates';
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_TYPES,
|
||||
validateHttpsUrl,
|
||||
serializePublicNotificationRoute,
|
||||
validateNotificationChannel,
|
||||
cleanStackPatterns,
|
||||
maskWebhookUrl,
|
||||
normalizeAppriseStoredJson,
|
||||
parseStoredAppriseConfig,
|
||||
redactedChannelWriteError,
|
||||
resolvePreservedAppriseConfig,
|
||||
storedAppriseToWriteConfig,
|
||||
} from '../helpers/notificationChannels';
|
||||
import {
|
||||
deleteSuppressionRuleFromFleet,
|
||||
@@ -246,14 +252,14 @@ notificationsRouter.delete('/', authMiddleware, async (req: Request, res: Respon
|
||||
notificationsRouter.post('/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { type, url } = req.body;
|
||||
const { type, url, config } = req.body;
|
||||
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
|
||||
res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const urlErr = validateHttpsUrl(url);
|
||||
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
|
||||
await NotificationService.getInstance().testDispatch(type, url);
|
||||
const channelErr = validateNotificationChannel(type, url, config);
|
||||
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
|
||||
await NotificationService.getInstance().testDispatch(type, url, config);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
|
||||
@@ -266,7 +272,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const routes = DatabaseService.getInstance().getNotificationRoutes();
|
||||
res.json(routes);
|
||||
res.json(routes.map(serializePublicNotificationRoute));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notification routes:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notification routes' });
|
||||
@@ -276,7 +282,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
|
||||
notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
@@ -299,7 +305,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const channelUrlErr = validateHttpsUrl(channel_url);
|
||||
const channelUrlErr = validateNotificationChannel(channel_type, channel_url, config);
|
||||
if (channelUrlErr) { res.status(400).json({ error: `channel_url ${channelUrlErr}` }); return; }
|
||||
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
|
||||
res.status(400).json({ error: 'priority must be a finite number' });
|
||||
@@ -315,6 +321,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
categories: Array.isArray(categories) && categories.length > 0 ? (categories as NotificationCategory[]) : null,
|
||||
channel_type,
|
||||
channel_url: channel_url.trim(),
|
||||
config: channel_type === 'apprise' ? normalizeAppriseStoredJson(channel_url.trim(), config) : null,
|
||||
priority: typeof priority === 'number' ? priority : 0,
|
||||
enabled: enabled !== false,
|
||||
created_at: now,
|
||||
@@ -322,7 +329,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
});
|
||||
console.log(`[Routes] Route "${sanitizeForLog(route.name)}" created (id=${route.id})`);
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog(cleanedPatterns.join(', '))}], channel=${channel_type}`);
|
||||
res.status(201).json(route);
|
||||
res.status(201).json(serializePublicNotificationRoute(route));
|
||||
} catch (error) {
|
||||
console.error('Failed to create notification route:', error);
|
||||
res.status(500).json({ error: 'Failed to create notification route' });
|
||||
@@ -338,7 +345,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
const existing = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
|
||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||
res.status(400).json({ error: 'Name must be a non-empty string' });
|
||||
@@ -368,10 +375,29 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (channel_url !== undefined) {
|
||||
const urlErr = validateHttpsUrl(channel_url);
|
||||
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
|
||||
const typeChanged = channel_type !== undefined && channel_type !== existing.channel_type;
|
||||
// Type changes replace credentials; never reuse a prior channel's URL/config (ciphertext or plaintext).
|
||||
if (typeChanged && (typeof channel_url !== 'string' || !channel_url.trim())) {
|
||||
res.status(400).json({ error: 'channel_url is required when changing channel_type' });
|
||||
return;
|
||||
}
|
||||
const effectiveType = channel_type ?? existing.channel_type;
|
||||
const effectiveUrl = channel_url !== undefined ? String(channel_url).trim() : existing.channel_url;
|
||||
let effectiveConfig: unknown = config ?? null;
|
||||
if (effectiveType === 'apprise' && config === undefined) {
|
||||
if (typeChanged) {
|
||||
// Fresh Apprise credentials: empty keyed (or stateless urls required via validate).
|
||||
effectiveConfig = null;
|
||||
} else {
|
||||
const resolved = resolvePreservedAppriseConfig(effectiveUrl, existing.config);
|
||||
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
|
||||
effectiveConfig = resolved.config;
|
||||
}
|
||||
}
|
||||
const redactedErr = redactedChannelWriteError(effectiveType, effectiveUrl, effectiveConfig, config);
|
||||
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
|
||||
const urlErr = validateNotificationChannel(effectiveType, effectiveUrl, effectiveConfig);
|
||||
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
|
||||
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
|
||||
res.status(400).json({ error: 'priority must be a finite number' });
|
||||
return;
|
||||
@@ -388,7 +414,9 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null;
|
||||
if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null;
|
||||
if (channel_type !== undefined) updates.channel_type = channel_type;
|
||||
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
|
||||
if (channel_url !== undefined || typeChanged) updates.channel_url = effectiveUrl;
|
||||
if (effectiveType === 'apprise') updates.config = normalizeAppriseStoredJson(effectiveUrl, effectiveConfig);
|
||||
else if (typeChanged || channel_type !== undefined) updates.config = null;
|
||||
if (priority !== undefined) updates.priority = priority;
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
|
||||
@@ -397,7 +425,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
const updated = db.getNotificationRoute(id);
|
||||
console.log(`[Routes] Route ${id} updated`);
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Route ${id} update fields: ${Object.keys(updates).filter(k => k !== 'updated_at')}`);
|
||||
res.json(updated);
|
||||
res.json(serializePublicNotificationRoute(updated!));
|
||||
} catch (error) {
|
||||
console.error('Failed to update notification route:', error);
|
||||
res.status(500).json({ error: 'Failed to update notification route' });
|
||||
@@ -430,7 +458,18 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request,
|
||||
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${maskWebhookUrl(route.channel_url)})`);
|
||||
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
|
||||
let testConfig: unknown;
|
||||
if (route.channel_type === 'apprise') {
|
||||
const stored = parseStoredAppriseConfig(route.channel_url, route.config);
|
||||
if (!stored.ok) {
|
||||
res.status(400).json({ error: stored.reason });
|
||||
return;
|
||||
}
|
||||
testConfig = storedAppriseToWriteConfig(stored);
|
||||
} else {
|
||||
testConfig = route.config ? JSON.parse(route.config) : undefined;
|
||||
}
|
||||
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url, testConfig);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
|
||||
|
||||
@@ -16,9 +16,10 @@ function isPilotMode(): boolean {
|
||||
|
||||
export interface Agent {
|
||||
id?: number;
|
||||
type: 'discord' | 'slack' | 'webhook';
|
||||
type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
config?: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalSetting {
|
||||
@@ -613,8 +614,9 @@ export interface NotificationRoute {
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: string[] | null;
|
||||
channel_type: 'discord' | 'slack' | 'webhook';
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
channel_url: string;
|
||||
config?: string | null;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
@@ -909,6 +911,7 @@ export class DatabaseService {
|
||||
this.migrateNotificationRoutes();
|
||||
this.migrateNotificationRoutesNodeId();
|
||||
this.migrateNotificationRoutesMatchers();
|
||||
this.migrateNotificationChannelConfig();
|
||||
this.migrateNotificationSuppressionRules();
|
||||
this.migrateNotificationHistoryContext();
|
||||
this.migrateScanPolicyFleetColumns();
|
||||
@@ -1907,6 +1910,11 @@ export class DatabaseService {
|
||||
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
|
||||
}
|
||||
|
||||
private migrateNotificationChannelConfig(): void {
|
||||
this.tryAddColumn('agents', 'config', 'TEXT NULL');
|
||||
this.tryAddColumn('notification_routes', 'config', 'TEXT NULL');
|
||||
}
|
||||
|
||||
private migrateNotificationSuppressionRules(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
|
||||
@@ -2295,36 +2303,101 @@ export class DatabaseService {
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
/** Encrypt Apprise secrets at rest so a downgraded binary's SELECT * cannot return raw keys/URLs. */
|
||||
private sealAppriseSecret(value: string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
if (value === '') return value;
|
||||
const crypto = CryptoService.getInstance();
|
||||
return crypto.isEncrypted(value) ? value : crypto.encrypt(value);
|
||||
}
|
||||
|
||||
private openAppriseSecret(value: string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
return CryptoService.getInstance().decrypt(value);
|
||||
}
|
||||
|
||||
/** Seal or pass through url/config depending on whether the channel is Apprise. */
|
||||
private storeAppriseFields(
|
||||
isApprise: boolean,
|
||||
url: string,
|
||||
config: string | null | undefined,
|
||||
): { url: string; config: string | null } {
|
||||
if (!isApprise) return { url, config: config ?? null };
|
||||
return {
|
||||
url: this.sealAppriseSecret(url) ?? '',
|
||||
config: this.sealAppriseSecret(config ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt Apprise fields for one row. Corrupt ciphertext or a key mismatch
|
||||
* must not throw out of list/dispatch paths: one bad Apprise row would
|
||||
* otherwise 500 GET /agents and silently drop every notification channel.
|
||||
* Empty url/config forces the operator to re-enter credentials to repair.
|
||||
*/
|
||||
private loadAppriseFields(
|
||||
isApprise: boolean,
|
||||
url: string,
|
||||
config: string | null | undefined,
|
||||
): { url: string; config: string | null } {
|
||||
if (!isApprise) return { url, config: config ?? null };
|
||||
try {
|
||||
return {
|
||||
url: this.openAppriseSecret(url) ?? '',
|
||||
config: this.openAppriseSecret(config ?? null),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'[DatabaseService] Failed to decrypt Apprise credentials; isolating row:',
|
||||
(e as Error).message,
|
||||
);
|
||||
return { url: '', config: null };
|
||||
}
|
||||
}
|
||||
|
||||
private mapAgentRow(row: any): Agent {
|
||||
const type = row.type as Agent['type'];
|
||||
const fields = this.loadAppriseFields(type === 'apprise', row.url as string, row.config as string | null);
|
||||
return {
|
||||
...row,
|
||||
type,
|
||||
enabled: row.enabled === 1,
|
||||
url: fields.url,
|
||||
config: fields.config,
|
||||
};
|
||||
}
|
||||
|
||||
public getAgents(nodeId: number): Agent[] {
|
||||
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
|
||||
return stmt.all(nodeId).map((row: any) => ({
|
||||
...row,
|
||||
enabled: row.enabled === 1
|
||||
}));
|
||||
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
|
||||
}
|
||||
|
||||
public getEnabledAgents(nodeId: number): Agent[] {
|
||||
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
|
||||
return stmt.all(nodeId).map((row: any) => ({
|
||||
...row,
|
||||
enabled: row.enabled === 1
|
||||
}));
|
||||
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
|
||||
}
|
||||
|
||||
public upsertAgent(nodeId: number, agent: Agent): void {
|
||||
const stored = this.storeAppriseFields(agent.type === 'apprise', agent.url, agent.config);
|
||||
const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
|
||||
if (existing) {
|
||||
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE node_id = ? AND type = ?');
|
||||
stmt.run(agent.url, agent.enabled ? 1 : 0, nodeId, agent.type);
|
||||
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ? WHERE node_id = ? AND type = ?');
|
||||
stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, nodeId, agent.type);
|
||||
} else {
|
||||
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled) VALUES (?, ?, ?, ?)');
|
||||
stmt.run(nodeId, agent.type, agent.url, agent.enabled ? 1 : 0);
|
||||
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config) VALUES (?, ?, ?, ?, ?)');
|
||||
stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification Routes ---
|
||||
|
||||
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
|
||||
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
const fields = this.loadAppriseFields(
|
||||
channel_type === 'apprise',
|
||||
row.channel_url as string,
|
||||
row.config as string | null,
|
||||
);
|
||||
return {
|
||||
id: row.id as number,
|
||||
name: row.name as string,
|
||||
@@ -2332,8 +2405,9 @@ export class DatabaseService {
|
||||
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
|
||||
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
|
||||
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
|
||||
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
|
||||
channel_url: row.channel_url as string,
|
||||
channel_type,
|
||||
channel_url: fields.url,
|
||||
config: fields.config,
|
||||
priority: row.priority as number,
|
||||
enabled: row.enabled === 1,
|
||||
created_at: row.created_at as number,
|
||||
@@ -2366,8 +2440,9 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
|
||||
const stored = this.storeAppriseFields(route.channel_type === 'apprise', route.channel_url, route.config);
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
route.name,
|
||||
route.node_id ?? null,
|
||||
@@ -2375,7 +2450,8 @@ export class DatabaseService {
|
||||
route.label_ids ? JSON.stringify(route.label_ids) : null,
|
||||
route.categories ? JSON.stringify(route.categories) : null,
|
||||
route.channel_type,
|
||||
route.channel_url,
|
||||
stored.url,
|
||||
stored.config,
|
||||
route.priority,
|
||||
route.enabled ? 1 : 0,
|
||||
route.created_at,
|
||||
@@ -2387,6 +2463,9 @@ export class DatabaseService {
|
||||
public updateNotificationRoute(id: number, updates: Partial<Omit<NotificationRoute, 'id' | 'created_at'>>): void {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
const existing = this.getNotificationRoute(id);
|
||||
const effectiveType = updates.channel_type ?? existing?.channel_type;
|
||||
const sealApprise = effectiveType === 'apprise';
|
||||
|
||||
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
|
||||
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
|
||||
@@ -2394,7 +2473,14 @@ export class DatabaseService {
|
||||
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
|
||||
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
|
||||
if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
|
||||
if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); }
|
||||
if (updates.channel_url !== undefined) {
|
||||
fields.push('channel_url = ?');
|
||||
values.push(sealApprise ? (this.sealAppriseSecret(updates.channel_url) ?? '') : updates.channel_url);
|
||||
}
|
||||
if ('config' in updates) {
|
||||
fields.push('config = ?');
|
||||
values.push(sealApprise ? this.sealAppriseSecret(updates.config ?? null) : (updates.config ?? null));
|
||||
}
|
||||
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
|
||||
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
|
||||
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
matchesNotificationFilters,
|
||||
ruleNeedsStackLabels,
|
||||
} from '../helpers/notificationMatchers';
|
||||
import {
|
||||
type NotificationChannelType,
|
||||
type ParsedAppriseConfig,
|
||||
normalizeAppriseStoredJson,
|
||||
parseStoredAppriseConfig,
|
||||
validateNotificationChannel,
|
||||
} from '../helpers/notificationChannels';
|
||||
|
||||
export type NotificationCategory =
|
||||
| 'deploy_success'
|
||||
@@ -65,7 +72,13 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
|
||||
const WEBHOOK_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Valid notification channel types for defense-in-depth validation. */
|
||||
const ALLOWED_CHANNEL_TYPES = new Set(['discord', 'slack', 'webhook']);
|
||||
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise']);
|
||||
|
||||
export class NotificationDeliveryError extends Error {
|
||||
public constructor(message: string, public readonly status: number | null, public readonly retryable: boolean) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
@@ -251,7 +264,7 @@ export class NotificationService {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
|
||||
await Promise.allSettled(
|
||||
matched.map(route =>
|
||||
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized)
|
||||
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized, route.config)
|
||||
.then(() => {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via route "${sanitizeForLog(route.name)}" (${route.channel_type})`);
|
||||
})
|
||||
@@ -275,7 +288,7 @@ export class NotificationService {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Falling back to ${agents.length} global agent(s)`);
|
||||
await Promise.allSettled(
|
||||
agents.map(agent =>
|
||||
this.sendToChannel(agent.type, agent.url, level, sanitized)
|
||||
this.sendToChannel(agent.type, agent.url, level, sanitized, agent.config)
|
||||
.then(() => {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via global agent (${agent.type})`);
|
||||
})
|
||||
@@ -302,22 +315,66 @@ export class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string): Promise<void> {
|
||||
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null): Promise<void> {
|
||||
if (type === 'discord') {
|
||||
await this.sendDiscordWebhook(url, level, message);
|
||||
} else if (type === 'slack') {
|
||||
await this.sendSlackWebhook(url, level, message);
|
||||
} else if (type === 'webhook') {
|
||||
await this.sendCustomWebhook(url, level, message);
|
||||
} else if (type === 'apprise') {
|
||||
const parsed = parseStoredAppriseConfig(url, config);
|
||||
if (!parsed.ok) {
|
||||
throw new NotificationDeliveryError(parsed.reason, null, false);
|
||||
}
|
||||
await this.sendAppriseNotify(url, level, message, parsed);
|
||||
} else {
|
||||
throw new Error(`Unsupported channel type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async testDispatch(type: 'discord' | 'slack' | 'webhook', url: string) {
|
||||
public async testDispatch(type: NotificationChannelType, url: string, config?: unknown) {
|
||||
if (!ALLOWED_CHANNEL_TYPES.has(type)) throw new Error(`Invalid notification type: ${type}`);
|
||||
if (!url || !url.startsWith('https://')) throw new Error('URL must use HTTPS');
|
||||
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!');
|
||||
const validation = validateNotificationChannel(type, url, config);
|
||||
if (validation) throw new Error(`URL ${validation}`);
|
||||
const stored = type === 'apprise' ? normalizeAppriseStoredJson(url, config) : (config == null ? null : JSON.stringify(config));
|
||||
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!', stored);
|
||||
}
|
||||
|
||||
private async sendAppriseNotify(
|
||||
url: string,
|
||||
level: 'info' | 'warning' | 'error',
|
||||
message: string,
|
||||
config: Extract<ParsedAppriseConfig, { ok: true }>,
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
title: `Sencho Alert [${level.toUpperCase()}]`,
|
||||
body: message,
|
||||
type: level === 'error' ? 'failure' : level,
|
||||
};
|
||||
if (config.mode === 'stateless') payload.urls = config.urlsJoined;
|
||||
else if (config.tags) payload.tag = config.tags;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status === 204) throw new NotificationDeliveryError('Apprise returned no delivery (HTTP 204)', 204, false);
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new NotificationDeliveryError(`Apprise responded with HTTP ${response.status}`, response.status, false);
|
||||
}
|
||||
if (!response.ok) throw new NotificationDeliveryError(`Apprise responded with HTTP ${response.status}`, response.status, true);
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationDeliveryError) throw error;
|
||||
const aborted = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
|
||||
throw new NotificationDeliveryError(
|
||||
aborted ? 'Apprise request timed out' : 'Apprise request failed',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendDiscordWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {
|
||||
|
||||
Reference in New Issue
Block a user