Add persistent TLS bypass warning banners

This commit is contained in:
rcourtman 2026-04-22 10:30:35 +01:00
parent 5aaa8d98b2
commit 7e4f1f474e
9 changed files with 409 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import {
formLabel,
formSelect,
} from '@/components/shared/Form';
import { TlsVerificationWarningBanner } from '@/components/shared/TlsVerificationWarningBanner';
import { MonitoredSystemAdmissionPreview } from '../../MonitoredSystemAdmissionPreview';
import type { TrueNASConnection } from '@/api/truenas';
import type { TrueNASSettingsPanelState } from '../../useTrueNASSettingsPanelState';
@ -191,6 +192,12 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
<span class={formHelpText}>Optional certificate pin for HTTPS connections.</span>
</label>
<div class="space-y-3 rounded-md border border-border bg-surface-alt px-4 py-3">
<Show when={props.state.form().insecureSkipVerify}>
<TlsVerificationWarningBanner
subject="this TrueNAS connection"
remediation="Install a trusted certificate or configure the TLS fingerprint before using this in production."
/>
</Show>
<label class="flex items-center gap-3">
<input
type="checkbox"

View file

@ -8,6 +8,7 @@ import {
formHelpText,
formLabel,
} from '@/components/shared/Form';
import { TlsVerificationWarningBanner } from '@/components/shared/TlsVerificationWarningBanner';
import { MonitoredSystemAdmissionPreview } from '../../MonitoredSystemAdmissionPreview';
import type { VMwareConnection } from '@/api/vmware';
import type { VMwareSettingsPanelState } from '../../useVMwareSettingsPanelState';
@ -141,6 +142,12 @@ export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props
</div>
<div class="space-y-3 rounded-md border border-border bg-surface-alt px-4 py-3">
<Show when={props.state.form().insecureSkipVerify}>
<TlsVerificationWarningBanner
subject="this vCenter connection"
remediation="Install a trusted certificate for vCenter before using this in production."
/>
</Show>
<label class="flex items-center gap-3">
<input
type="checkbox"

View file

