mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
0ba09ebdee
* chore: bump brace-expansion and fast-uri via npm audit fix Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded intermediate arrays). Both transitive dev dependencies updated: - brace-expansion 5.0.8 -> 5.0.9 - fast-uri 3.1.4 -> 3.1.5 * chore: also bump frontend deps via npm audit fix Fixes brace-expansion and postcss in the frontend lockfile so npm audit --audit-level=high passes on both packages. * chore: bump ip-address transitive dep via npm audit fix Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr, GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior push and CI run. * feat: add ntfy notification channel Add ntfy (https://ntfy.sh) as the fifth notification channel alongside Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol: plain-text POST body with Content-Type, Title, Priority, and Tags headers. Priority maps info/warning/error to ntfy's default/high/urgent. URL validation allows both HTTP and HTTPS (common for LAN self-hosting) but rejects embedded credentials, consistent with Apprise. Token auth via ntfy's documented ?auth= query parameter is supported. * fix: correct ntfy channel test cases for Linux URL parsing and required type field - notification-channels.test.ts: replace http:///topic host check with a cross-platform invalid-URL case (WHATWG parser treats triple-slash authority differently on Linux vs Windows) - ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and inline agents fixtures (required by the expanded ConfigurationAgents type) * fix: remove unused import and update 0/4 masthead assertions to 0/5 * ci: exclude NotificationService.ts from js/request-forgery CodeQL rule Notification channel dispatch methods (Discord, Slack, Webhook, Apprise, ntfy) all call fetch() with admin-configured URLs and notification bodies that may embed stack or path data. This matches the trust model already documented for registry-api.ts: single-tenant self-hosted, admin owns the server, outbound posting is the intended behavior. The write path is gated by requireAdmin or requirePermission(node:manage), and every dispatch runs with a 10s AbortSignal.timeout. * ci: also exclude NotificationService.ts from js/file-access-to-http Notification messages may embed stack names, paths, or compose-derived content. Same trust model as js/request-forgery: admin owns the server and the configured endpoints, write path is gated. * fix: correct ntfy channel tab copy and validation error message The ntfy settings tab was reusing the generic webhook label, helper, and placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own copy: label names the server-and-topic URL, helper states plain-text delivery and the mandatory topic path, placeholder matches the routing section. Also fix the routing-rule validation toast: the guard correctly exempts ntfy from the HTTPS check but the error message was not updated alongside it, so ntfy URLs received a misleading HTTPS-required message. * fix: strip trailing slash from ntfy topic URL before dispatch A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips the trailing slash internally) but was stored and dispatched with the slash intact, causing the real ntfy server to 404. Normalize before fetch so the request reaches the correct topic path. Also add ntfy to the Channels card description in the settings registry.
213 lines
8.9 KiB
TypeScript
213 lines
8.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
|
|
const useConfigurationStatusMock = vi.fn();
|
|
vi.mock('../useConfigurationStatus', () => ({
|
|
useConfigurationStatus: () => useConfigurationStatusMock(),
|
|
}));
|
|
|
|
import { ConfigurationStatus } from '../ConfigurationStatus';
|
|
import type { ConfigurationStatus as ConfigurationStatusPayload } from '../useConfigurationStatus';
|
|
|
|
function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): ConfigurationStatusPayload {
|
|
return {
|
|
tier: 'community',
|
|
notifications: {
|
|
agents: {
|
|
discord: { configured: false, enabled: false },
|
|
slack: { configured: false, enabled: false },
|
|
webhook: { configured: false, enabled: false },
|
|
apprise: { configured: false, enabled: false },
|
|
ntfy: { configured: false, enabled: false },
|
|
},
|
|
alertRules: 0,
|
|
routingRules: { count: 0, enabledCount: 0, locked: true },
|
|
suppressionRules: { total: 0, enabledCount: 0 },
|
|
},
|
|
automation: {
|
|
autoHeal: { total: 0, enabled: 0 },
|
|
autoUpdate: { enabled: 0, total: 0 },
|
|
scheduledTasks: { total: 0, enabled: 0, locked: true },
|
|
webhooks: { total: 0, enabled: 0, locked: true },
|
|
},
|
|
security: {
|
|
mfaEnabled: null,
|
|
ssoEnabled: false,
|
|
ssoProvider: null,
|
|
trivyInstalled: false,
|
|
scanPolicies: { total: 0, enabled: 0, locked: false },
|
|
},
|
|
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true },
|
|
backup: { provider: 'disabled', autoUpload: false, locked: false },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
useConfigurationStatusMock.mockReset();
|
|
});
|
|
|
|
describe('ConfigurationStatus row visibility', () => {
|
|
it('renders a skeleton while loading', () => {
|
|
useConfigurationStatusMock.mockReturnValue({ status: null, loading: true });
|
|
render(<ConfigurationStatus />);
|
|
expect(screen.getByText('Configuration Status')).toBeDefined();
|
|
// Skeleton renders placeholder rows; assert the load-error message is NOT shown.
|
|
expect(screen.queryByText(/Unable to load configuration/i)).toBeNull();
|
|
});
|
|
|
|
it('renders an error state when the payload is null and not loading', () => {
|
|
useConfigurationStatusMock.mockReturnValue({ status: null, loading: false });
|
|
render(<ConfigurationStatus />);
|
|
expect(screen.getByText(/Unable to load configuration/i)).toBeDefined();
|
|
});
|
|
|
|
it('always shows the Automation section and its free rows, hiding only the per-row locked entries', () => {
|
|
useConfigurationStatusMock.mockReturnValue({ status: makePayload(), loading: false });
|
|
render(<ConfigurationStatus />);
|
|
|
|
// Automation moved to free: the section and its auto-heal / auto-update
|
|
// rows render for every tier.
|
|
expect(screen.getByText('Automation')).toBeDefined();
|
|
expect(screen.getByText('Auto-heal policies')).toBeDefined();
|
|
expect(screen.getByText('Auto-update schedules')).toBeDefined();
|
|
// Rows whose payload reports locked stay hidden.
|
|
expect(screen.queryByText('Routing')).toBeNull();
|
|
expect(screen.queryByText('Webhooks')).toBeNull();
|
|
expect(screen.queryByText('Scheduled tasks')).toBeNull();
|
|
// Scan policies are free, so the row renders.
|
|
expect(screen.getByText('Scan policies')).toBeDefined();
|
|
// Recovery Vault row is universal because Custom S3 is open to every tier.
|
|
expect(screen.getByText('Recovery Vault')).toBeDefined();
|
|
});
|
|
|
|
it('shows every row when the payload reports nothing locked', () => {
|
|
useConfigurationStatusMock.mockReturnValue({
|
|
status: makePayload({
|
|
tier: 'paid',
|
|
notifications: {
|
|
agents: {
|
|
discord: { configured: false, enabled: false },
|
|
slack: { configured: false, enabled: false },
|
|
webhook: { configured: false, enabled: false },
|
|
apprise: { configured: false, enabled: false },
|
|
ntfy: { configured: false, enabled: false },
|
|
},
|
|
alertRules: 2,
|
|
routingRules: { count: 1, enabledCount: 1, locked: false },
|
|
suppressionRules: { total: 0, enabledCount: 0 },
|
|
},
|
|
automation: {
|
|
autoHeal: { total: 3, enabled: 2 },
|
|
autoUpdate: { enabled: 4, total: 5 },
|
|
scheduledTasks: { total: 1, enabled: 1, locked: false },
|
|
webhooks: { total: 1, enabled: 1, locked: false },
|
|
},
|
|
security: {
|
|
mfaEnabled: true,
|
|
ssoEnabled: true,
|
|
ssoProvider: 'oidc_google',
|
|
trivyInstalled: true,
|
|
scanPolicies: { total: 2, enabled: 2, locked: false },
|
|
},
|
|
}),
|
|
loading: false,
|
|
});
|
|
render(<ConfigurationStatus />);
|
|
|
|
expect(screen.getByText('Automation')).toBeDefined();
|
|
expect(screen.getByText('Auto-heal policies')).toBeDefined();
|
|
expect(screen.getByText('Auto-update schedules')).toBeDefined();
|
|
expect(screen.getByText('Routing')).toBeDefined();
|
|
expect(screen.getByText('Webhooks')).toBeDefined();
|
|
expect(screen.getByText('Scheduled tasks')).toBeDefined();
|
|
expect(screen.getByText('Scan policies')).toBeDefined();
|
|
expect(screen.getByText('Recovery Vault')).toBeDefined();
|
|
// SSO label maps the provider to a friendly name.
|
|
expect(screen.getByText('Google')).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('ConfigurationStatus threshold display', () => {
|
|
it('renders threshold values when hostAlertsEnabled is true', () => {
|
|
useConfigurationStatusMock.mockReturnValue({
|
|
status: makePayload({ thresholds: { cpuLimit: 80, ramLimit: 85, diskLimit: 90, dockerJanitorGb: 5, globalCrash: true, hostAlertsEnabled: true } }),
|
|
loading: false,
|
|
});
|
|
render(<ConfigurationStatus />);
|
|
expect(screen.getByText('CPU 80% · RAM 85% · Disk 90%')).toBeDefined();
|
|
});
|
|
|
|
it('renders OFF badge when hostAlertsEnabled is false', () => {
|
|
useConfigurationStatusMock.mockReturnValue({
|
|
status: makePayload({
|
|
thresholds: { cpuLimit: 80, ramLimit: 85, diskLimit: 90, dockerJanitorGb: 5, globalCrash: true, hostAlertsEnabled: false },
|
|
}),
|
|
loading: false,
|
|
});
|
|
render(<ConfigurationStatus />);
|
|
// StatusBadge uppercases 'Off' to 'OFF'. Since backup.provider is
|
|
// 'disabled' (also rendered as OFF), there are two OFF badges.
|
|
// Verify the Alert thresholds row specifically shows OFF.
|
|
const thresholdRow = screen.getByText('Alert thresholds').closest('button');
|
|
expect(thresholdRow).toBeDefined();
|
|
// The OFF badge is the span inside the row that has font-mono + uppercase.
|
|
const badge = thresholdRow!.querySelector('.font-mono');
|
|
expect(badge?.textContent?.trim()).toBe('OFF');
|
|
});
|
|
});
|
|
|
|
describe('ConfigurationStatus click targets', () => {
|
|
it('routes Crash detection to container-alerts', () => {
|
|
const onOpenSection = vi.fn();
|
|
useConfigurationStatusMock.mockReturnValue({
|
|
status: makePayload(),
|
|
loading: false,
|
|
});
|
|
render(<ConfigurationStatus onOpenSection={onOpenSection} />);
|
|
const crashRow = screen.getByText('Crash detection').closest('button');
|
|
expect(crashRow).toBeDefined();
|
|
fireEvent.click(crashRow!);
|
|
expect(onOpenSection).toHaveBeenCalledWith('container-alerts');
|
|
});
|
|
});
|
|
|
|
describe('ConfigurationStatus legacy remote agents', () => {
|
|
it('renders a three-channel payload that omits apprise without throwing', () => {
|
|
const legacy = makePayload();
|
|
// Simulate older remote contract: no apprise key on the wire.
|
|
delete (legacy.notifications.agents as { apprise?: unknown }).apprise;
|
|
useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false });
|
|
expect(() => render(<ConfigurationStatus />)).not.toThrow();
|
|
const channelsRow = screen.getByText('Channels').closest('button');
|
|
expect(channelsRow?.textContent).toContain('None');
|
|
});
|
|
|
|
it('summarizes enabled legacy channels without requiring apprise', () => {
|
|
const legacy = makePayload({
|
|
notifications: {
|
|
agents: {
|
|
discord: { configured: true, enabled: true },
|
|
slack: { configured: false, enabled: false },
|
|
webhook: { configured: false, enabled: false },
|
|
} as ConfigurationStatusPayload['notifications']['agents'],
|
|
alertRules: 0,
|
|
routingRules: { count: 0, enabledCount: 0, locked: true },
|
|
suppressionRules: { total: 0, enabledCount: 0 },
|
|
},
|
|
});
|
|
useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false });
|
|
render(<ConfigurationStatus />);
|
|
expect(screen.getByText('Discord')).toBeDefined();
|
|
});
|
|
|
|
it('renders a payload that omits ntfy without throwing', () => {
|
|
const legacy = makePayload();
|
|
delete (legacy.notifications.agents as { ntfy?: unknown }).ntfy;
|
|
useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false });
|
|
expect(() => render(<ConfigurationStatus />)).not.toThrow();
|
|
const channelsRow = screen.getByText('Channels').closest('button');
|
|
expect(channelsRow?.textContent).toContain('None');
|
|
});
|
|
});
|