mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 01:42:11 +02:00
Fanout hitlist fixes: bugs, quality, tests, webhook HMAC signing
This commit is contained in:
@@ -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: '',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user