Fanout hitlist fixes: bugs, quality, tests, webhook HMAC signing

This commit is contained in:
Jack Kingsman
2026-03-05 22:27:24 -08:00
parent 7534f0cc54
commit cb4333df4f
13 changed files with 840 additions and 54 deletions
@@ -777,19 +777,43 @@ function WebhookConfigEditor({
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-webhook-secret">Secret (optional)</Label>
<Input
id="fanout-webhook-secret"
type="password"
placeholder="Sent as X-Webhook-Secret header"
value={(config.secret as string) || ''}
onChange={(e) => onChange({ ...config, secret: e.target.value })}
/>
<Separator />
<div className="space-y-3">
<Label>HMAC Signing</Label>
<p className="text-xs text-muted-foreground">
When a secret is set, each request includes an HMAC-SHA256 signature of the JSON body in
the specified header (e.g. <code className="bg-muted px-1 rounded">sha256=ab12cd...</code>
).
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fanout-webhook-hmac-secret">HMAC Secret</Label>
<Input
id="fanout-webhook-hmac-secret"
type="password"
placeholder="Leave empty to disable signing"
value={(config.hmac_secret as string) || ''}
onChange={(e) => onChange({ ...config, hmac_secret: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-webhook-hmac-header">Signature Header Name</Label>
<Input
id="fanout-webhook-hmac-header"
type="text"
placeholder="X-Webhook-Signature"
value={(config.hmac_header as string) || ''}
onChange={(e) => onChange({ ...config, hmac_header: e.target.value })}
/>
</div>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="fanout-webhook-headers">Extra Headers (JSON)</Label>
<textarea
@@ -915,7 +939,8 @@ export function SettingsFanoutSection({
url: '',
method: 'POST',
headers: {},
secret: '',
hmac_secret: '',
hmac_header: '',
},
apprise: {
urls: '',
+156
View File
@@ -0,0 +1,156 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { SettingsFanoutSection } from '../components/settings/SettingsFanoutSection';
import type { HealthStatus, FanoutConfig } from '../types';
// Mock the api module
vi.mock('../api', () => ({
api: {
getFanoutConfigs: vi.fn(),
createFanoutConfig: vi.fn(),
updateFanoutConfig: vi.fn(),
deleteFanoutConfig: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
},
}));
// Suppress BotCodeEditor lazy load in tests
vi.mock('../components/BotCodeEditor', () => ({
BotCodeEditor: () => <textarea data-testid="bot-code-editor" />,
}));
import { api } from '../api';
const mockedApi = vi.mocked(api);
const baseHealth: HealthStatus = {
status: 'connected',
radio_connected: true,
connection_info: 'Serial: /dev/ttyUSB0',
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
};
const webhookConfig: FanoutConfig = {
id: 'wh-1',
type: 'webhook',
name: 'Test Hook',
enabled: true,
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
scope: { messages: 'all', raw_packets: 'none' },
sort_order: 0,
created_at: 1000,
};
function renderSection(overrides?: { health?: HealthStatus }) {
return render(
<SettingsFanoutSection
health={overrides?.health ?? baseHealth}
onHealthRefresh={vi.fn(async () => {})}
/>
);
}
beforeEach(() => {
vi.clearAllMocks();
mockedApi.getFanoutConfigs.mockResolvedValue([]);
mockedApi.getChannels.mockResolvedValue([]);
mockedApi.getContacts.mockResolvedValue([]);
});
describe('SettingsFanoutSection', () => {
it('shows add buttons for all integration types', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Private MQTT' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Webhook' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apprise' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Bot' })).toBeInTheDocument();
});
});
it('hides bot add button when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Bot' })).not.toBeInTheDocument();
});
});
it('shows bots disabled banner when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {
expect(screen.getByText(/Bot system is disabled/)).toBeInTheDocument();
});
});
it('lists existing configs after load', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => {
expect(screen.getByText('Test Hook')).toBeInTheDocument();
});
});
it('navigates to edit view when clicking edit', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => {
expect(screen.getByText('Test Hook')).toBeInTheDocument();
});
const editBtn = screen.getByRole('button', { name: 'Edit' });
fireEvent.click(editBtn);
await waitFor(() => {
expect(screen.getByText('← Back to list')).toBeInTheDocument();
});
});
it('calls toggle enabled on checkbox click', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
mockedApi.updateFanoutConfig.mockResolvedValue({ ...webhookConfig, enabled: false });
renderSection();
await waitFor(() => {
expect(screen.getByText('Test Hook')).toBeInTheDocument();
});
const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);
await waitFor(() => {
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('wh-1', { enabled: false });
});
});
it('navigates to create view when clicking add button', async () => {
const createdWebhook: FanoutConfig = {
id: 'wh-new',
type: 'webhook',
name: 'Webhook',
enabled: false,
config: { url: '', method: 'POST', headers: {} },
scope: { messages: 'all', raw_packets: 'none' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdWebhook);
// After creation, getFanoutConfigs returns the new config
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Webhook' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Webhook' }));
await waitFor(() => {
expect(screen.getByText('← Back to list')).toBeInTheDocument();
// Should show the URL input for webhook type
expect(screen.getByLabelText(/URL/)).toBeInTheDocument();
});
});
});