@ -11,6 +11,8 @@ import connectionEditorSource from '../ConnectionEditor/ConnectionEditor.tsx?raw
import addressProbeStepSource from '../ConnectionEditor/AddressProbeStep.tsx?raw';
import connectionEditorStateSource from '../ConnectionEditor/useConnectionEditor.ts?raw';
import nodeCredentialSlotSource from '../ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx?raw';
import trueNASCredentialSlotSource from '../ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx?raw';
import vmwareCredentialSlotSource from '../ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx?raw';
import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw';
import diagnosticsModelSource from '../diagnosticsModel.ts?raw';
@ -149,6 +151,18 @@ describe('settings architecture guardrails', () => {
expect(nodeCredentialSlotSource).toContain('<NodeModalMonitoringSection');
expect(nodeCredentialSlotSource).toContain('<NodeModalStatusFooter');
expect(nodeCredentialSlotSource).not.toContain('<Dialog');
expect(vmwareCredentialSlotSource).toContain('TlsVerificationWarningBanner');
expect(vmwareCredentialSlotSource).toContain('subject="this vCenter connection"');
expect(vmwareCredentialSlotSource).toContain(
'Install a trusted certificate for vCenter before using this in production.',
);
expect(trueNASCredentialSlotSource).toContain('TlsVerificationWarningBanner');
expect(trueNASCredentialSlotSource).toContain('subject="this TrueNAS connection"');
expect(trueNASCredentialSlotSource).toContain(
'Install a trusted certificate or configure the TLS fingerprint before using this in production.',
);
});
it('keeps diagnostics commercial funnel rendering on the shared results/model boundary', () => {

View file

@ -78,6 +78,7 @@ import summaryMetricCardSource from '@/components/shared/SummaryMetricCard.tsx?r
import summaryPanelSource from '@/components/shared/SummaryPanel.tsx?raw';
import summarySynchronizedReadoutSource from '@/components/shared/SummarySynchronizedReadout.tsx?raw';
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
import tlsVerificationWarningBannerSource from '@/components/shared/TlsVerificationWarningBanner.tsx?raw';
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
import columnPickerStateSource from '@/components/shared/useColumnPickerState.ts?raw';
import tagInputStateSource from '@/components/shared/useTagInputState.ts?raw';
@ -587,6 +588,14 @@ describe('shared primitive guardrails', () => {
expect(reportingPanelSource).not.toContain('rounded-md border border-blue-200 bg-blue-50 p-6');
});
it('keeps TLS verification warnings in the shared primitive boundary', () => {
expect(tlsVerificationWarningBannerSource).toContain('role="alert"');
expect(tlsVerificationWarningBannerSource).toContain('TLS verification disabled.');
expect(tlsVerificationWarningBannerSource).toContain('controlled lab environments');
expect(tlsVerificationWarningBannerSource).toContain('Install a trusted certificate');
expect(tlsVerificationWarningBannerSource).not.toContain('CalloutCard');
});
it('keeps shared fleet limit banner copy on the monitored-system commercial term', () => {
expect(monitoredSystemLimitWarningBannerModelSource).toContain(
'@/utils/monitoredSystemPresentation',

View file

@ -0,0 +1,28 @@
import { splitProps, type JSX } from 'solid-js';
interface TlsVerificationWarningBannerProps extends JSX.HTMLAttributes<HTMLDivElement> {
subject: string;
remediation?: string;
}
const bannerClass =
'rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200';
export function TlsVerificationWarningBanner(props: TlsVerificationWarningBannerProps) {
const [local, rest] = splitProps(props, ['subject', 'remediation', 'class']);
return (
<div
role="alert"
class={`${bannerClass} ${local.class ?? ''}`.trim()}
{...rest}
>
<span class="font-medium">TLS verification disabled.</span>{' '}
Pulse will accept untrusted certificates for {local.subject}. Use this only for
controlled lab environments.{' '}
{local.remediation ?? 'Install a trusted certificate before using this in production.'}
</div>
);
}
export default TlsVerificationWarningBanner;

View file

@ -0,0 +1,29 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { TlsVerificationWarningBanner } from '../TlsVerificationWarningBanner';
describe('TlsVerificationWarningBanner', () => {
afterEach(() => cleanup());
it('renders the shared TLS risk copy with the supplied subject', () => {
render(() => <TlsVerificationWarningBanner subject="this connection" />);
expect(screen.getByRole('alert')).toHaveTextContent(
'TLS verification disabled. Pulse will accept untrusted certificates for this connection. Use this only for controlled lab environments. Install a trusted certificate before using this in production.',
);
});
it('renders custom remediation guidance when provided', () => {
render(() => (
<TlsVerificationWarningBanner
subject="this endpoint"
remediation="Install a trusted certificate on the endpoint before using this in production."
/>
));
expect(screen.getByRole('alert')).toHaveTextContent(
'Install a trusted certificate on the endpoint before using this in production.',
);
});
});

View file

@ -1,6 +1,7 @@
import { Show } from 'solid-js';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { TlsVerificationWarningBanner } from '@/components/shared/TlsVerificationWarningBanner';
import { Toggle } from '@/components/shared/Toggle';
import {
formControl,
@ -207,6 +208,13 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TLS_LABEL}
</label>
<Show when={props.config.skipTlsVerify}>
<TlsVerificationWarningBanner
class="mb-3"
subject="this Apprise API endpoint"
remediation="Install a trusted certificate on the Apprise server before using this in production."
/>
</Show>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"

View file

@ -9,6 +9,7 @@ import alertOverridesStateSource from '@/features/alerts/useAlertOverridesState.
import alertDestinationsModelSource from '@/features/alerts/alertDestinationsModel.ts?raw';
import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw';
import alertDestinationsTabStateSource from '@/features/alerts/useAlertDestinationsTabState.ts?raw';
import alertAppriseDestinationsSectionSource from '@/features/alerts/AlertAppriseDestinationsSection.tsx?raw';
import alertWebhookDestinationsStateSource from '@/features/alerts/useAlertWebhookDestinationsState.ts?raw';
import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowledgementState.ts?raw';
import alertHistoryAdministrationCardSource from '@/features/alerts/AlertHistoryAdministrationCard.tsx?raw';
@ -424,6 +425,11 @@ describe('tab path helpers', () => {
expect(alertDestinationsTabSource).not.toContain('ALERT_DESTINATIONS_EMAIL_PANEL_TITLE');
expect(alertDestinationsTabSource).not.toContain('ALERT_DESTINATIONS_APPRISE_PANEL_TITLE');
expect(alertDestinationsTabSource).not.toContain('getAlertWebhooksSectionTitle');
expect(alertAppriseDestinationsSectionSource).toContain('TlsVerificationWarningBanner');
expect(alertAppriseDestinationsSectionSource).toContain('subject="this Apprise API endpoint"');
expect(alertAppriseDestinationsSectionSource).toContain(
'Install a trusted certificate on the Apprise server before using this in production.',
);
expect(alertHistoryTabSource).not.toContain('useAlertIncidentTimelineState');
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getHistory');
expect(alertHistoryTabSource).not.toContain('AlertsAPI.getIncidentsForResource');

View file

@ -0,0 +1,301 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
const VMWARE_SCREENSHOT_PATH = path.resolve(
__dirname,
"..",
"..",
"tmp",
"vmware-tls-warning-banner.png",
);
const TRUENAS_SCREENSHOT_PATH = path.resolve(
__dirname,
"..",
"..",
"tmp",
"truenas-tls-warning-banner.png",
);
const APPRISE_SCREENSHOT_PATH = path.resolve(
__dirname,
"..",
"..",
"tmp",
"apprise-tls-warning-banner.png",
);
const buildSafeTrueNASAdmissionPreview = () => ({
current_count: 1,
projected_count: 1,
additional_count: 0,
limit: 10,
would_exceed_limit: false,
effect: "attaches_existing",
current_systems: [],
projected_systems: [],
current_system: null,
projected_system: null,
});
const buildSafeVMwareAdmissionPreview = () => ({
current_count: 1,
projected_count: 1,
additional_count: 0,
limit: 10,
would_exceed_limit: false,
effect: "attaches_existing",
current_systems: [],
projected_systems: [],
current_system: null,
projected_system: null,
});
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
"..",
"..",
"tmp",
"playwright-auth",
`tls-warning-banners-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
{ scope: "worker" },
],
});
async function mockVMwareConnections(page: import("@playwright/test").Page) {
await page.route("**/api/vmware/connections**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === "/api/vmware/connections" && request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
return;
}
if (
pathname === "/api/vmware/connections/preview" &&
request.method() === "POST"
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(buildSafeVMwareAdmissionPreview()),
});
return;
}
await route.continue();
});
}
async function mockTrueNASConnections(page: import("@playwright/test").Page) {
await page.route("**/api/truenas/connections**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === "/api/truenas/connections" && request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
return;
}
if (
pathname === "/api/truenas/connections/preview" &&
request.method() === "POST"
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(buildSafeTrueNASAdmissionPreview()),
});
return;
}
await route.continue();
});
}
async function mockAlertDestinations(page: import("@playwright/test").Page) {
await page.route("**/api/alerts/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
enabled: true,
activationState: "active",
overrides: {},
}),
});
});
await page.route("**/api/alerts/active", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/api/notifications/email", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
enabled: false,
provider: "",
server: "",
port: 587,
username: "",
password: "",
from: "",
to: [],
tls: false,
startTLS: false,
}),
});
});
await page.route("**/api/notifications/apprise", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
enabled: true,
mode: "http",
targets: [],
cliPath: "apprise",
timeoutSeconds: 15,
serverUrl: "https://apprise.lab.local",
configKey: "",
apiKey: "",
apiKeyHeader: "X-API-KEY",
skipTlsVerify: false,
}),
});
});
await page.route("**/api/notifications/webhooks", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
}
test.describe("TLS verification warning banners", () => {
test.setTimeout(180_000);
test("keeps a persistent warning visible while editing a VMware connection", async ({
page,
}) => {
await mockVMwareConnections(page);
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure/, {
timeout: 15_000,
});
await page.getByRole("button", { name: "Add infrastructure" }).click();
await page.getByRole("button", { name: "VMware" }).click();
await page.getByLabel("Skip TLS verification").check();
const warning = page.getByRole("alert").filter({
hasText: "this vCenter connection",
});
await expect(warning).toBeVisible();
await expect(warning).toContainText(
"Install a trusted certificate for vCenter before using this in production.",
);
fs.mkdirSync(path.dirname(VMWARE_SCREENSHOT_PATH), { recursive: true });
await page.screenshot({ path: VMWARE_SCREENSHOT_PATH, fullPage: true });
});
test("keeps a persistent warning visible while editing a TrueNAS connection", async ({
page,
}) => {
await mockTrueNASConnections(page);
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure/, {
timeout: 15_000,
});
await page.getByRole("button", { name: "Add infrastructure" }).click();
await page.getByRole("button", { name: "TrueNAS" }).click();
await page.getByLabel("Skip TLS verification").check();
const warning = page.getByRole("alert").filter({
hasText: "this TrueNAS connection",
});
await expect(warning).toBeVisible();
await expect(warning).toContainText(
"Install a trusted certificate or configure the TLS fingerprint before using this in production.",
);
fs.mkdirSync(path.dirname(TRUENAS_SCREENSHOT_PATH), { recursive: true });
await page.screenshot({ path: TRUENAS_SCREENSHOT_PATH, fullPage: true });
});
test("keeps a persistent warning visible while enabling Apprise self-signed certificates", async ({
page,
}) => {
await mockAlertDestinations(page);
await page.goto("/alerts/destinations", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/alerts\/destinations/, { timeout: 15_000 });
await expect(
page.getByRole("heading", { level: 1, name: "Notification Destinations" }),
).toBeVisible();
await page.getByLabel("Allow self-signed certificates").check();
const warning = page.getByRole("alert").filter({
hasText: "this Apprise API endpoint",
});
await expect(warning).toBeVisible();
await expect(warning).toContainText(
"Install a trusted certificate on the Apprise server before using this in production.",
);
fs.mkdirSync(path.dirname(APPRISE_SCREENSHOT_PATH), { recursive: true });
await page.screenshot({ path: APPRISE_SCREENSHOT_PATH, fullPage: true });
});
});