diff --git a/frontend-modern/src/components/UpdateBanner.test.tsx b/frontend-modern/src/components/UpdateBanner.test.tsx
new file mode 100644
index 000000000..cc50f119c
--- /dev/null
+++ b/frontend-modern/src/components/UpdateBanner.test.tsx
@@ -0,0 +1,102 @@
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// Mutable mock state shared across the hoisted vi.mock factories below.
+const { mock } = vi.hoisted(() => ({
+ mock: {
+ runtimeBuild: 'community' as string,
+ plan: {
+ canAutoUpdate: true,
+ requiresRoot: false,
+ rollbackSupport: false,
+ instructions: [] as string[],
+ },
+ },
+}));
+
+// runtime.build is the binary-edition signal (business-hooks presence), which
+// is what UpdateBanner keys the Pro path off of.
+vi.mock('@/stores/license', () => ({
+ runtimeCapabilities: () => ({ runtime: { build: mock.runtimeBuild } }),
+}));
+
+vi.mock('@/stores/updates', () => ({
+ updateStore: {
+ updateInfo: () => ({
+ available: true,
+ currentVersion: '6.0.0',
+ latestVersion: '6.1.0',
+ releaseNotes: '',
+ releaseDate: '',
+ downloadUrl:
+ 'https://github.com/rcourtman/Pulse/releases/download/v6.1.0/pulse-v6.1.0-linux-amd64.tar.gz',
+ isPrerelease: false,
+ isMajorUpgrade: false,
+ }),
+ versionInfo: () => ({ version: '6.0.0', deploymentType: 'systemd' }),
+ isUpdateVisible: () => true,
+ dismissUpdate: vi.fn(),
+ },
+}));
+
+vi.mock('@/api/updates', () => ({
+ UpdatesAPI: {
+ getUpdatePlan: vi.fn(() => Promise.resolve(mock.plan)),
+ applyUpdate: vi.fn(() => Promise.resolve()),
+ },
+}));
+
+vi.mock('./UpdateConfirmationModal', () => ({
+ UpdateConfirmationModal: () => null,
+}));
+
+import { UpdateBanner } from './UpdateBanner';
+
+const PORTAL_URL = 'https://pulserelay.pro/download.html';
+
+afterEach(() => {
+ cleanup();
+ mock.runtimeBuild = 'community';
+ mock.plan = { canAutoUpdate: true, requiresRoot: false, rollbackSupport: false, instructions: [] };
+});
+
+describe('UpdateBanner Pro edition guard', () => {
+ it('suppresses in-app apply and routes the Pro binary to the portal', () => {
+ mock.runtimeBuild = 'pro';
+
+ render(() => );
+
+ // The in-app apply affordance must never render for the Pro binary, even
+ // though the plan reports canAutoUpdate=true (systemd).
+ expect(screen.queryByRole('button', { name: /Apply Update/ })).not.toBeInTheDocument();
+
+ const portalLink = screen.getByRole('link', { name: /Private Release Access/ });
+ expect(portalLink).toHaveAttribute('href', PORTAL_URL);
+ });
+
+ it('shows the portal steps (archive + .sshsig) when the Pro banner is expanded', () => {
+ mock.runtimeBuild = 'pro';
+
+ render(() => );
+ fireEvent.click(screen.getByTitle('Show more'));
+
+ expect(screen.getByText('Pulse Pro update')).toBeInTheDocument();
+ expect(screen.getByText(/install\.sh --archive/)).toBeInTheDocument();
+ expect(screen.getByText(/\.sshsig/)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Apply Update/ })).not.toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Open Private Release Access/ })).toHaveAttribute(
+ 'href',
+ PORTAL_URL,
+ );
+ });
+
+ it('keeps the in-app apply button for the community binary', async () => {
+ mock.runtimeBuild = 'community';
+
+ render(() => );
+
+ // Apply appears once the (async) update plan resolves with canAutoUpdate.
+ expect(await screen.findByRole('button', { name: 'Apply Update' })).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: /Private Release Access/ })).not.toBeInTheDocument();
+ });
+});