diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 408aff051..9e6629baf 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -89,6 +89,8 @@ management, and fleet control surfaces. 65. `frontend-modern/src/components/Settings/platformConnectionsModel.ts` 66. `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx` 67. `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts` +68. `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx` +69. `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts` ## Shared Boundaries @@ -125,7 +127,7 @@ management, and fleet control surfaces. 4. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice. 4. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance. 5. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`. -6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the API-backed platform connections workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, the split infrastructure install/reporting state owners, the split direct-node/discovery infrastructure settings owners plus their shared model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. +6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the API-backed platform connections workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, the split infrastructure install/reporting state owners, the split direct-node/discovery infrastructure settings owners plus their shared model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. 7. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential. 8. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles. 9. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership. @@ -531,6 +533,13 @@ may show one VMware connection's poll health, last error classification, and observed contribution summary, but it must not force the operator to manage separate Automation API versus VI JSON sessions or understand multi-client runtime wiring just to use the shared platform-connections path. +That same lifecycle-owned settings slice now also owns the shared VMware +summary and handoff framing. `InfrastructurePlatformConnectionsSummaryCard.tsx`, +`InfrastructureReportingPanel.tsx`, `useInfrastructureSettingsState.ts`, and +`useSettingsInfrastructurePanelProps.ts` must surface VMware availability and +connection counts from the same shared infrastructure settings state that owns +the VMware panel itself, rather than letting reporting cards or adjacent setup +surfaces grow a second VMware availability fetch or a VMware-only handoff path. That same infrastructure workspace boundary now also owns the first-run handoff copy for new operators. `InfrastructureWorkspace.tsx` must tell a new Pulse user to start with `Install on a host` to add the first monitored diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index fdd6cdf4a..58ef94a7d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -700,6 +700,21 @@ summary surface. Phase 1 must also keep the negative space explicit: no public `/api/vmware/events`, `/api/vmware/tasks`, or VMware control routes should be introduced while inventory, alerts, history, and Assistant reads still route through the shared canonical Pulse surfaces. +That same `/api/vmware/connections` family now also owns the current phase-1 +implementation contract under `internal/api/vmware_handlers.go`, +`internal/api/router.go`, `internal/api/router_routes_registration.go`, and +`frontend-modern/src/api/vmware.ts`. The list response must carry one redacted +stored connection shape plus canonical last-test status and observed +contribution summary (`hosts`, `vms`, `datastores`, `viRelease`) so the shared +settings workspace can render VMware status without another provider-local +inventory route. `POST /api/vmware/connections/test` must stay the draft test +surface, while `POST /api/vmware/connections/{id}/test` remains the saved +connection retest surface: a row-level saved retest with no payload should +refresh the stored runtime summary, but an edit-form test overlay must preserve +the stored summary until a real save succeeds. The explicit disabled path also +stays on this boundary: `404 vmware_disabled` means the operator or runtime has +opted out of the default-on VMware candidate, not that the platform requires a +different onboarding contract. That same backend API boundary now also owns the negative space around assistant control. Wiring native TrueNAS app actions into `internal/api/router.go`, `internal/api/ai_handler.go`, or adjacent backend diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 008016958..9cecc7f80 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -9,7 +9,7 @@ "contract_file": "docs/release-control/v6/internal/subsystems/frontend-primitives.md", "status_file": "docs/release-control/v6/internal/status.json", "registry_file": "docs/release-control/v6/internal/subsystems/registry.json", - "dependency_subsystem_ids": [] + "dependency_subsystem_ids": ["agent-lifecycle"] } ``` @@ -78,6 +78,7 @@ work extends shared components instead of creating new local variants. 47. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` 48. `frontend-modern/src/features/` 49. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` +50. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts` 50. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` 51. `frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx` 52. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` @@ -141,6 +142,7 @@ work extends shared components instead of creating new local variants. instead of introducing page-local framing 3. Add feature-specific presentation only when no shared primitive should own it 4. Add guardrail tests when a new shared pattern is introduced +5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary. ## Forbidden Paths @@ -257,16 +259,18 @@ work extends shared components instead of creating new local variants. `GeneralSettingsPanel.tsx` must state those facts plainly instead of reverting to a stronger but inaccurate shorthand. 16. Keep infrastructure settings-shell API alternatives on the shared shell - contract. `InfrastructureWorkspace.tsx`, `settingsHeaderMeta.ts`, - `settingsNavigationModel.ts`, and shared empty-state/setup guidance must + contract. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, + `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`, + `frontend-modern/src/components/Settings/settingsNavigationModel.ts`, and + shared empty-state/setup guidance must present `Platform connections` as the canonical API-backed alternative for Proxmox, TrueNAS, and future provider integrations instead of reviving top-level `Direct Proxmox` wording or shell-local provider routes. 17. Keep the infrastructure settings platform-connections summary and provider - workspaces on one shared state source. `useInfrastructureSettingsState.ts`, - `useSettingsInfrastructurePanelProps.ts`, - `InfrastructurePlatformConnectionsSummaryCard.tsx`, - `PlatformConnectionsWorkspace.tsx`, and `TrueNASSettingsPanel.tsx` must + workspaces on one shared state source. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, + `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, + `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, + `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, and `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx` must derive TrueNAS connection counts and availability from the shared infrastructure settings state instead of letting the reporting summary and the provider-specific panel issue separate connection fetches. @@ -1594,21 +1598,28 @@ reference cases, and locks that direct-root contract so single-surface pages do not quietly regain redundant outer spacing chrome. The same shared settings-shell boundary now also owns the API-backed -alternative path inside Infrastructure Operations. `InfrastructureWorkspace.tsx`, -`InfrastructurePlatformConnectionsSummaryCard.tsx`, `settingsHeaderMeta.ts`, -`settingsNavigationModel.ts`, `dashboardEmptyStatePresentation.ts`, -`infrastructureEmptyStatePresentation.ts`, and adjacent setup guidance must +alternative path inside Infrastructure Operations. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, +`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`, +`frontend-modern/src/components/Settings/settingsNavigationModel.ts`, `frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`, +`frontend-modern/src/utils/infrastructureEmptyStatePresentation.ts`, and adjacent setup guidance must treat `Platform connections` as the canonical API-backed alternative for Proxmox, TrueNAS, and future provider integrations instead of reviving top-level `Direct Proxmox` wording or shell-local provider routes. That same settings-shell contract also owns the shared platform-connections -summary state. `useInfrastructureSettingsState.ts`, -`useSettingsInfrastructurePanelProps.ts`, -`InfrastructurePlatformConnectionsSummaryCard.tsx`, -`PlatformConnectionsWorkspace.tsx`, and `TrueNASSettingsPanel.tsx` must derive -Proxmox/PBS/PMG/TrueNAS counts and availability from one shared infrastructure -settings state source instead of letting the reporting summary and the -provider-specific panel fetch the same TrueNAS connection state separately. +summary state. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, +`frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, +`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, +`frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, and +`frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx` must derive Proxmox/PBS/PMG/TrueNAS/VMware counts +and availability from one shared infrastructure settings state source instead +of letting the reporting summary and the provider-specific panels fetch the +same connection state separately. +That same shared settings-shell boundary also owns provider parity inside the +platform workspace. Adding VMware to the shared `Platform connections` +subtabs may extend the same card, empty-state, dialog, and summary-shell +patterns used by TrueNAS, but it must not introduce a VMware-only outer page +shell, alternate settings route hierarchy, or another summary vocabulary for +connection health and contribution counts. That same shared filter-presentation boundary also owns infrastructure route-filter continuity. `frontend-modern/src/features/infrastructure/` must keep a route-owned canonical source option such as `truenas` visible in diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 985f0e90f..f510e4293 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -91,6 +91,7 @@ querying, and the operator-facing storage health presentation layer. 25. Keep provider-backed poll cadence and settings-runtime health on the adjacent platform-connections contract. When shared `internal/api/` and poller wiring expose TrueNAS last-sync status, failure summaries, discovered contribution counts, manual saved-test status refresh, or platform handoff links in settings, storage and recovery may consume the resulting datasets, apps, disks, and recovery artifacts but must not redefine those settings-runtime health semantics or connection-level handoffs in storage/recovery-local transport or page flows. 26. Keep cross-surface recovery handoffs on the shared route-helper contract. When infrastructure or other unified-resource consumers expose TrueNAS recovery links, they must reuse the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder with owned `platform` and `node` queries instead of inventing drawer-local recovery URLs or treating PBS services as the only infrastructure-to-recovery path. 27. Keep alert-side recovery drill-ins on that same shared route-helper contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder instead of freezing alert-local recovery URLs or introducing another provider-shaped recovery handoff vocabulary. +28. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport. ## Forbidden Paths @@ -1635,3 +1636,11 @@ Storage and recovery must not treat vSphere snapshots, changed-disk/block visibility, or datastore presence as canonical Pulse recovery artifacts, restore capability, or recovery-backed Assistant control until a later governed slice adds those contracts explicitly. +That same storage/recovery boundary also keeps the onboarding runtime separate +from recovery semantics. `internal/api/router.go`, +`internal/api/router_routes_registration.go`, and +`internal/api/vmware_handlers.go` may expose VMware connection CRUD, saved-test +health refresh, and observed datastore/VM snapshot visibility on the shared +platform-connections surface, but storage and recovery must treat that data as +setup/runtime context only, not as proof that VMware has joined the canonical +recovery artifact or restore plane. diff --git a/frontend-modern/src/api/__tests__/vmware.test.ts b/frontend-modern/src/api/__tests__/vmware.test.ts new file mode 100644 index 000000000..9a8b6479a --- /dev/null +++ b/frontend-modern/src/api/__tests__/vmware.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { apiFetchJSON } from '@/utils/apiClient'; +import { VMwareAPI, isRedactedVMwareSecret } from '@/api/vmware'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +describe('VMwareAPI', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('normalizes listed connections from the API contract', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce([ + { + id: ' conn-1 ', + name: ' lab-vcenter ', + host: ' vcsa.lab.local ', + port: 443, + username: ' administrator@vsphere.local ', + password: ' ******** ', + insecureSkipVerify: true, + enabled: true, + test: { + lastAttemptAt: ' 2026-03-30T12:00:00Z ', + lastSuccessAt: ' 2026-03-30T12:00:01Z ', + }, + observed: { + collectedAt: ' 2026-03-30T12:00:02Z ', + hosts: 3, + vms: 42, + datastores: 6, + viRelease: ' 8.0.3 ', + }, + }, + ]); + + await expect(VMwareAPI.listConnections()).resolves.toEqual([ + { + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: true, + enabled: true, + test: { + lastAttemptAt: '2026-03-30T12:00:00Z', + lastSuccessAt: '2026-03-30T12:00:01Z', + lastError: undefined, + }, + observed: { + collectedAt: '2026-03-30T12:00:02Z', + hosts: 3, + vms: 42, + datastores: 6, + viRelease: '8.0.3', + }, + }, + ]); + }); + + it('creates, updates, deletes, and tests connections through canonical routes', async () => { + vi.mocked(apiFetchJSON) + .mockResolvedValueOnce({ + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + insecureSkipVerify: false, + enabled: true, + }) + .mockResolvedValueOnce({ + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + insecureSkipVerify: true, + enabled: false, + }) + .mockResolvedValueOnce({ success: true, id: 'conn-1' }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: true }); + + await VMwareAPI.createConnection({ + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: 'secret', + enabled: true, + }); + await VMwareAPI.updateConnection('conn/1', { + host: 'vcsa.lab.local', + port: 8443, + username: 'operator@vsphere.local', + password: '********', + insecureSkipVerify: true, + enabled: false, + }); + await VMwareAPI.deleteConnection('conn/1'); + await expect( + VMwareAPI.testConnection({ + host: 'vcsa.lab.local', + username: 'administrator@vsphere.local', + password: 'secret', + }), + ).resolves.toEqual({ success: true }); + await expect( + VMwareAPI.testSavedConnection('conn/1', { + host: 'vcsa.lab.local', + username: 'operator@vsphere.local', + password: '********', + insecureSkipVerify: true, + }), + ).resolves.toEqual({ success: true }); + + expect(apiFetchJSON).toHaveBeenNthCalledWith( + 1, + '/api/vmware/connections', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: 'secret', + enabled: true, + }), + }), + ); + expect(apiFetchJSON).toHaveBeenNthCalledWith( + 2, + '/api/vmware/connections/conn%2F1', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + host: 'vcsa.lab.local', + port: 8443, + username: 'operator@vsphere.local', + password: '********', + insecureSkipVerify: true, + enabled: false, + }), + }), + ); + expect(apiFetchJSON).toHaveBeenNthCalledWith( + 3, + '/api/vmware/connections/conn%2F1', + expect.objectContaining({ method: 'DELETE' }), + ); + expect(apiFetchJSON).toHaveBeenNthCalledWith( + 4, + '/api/vmware/connections/test', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + host: 'vcsa.lab.local', + username: 'administrator@vsphere.local', + password: 'secret', + }), + }), + ); + expect(apiFetchJSON).toHaveBeenNthCalledWith( + 5, + '/api/vmware/connections/conn%2F1/test', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + host: 'vcsa.lab.local', + username: 'operator@vsphere.local', + password: '********', + insecureSkipVerify: true, + }), + }), + ); + }); + + it('recognizes the masked-secret placeholder used for update preservation', () => { + expect(isRedactedVMwareSecret('********')).toBe(true); + expect(isRedactedVMwareSecret(' ******** ')).toBe(true); + expect(isRedactedVMwareSecret('secret')).toBe(false); + expect(isRedactedVMwareSecret(undefined)).toBe(false); + }); +}); diff --git a/frontend-modern/src/api/vmware.ts b/frontend-modern/src/api/vmware.ts new file mode 100644 index 000000000..e20bd0676 --- /dev/null +++ b/frontend-modern/src/api/vmware.ts @@ -0,0 +1,194 @@ +import { apiFetchJSON } from '@/utils/apiClient'; +import { + arrayOrUndefined, + finiteNumberOrUndefined, + optionalTrimmedString, + strictBoolean, + trimmedString, +} from './responseUtils'; + +const VMWARE_CONNECTIONS_PATH = '/api/vmware/connections'; + +const REDACTED_SECRET = '********'; + +type RawVMwareConnection = Partial; +type RawVMwareConnectionTestError = Partial; +type RawVMwareConnectionTest = Partial; +type RawVMwareConnectionObservedSummary = Partial; + +export interface VMwareConnectionTestError { + at?: string; + message?: string; + category?: string; +} + +export interface VMwareConnectionTestStatus { + lastAttemptAt?: string; + lastSuccessAt?: string; + lastError?: VMwareConnectionTestError; +} + +export interface VMwareConnectionObservedSummary { + collectedAt?: string; + hosts: number; + vms: number; + datastores: number; + viRelease?: string; +} + +export interface VMwareConnection { + id: string; + name: string; + host: string; + port?: number; + username?: string; + password?: string; + insecureSkipVerify: boolean; + enabled: boolean; + test?: VMwareConnectionTestStatus; + observed?: VMwareConnectionObservedSummary; +} + +export interface VMwareConnectionInput { + name?: string; + host: string; + port?: number; + username?: string; + password?: string; + insecureSkipVerify?: boolean; + enabled?: boolean; +} + +export interface VMwareConnectionTestResult { + success: boolean; +} + +const normalizeVMwareConnectionTestError = ( + error: RawVMwareConnectionTestError | undefined, +): VMwareConnectionTestError | undefined => { + if (!error || typeof error !== 'object') return undefined; + return { + at: optionalTrimmedString(error.at), + message: optionalTrimmedString(error.message), + category: optionalTrimmedString(error.category), + }; +}; + +const normalizeVMwareConnectionTest = ( + test: RawVMwareConnectionTest | undefined, +): VMwareConnectionTestStatus | undefined => { + if (!test || typeof test !== 'object') return undefined; + return { + lastAttemptAt: optionalTrimmedString(test.lastAttemptAt), + lastSuccessAt: optionalTrimmedString(test.lastSuccessAt), + lastError: normalizeVMwareConnectionTestError(test.lastError), + }; +}; + +const normalizeVMwareConnectionObservedSummary = ( + observed: RawVMwareConnectionObservedSummary | undefined, +): VMwareConnectionObservedSummary | undefined => { + if (!observed || typeof observed !== 'object') return undefined; + return { + collectedAt: optionalTrimmedString(observed.collectedAt), + hosts: finiteNumberOrUndefined(observed.hosts) ?? 0, + vms: finiteNumberOrUndefined(observed.vms) ?? 0, + datastores: finiteNumberOrUndefined(observed.datastores) ?? 0, + viRelease: optionalTrimmedString(observed.viRelease), + }; +}; + +const normalizeVMwareConnection = (connection: RawVMwareConnection): VMwareConnection => ({ + id: trimmedString(connection.id), + name: optionalTrimmedString(connection.name) ?? '', + host: trimmedString(connection.host), + port: finiteNumberOrUndefined(connection.port), + username: optionalTrimmedString(connection.username), + password: optionalTrimmedString(connection.password), + insecureSkipVerify: strictBoolean(connection.insecureSkipVerify), + enabled: strictBoolean(connection.enabled), + test: normalizeVMwareConnectionTest(connection.test), + observed: normalizeVMwareConnectionObservedSummary(connection.observed), +}); + +const serializeVMwareConnectionInput = (input: VMwareConnectionInput) => ({ + ...(input.name !== undefined ? { name: input.name } : {}), + host: input.host, + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.username !== undefined ? { username: input.username } : {}), + ...(input.password !== undefined ? { password: input.password } : {}), + ...(input.insecureSkipVerify !== undefined + ? { insecureSkipVerify: input.insecureSkipVerify } + : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), +}); + +export const isRedactedVMwareSecret = (value: string | null | undefined) => + (value || '').trim() === REDACTED_SECRET; + +export class VMwareAPI { + static async listConnections(): Promise { + const response = await apiFetchJSON(VMWARE_CONNECTIONS_PATH); + const list = arrayOrUndefined(response) ?? []; + return list.map(normalizeVMwareConnection); + } + + static async createConnection(input: VMwareConnectionInput): Promise { + const response = await apiFetchJSON(VMWARE_CONNECTIONS_PATH, { + method: 'POST', + body: JSON.stringify(serializeVMwareConnectionInput(input)), + }); + return normalizeVMwareConnection(response); + } + + static async updateConnection( + id: string, + input: VMwareConnectionInput, + ): Promise { + const response = await apiFetchJSON( + `${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}`, + { + method: 'PUT', + body: JSON.stringify(serializeVMwareConnectionInput(input)), + }, + ); + return normalizeVMwareConnection(response); + } + + static async deleteConnection(id: string): Promise<{ success: boolean; id: string }> { + return apiFetchJSON(`${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + } + + static async testConnection(input: VMwareConnectionInput): Promise { + const response = await apiFetchJSON>( + `${VMWARE_CONNECTIONS_PATH}/test`, + { + method: 'POST', + body: JSON.stringify(serializeVMwareConnectionInput(input)), + }, + ); + return { + success: strictBoolean(response.success), + }; + } + + static async testSavedConnection( + id: string, + input?: VMwareConnectionInput, + ): Promise { + const response = await apiFetchJSON>( + `${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}/test`, + { + method: 'POST', + ...(input !== undefined + ? { body: JSON.stringify(serializeVMwareConnectionInput(input)) } + : {}), + }, + ); + return { + success: strictBoolean(response.success), + }; + } +} diff --git a/frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx b/frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx index 3fa8ad75b..3e7466c71 100644 --- a/frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx +++ b/frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx @@ -7,6 +7,8 @@ interface InfrastructurePlatformConnectionsSummaryCardProps { pmgCount: number; truenasCount: number; truenasAvailable: boolean; + vmwareCount: number; + vmwareAvailable: boolean; onManagePlatformConnections: () => void; } @@ -20,8 +22,8 @@ export const InfrastructurePlatformConnectionsSummaryCard: Component<

Platform connections

- Manage the API-backed platforms Pulse polls directly. Proxmox VE, PBS, PMG, and - TrueNAS all live in the same shared platform-connections workspace. + Manage the API-backed platforms Pulse polls directly. Proxmox VE, PBS, PMG, TrueNAS, + and VMware all live in the same shared platform-connections workspace.

+ + + + + +
+ {getSettingsConfigurationLoadingState().text} +
+
+ + + +

{state.loadingError()}

+ + + } + /> +
+ + +
+

No VMware connections yet

+

+ Add the first vCenter endpoint Pulse should validate. +

+
+
+ + 0} + > +
+ + {(connection) => { + const health = () => getConnectionHealthPresentation(connection); + const observedMetrics = () => getConnectionObservedMetrics(connection); + + return ( +
+
+
+
+
+

+ {connection.name || connection.host} +

+ + {connection.enabled ? 'Enabled' : 'Disabled'} + + + {health().label} + +
+ +

+ {connection.host} + {connection.port ? `:${connection.port}` : ''} +

+ +
+ {connection.username || 'Username not set'} + + vCenter + + <> + + Skip TLS verification + + + + <> + + {health().detail} + + +
+ + +

+ {health().error} +

+
+
+ + +
+
+ + + Validated{' '} + {formatRelativeTime(connection.observed?.collectedAt, { + compact: true, + })} + + + + {(item) => ( + + {formatNumber(item.value)}{' '} + {pluralize(item.value, item.label)} + + )} + + + + VI JSON {connection.observed?.viRelease} + + +
+
+
+
+ +
+ + + +
+
+
+ ); + }} +
+
+
+ + + + + +
+
+

+ {state.editingConnection() ? 'Edit VMware connection' : 'Add VMware connection'} +

+

+ Configure the vCenter endpoint Pulse should validate for the VMware platform. +

+
+ +
+ + + + + +
+ +
+ + +
+ +
+ + + +
+
+
+ + +
+
+

Delete VMware connection

+

+ Remove{' '} + + {state.pendingDeleteConnection()?.name || state.pendingDeleteConnection()?.host} + {' '} + from the configured VMware platform connections. +

+
+ +
+ + +
+
+
+ + ); +}; + +export default VMwareSettingsPanel; diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx index 178eda1f7..615cb37c7 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx @@ -89,10 +89,14 @@ let securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false }; describe('InfrastructureOperationsController ownership guardrails', () => { it('routes controller and workspace panels through the shared infrastructure operations state owner', () => { - expect(infrastructureOperationsControllerSource).toContain('InfrastructureOperationsStateProvider'); + expect(infrastructureOperationsControllerSource).toContain( + 'InfrastructureOperationsStateProvider', + ); expect(infrastructureOperationsControllerSource).toContain('InfrastructureInstallerSection'); expect(infrastructureOperationsControllerSource).toContain('InfrastructureInventorySection'); - expect(infrastructureOperationsControllerSource).toContain('InfrastructureStopMonitoringDialog'); + expect(infrastructureOperationsControllerSource).toContain( + 'InfrastructureStopMonitoringDialog', + ); expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider'); expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection'); expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider'); @@ -102,11 +106,10 @@ describe('InfrastructureOperationsController ownership guardrails', () => { './InfrastructurePlatformConnectionsSummaryCard', ); expect(infrastructureReportingPanelSource).not.toContain('Platform connections'); - expect(infrastructurePlatformConnectionsSummaryCardSource).toContain( - 'Platform connections', - ); + expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections'); expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS'); - expect(infrastructureOperationsStateSource).toContain("./infrastructureOperationsModel"); + expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware'); + expect(infrastructureOperationsStateSource).toContain('./infrastructureOperationsModel'); expect(infrastructureOperationsStateSource).toContain('./useInfrastructureInstallState'); expect(infrastructureOperationsStateSource).toContain('./useInfrastructureReportingState'); expect(infrastructureOperationsStateSource).toContain( @@ -118,7 +121,9 @@ describe('InfrastructureOperationsController ownership guardrails', () => { expect(infrastructureOperationsStateSource).not.toContain('renderInstallerSection'); expect(infrastructureOperationsStateSource).not.toContain('renderInventorySection'); expect(infrastructureOperationsStateSource).not.toContain('renderStopMonitoringDialog'); - expect(infrastructureInstallStateSource).toContain('export const useInfrastructureInstallState'); + expect(infrastructureInstallStateSource).toContain( + 'export const useInfrastructureInstallState', + ); expect(infrastructureInstallStateSource).toContain('MonitoringAPI.getState()'); expect(infrastructureInstallStateSource).toContain('./infrastructureWorkspaceModel'); expect(infrastructureInstallStateSource).toContain( @@ -140,16 +145,18 @@ describe('InfrastructureOperationsController ownership guardrails', () => { 'export const useInfrastructureDiscoveryRuntimeState', ); expect(infrastructureDiscoveryRuntimeStateSource).toContain("apiFetch('/api/discover'"); - expect(infrastructureDiscoveryRuntimeStateSource).toContain( - 'SettingsAPI.updateSystemSettings', - ); + expect(infrastructureDiscoveryRuntimeStateSource).toContain('SettingsAPI.updateSystemSettings'); expect(infrastructureInstallerSectionSource).toContain('useInfrastructureOperationsContext'); - expect(infrastructureInstallerSectionSource).toContain('Advanced connection and install options'); + expect(infrastructureInstallerSectionSource).toContain( + 'Advanced connection and install options', + ); expect(infrastructureInstallerSectionSource).toContain( 'Show advanced connection and install options', ); expect(infrastructureInventorySectionSource).toContain('useInfrastructureOperationsContext'); - expect(infrastructureStopMonitoringDialogSource).toContain('useInfrastructureOperationsContext'); + expect(infrastructureStopMonitoringDialogSource).toContain( + 'useInfrastructureOperationsContext', + ); expect(infrastructureOperationsModelSource).toContain('export const getRowReportingSummary'); expect(infrastructureOperationsModelSource).toContain( 'export const getPowerShellInstallProfileEnvFromFlags', @@ -168,6 +175,8 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => { pmgCount={3} truenasCount={4} truenasAvailable={true} + vmwareCount={5} + vmwareAvailable={true} onManagePlatformConnections={onManagePlatformConnections} /> )); @@ -176,8 +185,11 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => { expect(screen.getByText('PBS')).toBeInTheDocument(); expect(screen.getByText('PMG')).toBeInTheDocument(); expect(screen.getByText('TrueNAS')).toBeInTheDocument(); + expect(screen.getByText('VMware')).toBeInTheDocument(); expect(screen.getByTestId('platform-connections-truenas')).toHaveTextContent('4'); + expect(screen.getByTestId('platform-connections-vmware')).toHaveTextContent('5'); expect(screen.getByText('API-backed NAS connections')).toBeInTheDocument(); + expect(screen.getByText('vCenter platform connections')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Open platform connections' })); expect(onManagePlatformConnections).toHaveBeenCalledTimes(1); @@ -191,12 +203,19 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => { pmgCount={0} truenasCount={0} truenasAvailable={false} + vmwareCount={0} + vmwareAvailable={false} onManagePlatformConnections={() => {}} /> )); - expect(screen.getByText('Disabled')).toBeInTheDocument(); - expect(screen.getByText('Explicitly disabled on this Pulse server.')).toBeInTheDocument(); + expect( + within(screen.getByTestId('platform-connections-truenas')).getByText('Disabled'), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('platform-connections-vmware')).getByText('Disabled'), + ).toBeInTheDocument(); + expect(screen.getAllByText('Explicitly disabled on this Pulse server.')).toHaveLength(2); }); }); @@ -633,7 +652,10 @@ const setupComponent = ( )); }; -const setupWithResources = (resources: any[], connectedInfrastructure: ConnectedInfrastructureItem[]) => { +const setupWithResources = ( + resources: any[], + connectedInfrastructure: ConnectedInfrastructureItem[], +) => { setMockResources(resources); const [state] = createStore>({ @@ -730,10 +752,13 @@ beforeEach(() => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator); vi.stubGlobal('Blob', MockBlob as unknown as typeof Blob); - vi.stubGlobal('URL', Object.assign(URL, { - createObjectURL: createObjectURLMock, - revokeObjectURL: revokeObjectURLMock, - })); + vi.stubGlobal( + 'URL', + Object.assign(URL, { + createObjectURL: createObjectURLMock, + revokeObjectURL: revokeObjectURLMock, + }), + ); listProfilesMock.mockResolvedValue([]); listAssignmentsMock.mockResolvedValue([]); @@ -790,7 +815,9 @@ describe('InfrastructureOperationsController token generation', () => { 'setup_handoff', ); expect(notificationSuccessMock).not.toHaveBeenCalled(); - expect(screen.getByText(/Security configured\. Save these first-run credentials now\./i)).toBeInTheDocument(); + expect( + screen.getByText(/Security configured\. Save these first-run credentials now\./i), + ).toBeInTheDocument(); expect( screen.getByText(/Pulse already prepared the first scoped install token for this handoff/i), ).toBeInTheDocument(); @@ -933,7 +960,9 @@ describe('InfrastructureOperationsController agent lookup', () => { const host = createAgent({ hostname: 'first-host.local', displayName: 'First Host' }); getStateMock .mockResolvedValueOnce(buildMonitoringState()) - .mockResolvedValue(buildMonitoringState(buildConnectedInfrastructureFromFixtures({ hosts: [host] }))); + .mockResolvedValue( + buildMonitoringState(buildConnectedInfrastructureFromFixtures({ hosts: [host] })), + ); setupComponent(); @@ -1099,7 +1128,9 @@ describe('InfrastructureOperationsController managed agents table', () => { expect(detailsRow).not.toBeNull(); const details = within(detailsRow as HTMLElement); expect(screen.getByText('Browse reporting items')).toBeInTheDocument(); - expect(screen.getByText('Select a reporting item to open its details drawer.')).toBeInTheDocument(); + expect( + screen.getByText('Select a reporting item to open its details drawer.'), + ).toBeInTheDocument(); expect(details.getByText('Selected reporting item')).toBeInTheDocument(); expect(details.getByText('Machine overview')).toBeInTheDocument(); expect(details.getByText('Surface controls')).toBeInTheDocument(); @@ -1261,45 +1292,44 @@ describe('InfrastructureOperationsController managed agents table', () => { expect( details.getByText('Container runtime coverage reported from this machine.'), ).toBeInTheDocument(); - expect( - details.getByText('Proxmox node telemetry linked to this machine.'), - ).toBeInTheDocument(); + expect(details.getByText('Proxmox node telemetry linked to this machine.')).toBeInTheDocument(); expect(details.getByText('Agent ID')).toBeInTheDocument(); expect(details.getByText('Docker runtime ID')).toBeInTheDocument(); expect(details.getByText('Node ID')).toBeInTheDocument(); expect(details.getAllByText('delly-resource').length).toBeGreaterThan(0); - expect( - details.getByRole('button', { name: /Open platform connections/i }), - ).toBeInTheDocument(); + expect(details.getByRole('button', { name: /Open platform connections/i })).toBeInTheDocument(); expect(details.getAllByText('delly-agent').length).toBeGreaterThan(0); expect(details.getAllByText('delly-docker').length).toBeGreaterThan(0); }); it('routes api-backed truenas rows to platform connections instead of machine uninstall actions', async () => { - setupWithResources([], [ - { - id: 'truenas-main', - name: 'Tower NAS', - displayName: 'Tower NAS', - hostname: 'truenas.local', - status: 'active', - healthStatus: 'online', - lastSeen: Date.now(), - version: '25.04.0', - upgradePlatform: 'linux', - surfaces: [ - { - id: 'truenas:truenas.local', - kind: 'truenas', - label: 'TrueNAS data', - detail: - 'System, storage, app, and recovery telemetry polled through the configured TrueNAS connection.', - idLabel: 'Hostname', - idValue: 'truenas.local', - }, - ], - }, - ]); + setupWithResources( + [], + [ + { + id: 'truenas-main', + name: 'Tower NAS', + displayName: 'Tower NAS', + hostname: 'truenas.local', + status: 'active', + healthStatus: 'online', + lastSeen: Date.now(), + version: '25.04.0', + upgradePlatform: 'linux', + surfaces: [ + { + id: 'truenas:truenas.local', + kind: 'truenas', + label: 'TrueNAS data', + detail: + 'System, storage, app, and recovery telemetry polled through the configured TrueNAS connection.', + idLabel: 'Hostname', + idValue: 'truenas.local', + }, + ], + }, + ], + ); await waitFor(() => { expect(screen.getAllByText('Reporting now').length).toBeGreaterThan(0); @@ -1313,7 +1343,9 @@ describe('InfrastructureOperationsController managed agents table', () => { const details = within(detailsRow as HTMLElement); expect(details.queryByText('Machine actions')).not.toBeInTheDocument(); - expect(details.queryByRole('button', { name: /Copy uninstall command/i })).not.toBeInTheDocument(); + expect( + details.queryByRole('button', { name: /Copy uninstall command/i }), + ).not.toBeInTheDocument(); const openPlatformConnectionsButton = details.getByRole('button', { name: /Open platform connections/i, @@ -1427,7 +1459,9 @@ describe('InfrastructureOperationsController managed agents table', () => { ).toBeInTheDocument(); expect(screen.queryByText('Proxmox data')).not.toBeInTheDocument(); expect( - screen.getByText('Pulse is currently receiving live reports from 1 host, 1 Docker runtime, and 1 PBS server.'), + screen.getByText( + 'Pulse is currently receiving live reports from 1 host, 1 Docker runtime, and 1 PBS server.', + ), ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /details for Tower/i })); @@ -1569,7 +1603,9 @@ describe('InfrastructureOperationsController managed agents table', () => { expect(screen.getAllByText('delly').length).toBeGreaterThan(0); }); expect( - screen.queryByText('Pulse is receiving host telemetry, Docker runtime data, and Proxmox data from this item.'), + screen.queryByText( + 'Pulse is receiving host telemetry, Docker runtime data, and Proxmox data from this item.', + ), ).not.toBeInTheDocument(); }); @@ -2209,12 +2245,12 @@ describe('InfrastructureOperationsController managed agents table', () => { expect(screen.getByText('Docker runtime')).toBeInTheDocument(); expect(screen.getByText('1 item(s) are currently ignored by Pulse.')).toBeInTheDocument(); expect( - screen.getByText( - 'Pulse is currently receiving live reports from 1 host.', - ), + screen.getByText('Pulse is currently receiving live reports from 1 host.'), ).toBeInTheDocument(); expect(screen.queryByText('Missing expected coverage')).not.toBeInTheDocument(); - expect(screen.queryByText('No Kubernetes reporter is currently connected.')).not.toBeInTheDocument(); + expect( + screen.queryByText('No Kubernetes reporter is currently connected.'), + ).not.toBeInTheDocument(); expect( screen.getByText( /Items you explicitly told Pulse to ignore stay out of live reporting until reconnect is allowed\./i, @@ -2263,8 +2299,12 @@ describe('InfrastructureOperationsController managed agents table', () => { expect(screen.getAllByText('Docker runtime data').length).toBeGreaterThan(0); const ignoredDrawer = screen.getByRole('dialog', { name: 'Ignored item details' }); const ignoredDetails = within(ignoredDrawer); - expect(ignoredDetails.getByRole('button', { name: /Allow Docker reconnect/i })).toBeInTheDocument(); - expect(ignoredDetails.queryByRole('button', { name: 'Stop monitoring' })).not.toBeInTheDocument(); + expect( + ignoredDetails.getByRole('button', { name: /Allow Docker reconnect/i }), + ).toBeInTheDocument(); + expect( + ignoredDetails.queryByRole('button', { name: 'Stop monitoring' }), + ).not.toBeInTheDocument(); expect(screen.getByText('Showing 0 of 0 active records.')).toBeInTheDocument(); expect(screen.getByText('1 item(s) are currently ignored by Pulse.')).toBeInTheDocument(); }); @@ -2354,7 +2394,9 @@ describe('InfrastructureOperationsController managed agents table', () => { ); expect(screen.getAllByText('Tower').length).toBeGreaterThan(0); fireEvent.click(screen.getAllByText('Tower')[0]); - expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan( + 0, + ); expect(notificationSuccessMock).toHaveBeenCalledWith( 'Monitoring stopped for Tower. Pulse will ignore future reports until reconnect is allowed.', ); @@ -2421,7 +2463,9 @@ describe('InfrastructureOperationsController managed agents table', () => { }); expect(screen.getAllByText('Ignored by Pulse').length).toBeGreaterThan(0); fireEvent.click(screen.getAllByText('Tower')[0]); - expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan( + 0, + ); }); it('force-removes docker runtimes from Pulse inventory', async () => { @@ -2451,7 +2495,9 @@ describe('InfrastructureOperationsController managed agents table', () => { }, ); fireEvent.click(screen.getAllByText('Tower')[0]); - expect(screen.getAllByRole('button', { name: 'Allow Docker reconnect' }).length).toBeGreaterThan(0); + expect( + screen.getAllByRole('button', { name: 'Allow Docker reconnect' }).length, + ).toBeGreaterThan(0); expect(notificationSuccessMock).toHaveBeenCalledWith( 'Monitoring stopped for Tower. Pulse will ignore future reports until reconnect is allowed.', ); diff --git a/frontend-modern/src/components/Settings/__tests__/PlatformConnectionsWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/PlatformConnectionsWorkspace.test.tsx index 92204350e..16ed25670 100644 --- a/frontend-modern/src/components/Settings/__tests__/PlatformConnectionsWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/PlatformConnectionsWorkspace.test.tsx @@ -5,6 +5,7 @@ import { PlatformConnectionsWorkspace } from '../PlatformConnectionsWorkspace'; let mockPathname = '/settings/infrastructure/platforms/proxmox'; const navigateSpy = vi.hoisted(() => vi.fn()); const trueNASStateSpy = vi.hoisted(() => vi.fn()); +const vmwareStateSpy = vi.hoisted(() => vi.fn()); vi.mock('@solidjs/router', async () => { const actual = await vi.importActual('@solidjs/router'); @@ -26,10 +27,18 @@ vi.mock('../TrueNASSettingsPanel', () => ({ }, })); +vi.mock('../VMwareSettingsPanel', () => ({ + VMwareSettingsPanel: (props: { state: unknown }) => { + vmwareStateSpy(props.state); + return
vmware
; + }, +})); + describe('PlatformConnectionsWorkspace', () => { beforeEach(() => { navigateSpy.mockReset(); trueNASStateSpy.mockReset(); + vmwareStateSpy.mockReset(); mockPathname = '/settings/infrastructure/platforms/proxmox'; }); @@ -47,6 +56,7 @@ describe('PlatformConnectionsWorkspace', () => { pbsNodes: () => [], pmgNodes: () => [], trueNASSettings: { connections: () => [], featureDisabled: () => false }, + vmwareSettings: { connections: () => [], featureDisabled: () => false }, } as any)} /> ) as any, @@ -57,6 +67,7 @@ describe('PlatformConnectionsWorkspace', () => { expect(screen.getByRole('tab', { name: 'Proxmox' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('tab', { name: 'TrueNAS' })).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByRole('tab', { name: 'VMware' })).toHaveAttribute('aria-selected', 'false'); expect(screen.getByTestId('proxmox-settings')).toBeInTheDocument(); }); @@ -68,6 +79,14 @@ describe('PlatformConnectionsWorkspace', () => { expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/truenas'); }); + it('navigates to the canonical VMware route from the shared subtabs', () => { + renderWorkspace(); + + fireEvent.click(screen.getByRole('tab', { name: 'VMware' })); + + expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/vmware'); + }); + it('treats legacy TrueNAS routes as the TrueNAS workspace', () => { mockPathname = '/settings/infrastructure/truenas'; renderWorkspace(); @@ -81,4 +100,18 @@ describe('PlatformConnectionsWorkspace', () => { }), ); }); + + it('treats the canonical VMware route as the VMware workspace', () => { + mockPathname = '/settings/infrastructure/platforms/vmware'; + renderWorkspace(); + + expect(screen.getByRole('tab', { name: 'VMware' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('vmware-settings')).toBeInTheDocument(); + expect(vmwareStateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + connections: expect.any(Function), + featureDisabled: expect.any(Function), + }), + ); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/VMwareSettingsPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/VMwareSettingsPanel.test.tsx new file mode 100644 index 000000000..dd13b1b99 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/VMwareSettingsPanel.test.tsx @@ -0,0 +1,152 @@ +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { VMwareConnection } from '@/api/vmware'; +import { VMwareSettingsPanel } from '../VMwareSettingsPanel'; +import type { VMwareSettingsPanelState } from '../useVMwareSettingsPanelState'; + +const mockState = vi.hoisted(() => ({ + closeDeleteDialog: vi.fn(), + closeDialog: vi.fn(), + connections: vi.fn((): VMwareConnection[] => []), + deleteDialogOpen: vi.fn(() => false), + deletePendingConnection: vi.fn(), + deleting: vi.fn(() => false), + dialogOpen: vi.fn(() => false), + editingConnection: vi.fn((): VMwareConnection | null => null), + featureDisabled: vi.fn(() => false), + featureDisabledMessage: vi.fn(() => ''), + form: vi.fn(() => ({ + name: '', + host: '', + port: '443', + username: '', + password: '', + insecureSkipVerify: false, + enabled: true, + hasStoredPassword: false, + })), + loadConnections: vi.fn(), + loading: vi.fn(() => false), + loadingError: vi.fn(() => null), + openCreateDialog: vi.fn(), + openDeleteDialog: vi.fn(), + openEditDialog: vi.fn(), + pendingDeleteConnection: vi.fn((): VMwareConnection | null => null), + saveCurrentForm: vi.fn(), + saving: vi.fn(() => false), + testCurrentForm: vi.fn(), + testSavedConnection: vi.fn(), + testing: vi.fn(() => false), + updateForm: vi.fn(), +})); + +describe('VMwareSettingsPanel', () => { + beforeEach(() => { + Object.values(mockState).forEach((value) => { + if (typeof value === 'function' && 'mockReset' in value) { + value.mockReset(); + } + }); + mockState.connections.mockReturnValue([]); + mockState.deleteDialogOpen.mockReturnValue(false); + mockState.deleting.mockReturnValue(false); + mockState.dialogOpen.mockReturnValue(false); + mockState.editingConnection.mockReturnValue(null); + mockState.featureDisabled.mockReturnValue(false); + mockState.featureDisabledMessage.mockReturnValue(''); + mockState.form.mockReturnValue({ + name: '', + host: '', + port: '443', + username: '', + password: '', + insecureSkipVerify: false, + enabled: true, + hasStoredPassword: false, + }); + mockState.loading.mockReturnValue(false); + mockState.loadingError.mockReturnValue(null); + mockState.pendingDeleteConnection.mockReturnValue(null); + mockState.saving.mockReturnValue(false); + mockState.testing.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + const renderPanel = () => + render(() => ); + + it('renders the settings shell and existing connections', () => { + mockState.connections.mockReturnValue([ + { + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + test: { + lastSuccessAt: new Date(Date.now() - 60_000).toISOString(), + }, + observed: { + collectedAt: new Date(Date.now() - 60_000).toISOString(), + hosts: 3, + vms: 42, + datastores: 6, + viRelease: '8.0.3', + }, + }, + { + id: 'conn-2', + name: 'staging-vcenter', + host: 'staging.lab.local', + username: 'operator@vsphere.local', + insecureSkipVerify: true, + enabled: false, + }, + ]); + + renderPanel(); + + expect(screen.getByText('VMware vSphere platform integration')).toBeInTheDocument(); + expect(screen.getByText('VMware connections')).toBeInTheDocument(); + expect(screen.getByText('lab-vcenter')).toBeInTheDocument(); + expect(screen.getByText('Validated')).toBeInTheDocument(); + expect( + within(screen.getByTestId('vmware-connection-conn-2')).getAllByText('Disabled'), + ).toHaveLength(2); + expect(screen.getByText('3 hosts')).toBeInTheDocument(); + expect(screen.getByText('42 vms')).toBeInTheDocument(); + expect(screen.getByText('6 datastores')).toBeInTheDocument(); + expect(screen.getByText('VI JSON 8.0.3')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add VMware connection' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add VMware connection' })); + expect(mockState.openCreateDialog).toHaveBeenCalledTimes(1); + + fireEvent.click( + within(screen.getByTestId('vmware-connection-conn-1')).getByRole('button', { name: 'Test' }), + ); + expect(mockState.testSavedConnection).toHaveBeenCalledWith( + expect.objectContaining({ id: 'conn-1' }), + ); + }); + + it('shows the feature gate warning when the backend path is disabled', () => { + mockState.featureDisabled.mockReturnValue(true); + mockState.featureDisabledMessage.mockReturnValue( + 'VMware integration has been explicitly disabled', + ); + + renderPanel(); + + expect(screen.getByText('VMware integration is disabled')).toBeInTheDocument(); + expect(screen.getByText('VMware integration has been explicitly disabled')).toBeInTheDocument(); + expect(screen.getByText(/PULSE_ENABLE_VMWARE=false/)).toBeInTheDocument(); + expect(screen.queryByText('VMware connections')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 7034bc10b..24c041f4e 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -16,6 +16,7 @@ import infrastructureOperationsModelSource from '../infrastructureOperationsMode import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw'; import infrastructurePlatformConnectionsSummaryCardSource from '../InfrastructurePlatformConnectionsSummaryCard.tsx?raw'; import trueNASSettingsPanelSource from '../TrueNASSettingsPanel.tsx?raw'; +import vmwareSettingsPanelSource from '../VMwareSettingsPanel.tsx?raw'; import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw'; import infrastructureActiveRowDetailsSource from '../InfrastructureActiveRowDetails.tsx?raw'; import infrastructureIgnoredRowDetailsSource from '../InfrastructureIgnoredRowDetails.tsx?raw'; @@ -31,6 +32,7 @@ import infrastructureInstallStateSource from '../useInfrastructureInstallState.t import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw'; import infrastructureReportingStateSource from '../useInfrastructureReportingState.tsx?raw'; import trueNASSettingsStateSource from '../useTrueNASSettingsPanelState.ts?raw'; +import vmwareSettingsStateSource from '../useVMwareSettingsPanelState.ts?raw'; import infrastructureSettingsStateSource from '../useInfrastructureSettingsState.ts?raw'; import infrastructureSettingsModelSource from '../infrastructureSettingsModel.ts?raw'; import infrastructureConfiguredNodesStateSource from '../useInfrastructureConfiguredNodesState.ts?raw'; @@ -171,10 +173,12 @@ const extractedModules = [ '../PlatformConnectionsWorkspace.tsx', '../platformConnectionsModel.ts', '../TrueNASSettingsPanel.tsx', + '../VMwareSettingsPanel.tsx', '../useInfrastructureInstallState.tsx', '../useInfrastructureOperationsState.tsx', '../useInfrastructureReportingState.tsx', '../useTrueNASSettingsPanelState.ts', + '../useVMwareSettingsPanelState.ts', '../infrastructureSettingsModel.ts', '../useInfrastructureConfiguredNodesState.ts', '../useInfrastructureDiscoveryRuntimeState.ts', @@ -498,7 +502,7 @@ describe('Settings architecture guardrails', () => { ); expect(settingsNavigationModelSource).toContain('export function resolveCanonicalSettingsPath'); expect(settingsNavigationModelSource).toContain('export function settingsTabPath'); - expect(settingsNavigationModelSource).toContain("return INFRASTRUCTURE_INSTALL_PREFIX;"); + expect(settingsNavigationModelSource).toContain('return INFRASTRUCTURE_INSTALL_PREFIX;'); expect(settingsNavigationHookSource).toContain('deriveTabFromPath'); expect(settingsNavigationHookSource).toContain('resolveCanonicalSettingsPath'); expect(settingsNavigationHookSource).toContain('settingsTabPath'); @@ -512,7 +516,7 @@ describe('Settings architecture guardrails', () => { expect(settingsShellStateSource).toContain('showPasswordModal'); expect(settingsNavCatalogSource).toContain('export const SETTINGS_NAV_GROUPS'); expect(settingsNavCatalogSource).toContain('export function getSettingsNavItem'); - expect(settingsNavCatalogSource).toContain("./selfHostedBillingPresentation"); + expect(settingsNavCatalogSource).toContain('./selfHostedBillingPresentation'); expect(settingsNavCatalogSource).toContain('SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle'); expect(settingsNavCatalogSource).not.toContain("label: 'Pulse Pro'"); expect(settingsNavVisibilitySource).toContain('export function shouldHideSettingsNavItem'); @@ -587,18 +591,12 @@ describe('Settings architecture guardrails', () => { expect(monitoredSystemPresentationSource).toContain( 'export function getMonitoredSystemDisclosureToggleLabel', ); - expect(selfHostedCommercialActivationSectionSource).toContain( - '@/utils/licensePresentation', - ); + expect(selfHostedCommercialActivationSectionSource).toContain('@/utils/licensePresentation'); expect(selfHostedCommercialActivationSectionSource).toContain( 'SELF_HOSTED_ACTIVATION_PRESENTATION', ); - expect(selfHostedCommercialActivationSectionSource).not.toContain( - 'License / Activation Key', - ); - expect(selfHostedCommercialActivationSectionSource).not.toContain( - 'Start 14-day Pro Trial', - ); + expect(selfHostedCommercialActivationSectionSource).not.toContain('License / Activation Key'); + expect(selfHostedCommercialActivationSectionSource).not.toContain('Start 14-day Pro Trial'); expect(organizationBillingPanelSource).toContain('./CommercialBillingSections'); expect(organizationBillingPanelSource).toContain('./OrganizationBillingLoadingState'); expect(organizationBillingPanelSource).toContain('./useOrganizationBillingPanelState'); @@ -872,9 +870,10 @@ describe('Settings architecture guardrails', () => { expect(platformConnectionsWorkspaceSource).toContain('./platformConnectionsModel'); expect(platformConnectionsWorkspaceSource).toContain('./ProxmoxSettingsPanel'); expect(platformConnectionsWorkspaceSource).toContain('./TrueNASSettingsPanel'); + expect(platformConnectionsWorkspaceSource).toContain('./VMwareSettingsPanel'); expect(platformConnectionsModelSource).toContain('export const PLATFORM_CONNECTIONS_TABS'); expect(platformConnectionsModelSource).toContain( - "export function getPlatformConnectionsViewFromPath", + 'export function getPlatformConnectionsViewFromPath', ); expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider'); expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection'); @@ -906,9 +905,13 @@ describe('Settings architecture guardrails', () => { 'export const useInfrastructureOperationsContext', ); expect(trueNASSettingsPanelSource).toContain('state: TrueNASSettingsPanelState'); + expect(vmwareSettingsPanelSource).toContain('state: VMwareSettingsPanelState'); expect(trueNASSettingsStateSource).toContain('@/api/truenas'); expect(trueNASSettingsStateSource).toContain('export function useTrueNASSettingsPanelState'); + expect(vmwareSettingsStateSource).toContain('@/api/vmware'); + expect(vmwareSettingsStateSource).toContain('export function useVMwareSettingsPanelState'); expect(infrastructureSettingsStateSource).toContain('./useTrueNASSettingsPanelState'); + expect(infrastructureSettingsStateSource).toContain('./useVMwareSettingsPanelState'); expect(infrastructureOperationsStateSource).not.toContain('const renderInstallerSection ='); expect(infrastructureOperationsStateSource).not.toContain('const renderInventorySection ='); expect(infrastructureOperationsStateSource).not.toContain('const renderStopMonitoringDialog ='); @@ -934,10 +937,9 @@ describe('Settings architecture guardrails', () => { expect(infrastructureWorkspaceModelSource).toContain( 'export function buildInfrastructureWorkspacePath', ); - expect(infrastructurePlatformConnectionsSummaryCardSource).toContain( - 'Platform connections', - ); + expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections'); expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS'); + expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware'); expect(infrastructurePlatformConnectionsSummaryCardSource).toContain( 'Open platform connections', ); @@ -1186,7 +1188,9 @@ describe('Settings architecture guardrails', () => { expect(reportingPanelStateSource).not.toContain("'advanced_reporting'"); expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()'); expect(reportingCatalogModelSource).toContain('export function buildReportingCatalogRequest'); - expect(reportingCatalogModelSource).toContain('export function buildLegacyReportingCatalogFallback'); + expect(reportingCatalogModelSource).toContain( + 'export function buildLegacyReportingCatalogFallback', + ); expect(reportingCatalogModelSource).toContain('export function parseReportingCatalog'); expect(reportingCatalogModelSource).toContain('interface ReportingLockedStateDefinition'); expect(reportingCatalogModelSource).toContain('interface ReportingGuidanceDefinition'); @@ -1312,14 +1316,18 @@ describe('Settings architecture guardrails', () => { 'export function getStartUpdateErrorMessage', ); expect(docsLinksSource).toContain("export const SHIPPED_DOCS_ROOT = '/docs'"); - expect(docsLinksSource).toContain("export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md')"); + expect(docsLinksSource).toContain( + "export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md')", + ); expect(docsLinksSource).toContain( "export const CONFIGURATION_DOC_URL = getShippedDocUrl('CONFIGURATION.md')", ); expect(docsLinksSource).toContain( "export const PROXY_AUTH_DOC_URL = getShippedDocUrl('PROXY_AUTH.md')", ); - expect(docsLinksSource).toContain("export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md')"); + expect(docsLinksSource).toContain( + "export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md')", + ); expect(docsLinksSource).toContain("export const TERMS_DOC_URL = getShippedDocUrl('TERMS.md')"); }); diff --git a/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts b/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts index 8451a6be5..17f0d1d58 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts @@ -3,10 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { useSettingsInfrastructurePanelProps } from '../useSettingsInfrastructurePanelProps'; -const createServiceResource = ( - type: 'pbs' | 'pmg', - overrides: Partial = {}, -): Resource => +const createServiceResource = (type: 'pbs' | 'pmg', overrides: Partial = {}): Resource => ({ id: `${type}-1`, type, @@ -31,6 +28,8 @@ const mountHook = ( options?: { truenasConnections?: Array<{ id: string }>; truenasFeatureDisabled?: boolean; + vmwareConnections?: Array<{ id: string }>; + vmwareFeatureDisabled?: boolean; pveCount?: number; pbsCount?: number; pmgCount?: number; @@ -65,18 +64,22 @@ const mountHook = ( disableDockerUpdateActionsLocked: () => false, savingDockerUpdateActions: () => false, handleDisableDockerUpdateActionsChange: async () => {}, - } as Parameters[0]['systemSettings'], + } as unknown as Parameters[0]['systemSettings'], infrastructureSettings: { initialLoadComplete: () => true, discoveryScanStatus: () => ({ scanning: false }), discoveredNodes: () => [], - pveNodes: () => Array.from({ length: options?.pveCount ?? 0 }, () => ({} as any)), - pbsNodes: () => Array.from({ length: options?.pbsCount ?? 0 }, () => ({} as any)), - pmgNodes: () => Array.from({ length: options?.pmgCount ?? 0 }, () => ({} as any)), + pveNodes: () => Array.from({ length: options?.pveCount ?? 0 }, () => ({}) as any), + pbsNodes: () => Array.from({ length: options?.pbsCount ?? 0 }, () => ({}) as any), + pmgNodes: () => Array.from({ length: options?.pmgCount ?? 0 }, () => ({}) as any), trueNASSettings: { connections: () => options?.truenasConnections ?? [], featureDisabled: () => options?.truenasFeatureDisabled ?? false, }, + vmwareSettings: { + connections: () => options?.vmwareConnections ?? [], + featureDisabled: () => options?.vmwareFeatureDisabled ?? false, + }, triggerDiscoveryScan: async () => {}, loadDiscoveredNodes: async () => {}, handleDiscoveryEnabledChange: async () => true, @@ -101,7 +104,9 @@ const mountHook = ( nodePendingDeleteHost: () => '', nodePendingDeleteType: () => '', nodePendingDeleteTypeLabel: () => '', - } as Parameters[0]['infrastructureSettings'], + } as unknown as Parameters< + typeof useSettingsInfrastructurePanelProps + >[0]['infrastructureSettings'], securityStatus: () => null, }); }); @@ -134,18 +139,16 @@ describe('useSettingsInfrastructurePanelProps', () => { const panelProps = hookState.getInfrastructurePanelProps(); - expect(panelProps.pbsInstances()).toEqual([ - expect.objectContaining({ name: 'PBS Main' }), - ]); - expect(panelProps.pmgInstances()).toEqual([ - expect.objectContaining({ name: 'PMG Main' }), - ]); + expect(panelProps.pbsInstances()).toEqual([expect.objectContaining({ name: 'PBS Main' })]); + expect(panelProps.pmgInstances()).toEqual([expect.objectContaining({ name: 'PMG Main' })]); expect(panelProps.platformConnectionsSummary()).toMatchObject({ pveCount: 0, pbsCount: 0, pmgCount: 0, truenasCount: 0, truenasAvailable: true, + vmwareCount: 0, + vmwareAvailable: true, }); dispose(); @@ -157,6 +160,7 @@ describe('useSettingsInfrastructurePanelProps', () => { pbsCount: 2, pmgCount: 3, truenasConnections: [{ id: 'truenas-1' }, { id: 'truenas-2' }], + vmwareConnections: [{ id: 'vmware-1' }], }); const panelProps = hookState.getInfrastructurePanelProps(); @@ -167,6 +171,8 @@ describe('useSettingsInfrastructurePanelProps', () => { pmgCount: 3, truenasCount: 2, truenasAvailable: true, + vmwareCount: 1, + vmwareAvailable: true, }); dispose(); diff --git a/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx b/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx new file mode 100644 index 000000000..80de5e3aa --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx @@ -0,0 +1,190 @@ +import { renderHook, waitFor } from '@solidjs/testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { VMwareAPI } from '@/api/vmware'; +import { notificationStore } from '@/stores/notifications'; +import { useVMwareSettingsPanelState } from '../useVMwareSettingsPanelState'; + +vi.mock('@/api/vmware', () => ({ + VMwareAPI: { + listConnections: vi.fn(), + createConnection: vi.fn(), + updateConnection: vi.fn(), + deleteConnection: vi.fn(), + testConnection: vi.fn(), + testSavedConnection: vi.fn(), + }, + isRedactedVMwareSecret: (value: string | null | undefined) => (value || '').trim() === '********', +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe('useVMwareSettingsPanelState', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('treats a 404 list response as a feature-disabled integration state', async () => { + vi.mocked(VMwareAPI.listConnections).mockRejectedValueOnce({ + status: 404, + message: 'VMware integration has been explicitly disabled', + }); + + const { result } = renderHook(() => useVMwareSettingsPanelState()); + + await waitFor(() => expect(result.featureDisabled()).toBe(true)); + expect(result.featureDisabledMessage()).toBe('VMware integration has been explicitly disabled'); + expect(result.connections()).toEqual([]); + }); + + it('preserves the masked password when editing an existing connection without replacing the secret', async () => { + vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([ + { + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + }, + ] as never); + vi.mocked(VMwareAPI.updateConnection).mockResolvedValueOnce({ + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + } as never); + vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([ + { + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + }, + ] as never); + + const { result } = renderHook(() => useVMwareSettingsPanelState()); + + await waitFor(() => expect(result.connections()).toHaveLength(1)); + + result.openEditDialog(result.connections()[0]); + expect(result.dialogOpen()).toBe(true); + await result.saveCurrentForm(); + + expect(VMwareAPI.updateConnection).toHaveBeenCalledWith( + 'conn-1', + expect.objectContaining({ + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + }), + ); + expect(result.dialogOpen()).toBe(false); + expect(notificationStore.success).toHaveBeenCalledWith('VMware connection updated'); + }); + + it('tests saved connections through the canonical saved-connection API path', async () => { + vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([ + { + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + }, + ] as never); + vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([ + { + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + test: { + lastSuccessAt: '2026-03-30T10:00:00Z', + }, + }, + ] as never); + vi.mocked(VMwareAPI.testSavedConnection).mockResolvedValueOnce({ success: true } as never); + + const { result } = renderHook(() => useVMwareSettingsPanelState()); + + await waitFor(() => expect(result.connections()).toHaveLength(1)); + + await result.testSavedConnection(result.connections()[0]); + + expect(VMwareAPI.testSavedConnection).toHaveBeenCalledWith('conn-1'); + expect(VMwareAPI.testConnection).not.toHaveBeenCalled(); + expect(notificationStore.success).toHaveBeenCalledWith( + 'VMware connection successful for lab-vcenter', + ); + expect(VMwareAPI.listConnections).toHaveBeenCalledTimes(2); + expect(result.connections()[0].test?.lastSuccessAt).toBe('2026-03-30T10:00:00Z'); + }); + + it('tests edited saved connections through the canonical saved-connection API path', async () => { + vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([ + { + id: 'conn-1', + name: 'lab-vcenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + }, + ] as never); + vi.mocked(VMwareAPI.testSavedConnection).mockResolvedValueOnce({ success: true } as never); + + const { result } = renderHook(() => useVMwareSettingsPanelState()); + + await waitFor(() => expect(result.connections()).toHaveLength(1)); + + result.openEditDialog(result.connections()[0]); + result.updateForm({ host: 'edited.lab.local', port: '8443', insecureSkipVerify: true }); + await result.testCurrentForm(); + + expect(VMwareAPI.testSavedConnection).toHaveBeenCalledWith( + 'conn-1', + expect.objectContaining({ + host: 'edited.lab.local', + port: 8443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: true, + }), + ); + expect(VMwareAPI.testConnection).not.toHaveBeenCalled(); + expect(notificationStore.success).toHaveBeenCalledWith('VMware connection successful'); + }); +}); diff --git a/frontend-modern/src/components/Settings/platformConnectionsModel.ts b/frontend-modern/src/components/Settings/platformConnectionsModel.ts index 80e541d2c..8bbd3ab51 100644 --- a/frontend-modern/src/components/Settings/platformConnectionsModel.ts +++ b/frontend-modern/src/components/Settings/platformConnectionsModel.ts @@ -1,4 +1,4 @@ -export type PlatformConnectionsView = 'proxmox' | 'truenas'; +export type PlatformConnectionsView = 'proxmox' | 'truenas' | 'vmware'; export interface PlatformConnectionsTabDefinition { id: PlatformConnectionsView; @@ -9,9 +9,11 @@ export interface PlatformConnectionsTabDefinition { export const PLATFORM_CONNECTIONS_PREFIX = '/settings/infrastructure/platforms'; const PLATFORM_CONNECTIONS_PROXMOX_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/proxmox`; const PLATFORM_CONNECTIONS_TRUENAS_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/truenas`; +const PLATFORM_CONNECTIONS_VMWARE_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/vmware`; const LEGACY_PROXMOX_PREFIX = '/settings/infrastructure/proxmox'; const LEGACY_PROXMOX_API_PREFIX = '/settings/infrastructure/api'; const LEGACY_TRUENAS_PREFIX = '/settings/infrastructure/truenas'; +const LEGACY_VMWARE_PREFIX = '/settings/infrastructure/vmware'; export const PLATFORM_CONNECTIONS_TABS: readonly PlatformConnectionsTabDefinition[] = [ { @@ -24,12 +26,23 @@ export const PLATFORM_CONNECTIONS_TABS: readonly PlatformConnectionsTabDefinitio label: 'TrueNAS', path: PLATFORM_CONNECTIONS_TRUENAS_PREFIX, }, + { + id: 'vmware', + label: 'VMware', + path: PLATFORM_CONNECTIONS_VMWARE_PREFIX, + }, ]; export function getPlatformConnectionsViewFromPath(pathname: string): PlatformConnectionsView { + if (pathname.startsWith(PLATFORM_CONNECTIONS_VMWARE_PREFIX)) { + return 'vmware'; + } if (pathname.startsWith(PLATFORM_CONNECTIONS_TRUENAS_PREFIX)) { return 'truenas'; } + if (pathname.startsWith(LEGACY_VMWARE_PREFIX)) { + return 'vmware'; + } if (pathname.startsWith(LEGACY_TRUENAS_PREFIX)) { return 'truenas'; } diff --git a/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts b/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts index 19a577cbe..ee2fc7e95 100644 --- a/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts +++ b/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts @@ -9,6 +9,7 @@ import type { NodeType, } from './infrastructureSettingsModel'; import type { TrueNASSettingsPanelState } from './useTrueNASSettingsPanelState'; +import type { VMwareSettingsPanelState } from './useVMwareSettingsPanelState'; export type DiscoveryMode = 'auto' | 'custom'; @@ -18,6 +19,8 @@ export interface PlatformConnectionsSummary { pmgCount: number; truenasCount: number; truenasAvailable: boolean; + vmwareCount: number; + vmwareAvailable: boolean; } export interface InfrastructurePlatformSettingsProps { @@ -37,6 +40,7 @@ export interface InfrastructurePlatformSettingsProps { pbsNodes: Accessor; pmgNodes: Accessor; trueNASSettings: TrueNASSettingsPanelState; + vmwareSettings: VMwareSettingsPanelState; platformConnectionsSummary: Accessor; temperatureMonitoringEnabled: Accessor; triggerDiscoveryScan: (options?: { quiet?: boolean }) => Promise; @@ -57,6 +61,10 @@ export interface InfrastructurePlatformSettingsProps { temperatureMonitoringLocked: Accessor; savingTemperatureSetting: Accessor; handleTemperatureMonitoringChange: (enabled: boolean) => Promise; + disableDockerUpdateActions: Accessor; + disableDockerUpdateActionsLocked: Accessor; + savingDockerUpdateActions: Accessor; + handleDisableDockerUpdateActionsChange: (enabled: boolean) => Promise; handleNodeTemperatureMonitoringChange: (nodeId: string, enabled: boolean | null) => Promise; saveNode: (nodeData: Partial) => Promise; showDeleteNodeModal: Accessor; diff --git a/frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts b/frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts index 47c902ba4..b5e166fd7 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts +++ b/frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts @@ -5,6 +5,7 @@ import type { SettingsTab } from './settingsTypes'; import { useInfrastructureConfiguredNodesState } from './useInfrastructureConfiguredNodesState'; import { useInfrastructureDiscoveryRuntimeState } from './useInfrastructureDiscoveryRuntimeState'; import { useTrueNASSettingsPanelState } from './useTrueNASSettingsPanelState'; +import { useVMwareSettingsPanelState } from './useVMwareSettingsPanelState'; export type { DiscoveryScanStatus, @@ -77,6 +78,7 @@ export function useInfrastructureSettingsState({ setSavingTemperatureSetting, }); const trueNASSettings = useTrueNASSettingsPanelState(); + const vmwareSettings = useVMwareSettingsPanelState(); const discoveryRuntime = useInfrastructureDiscoveryRuntimeState({ eventBus, @@ -156,6 +158,7 @@ export function useInfrastructureSettingsState({ return { initialLoadComplete, trueNASSettings, + vmwareSettings, ...configuredNodes, ...discoveryRuntime, }; diff --git a/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts b/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts index e0e3bf6d3..386f6ff1d 100644 --- a/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts +++ b/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts @@ -25,13 +25,15 @@ export function useSettingsInfrastructurePanelProps( params.resources().filter((resource) => resource.type === 'agent'), ); const pbsInstances = createMemo(() => - params.resources() + params + .resources() .filter((resource) => resource.type === 'pbs') .map(pbsInstanceFromResource) .filter((instance): instance is NonNullable => Boolean(instance)), ); const pmgInstances = createMemo(() => - params.resources() + params + .resources() .filter((resource) => resource.type === 'pmg') .map(pmgInstanceFromResource) .filter((instance): instance is NonNullable => Boolean(instance)), @@ -43,6 +45,8 @@ export function useSettingsInfrastructurePanelProps( pmgCount: params.infrastructureSettings.pmgNodes().length, truenasCount: params.infrastructureSettings.trueNASSettings.connections().length, truenasAvailable: !params.infrastructureSettings.trueNASSettings.featureDisabled(), + vmwareCount: params.infrastructureSettings.vmwareSettings.connections().length, + vmwareAvailable: !params.infrastructureSettings.vmwareSettings.featureDisabled(), })); const getInfrastructurePanelProps = (): InfrastructurePlatformSettingsProps => ({ @@ -62,6 +66,7 @@ export function useSettingsInfrastructurePanelProps( pbsNodes: params.infrastructureSettings.pbsNodes, pmgNodes: params.infrastructureSettings.pmgNodes, trueNASSettings: params.infrastructureSettings.trueNASSettings, + vmwareSettings: params.infrastructureSettings.vmwareSettings, platformConnectionsSummary, temperatureMonitoringEnabled: params.systemSettings.temperatureMonitoringEnabled, triggerDiscoveryScan: params.infrastructureSettings.triggerDiscoveryScan, diff --git a/frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts b/frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts new file mode 100644 index 000000000..d7f23ac08 --- /dev/null +++ b/frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts @@ -0,0 +1,290 @@ +import { createMemo, createSignal, onMount } from 'solid-js'; +import { + VMwareAPI, + isRedactedVMwareSecret, + type VMwareConnection, + type VMwareConnectionInput, +} from '@/api/vmware'; +import { apiErrorStatus } from '@/api/responseUtils'; +import { notificationStore } from '@/stores/notifications'; +import { logger } from '@/utils/logger'; + +export interface VMwareConnectionFormState { + name: string; + host: string; + port: string; + username: string; + password: string; + insecureSkipVerify: boolean; + enabled: boolean; + hasStoredPassword: boolean; +} + +const REDACTED_SECRET = '********'; + +const createEmptyFormState = (): VMwareConnectionFormState => ({ + name: '', + host: '', + port: '443', + username: '', + password: '', + insecureSkipVerify: false, + enabled: true, + hasStoredPassword: false, +}); + +const buildFormStateFromConnection = (connection: VMwareConnection): VMwareConnectionFormState => ({ + name: connection.name || '', + host: connection.host || '', + port: connection.port ? String(connection.port) : '443', + username: connection.username || '', + password: '', + insecureSkipVerify: connection.insecureSkipVerify, + enabled: connection.enabled, + hasStoredPassword: isRedactedVMwareSecret(connection.password), +}); + +const parseOptionalPort = (value: string): number | undefined => { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + throw new Error('Port must be a whole number between 1 and 65535'); + } + return parsed; +}; + +const getErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' + ) { + const message = (error as { message: string }).message.trim(); + if (message) { + return message; + } + } + return fallback; +}; + +const buildConnectionInput = (form: VMwareConnectionFormState): VMwareConnectionInput => { + const port = parseOptionalPort(form.port); + const name = form.name.trim(); + const host = form.host.trim(); + const username = form.username.trim(); + + return { + ...(name ? { name } : {}), + host, + ...(port !== undefined ? { port } : {}), + username, + password: form.password.trim() || (form.hasStoredPassword ? REDACTED_SECRET : ''), + insecureSkipVerify: form.insecureSkipVerify, + enabled: form.enabled, + }; +}; + +export function useVMwareSettingsPanelState() { + const [connections, setConnections] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [loadingError, setLoadingError] = createSignal(null); + const [featureDisabled, setFeatureDisabled] = createSignal(false); + const [featureDisabledMessage, setFeatureDisabledMessage] = createSignal(''); + const [dialogOpen, setDialogOpen] = createSignal(false); + const [deleteDialogOpen, setDeleteDialogOpen] = createSignal(false); + const [editingConnectionId, setEditingConnectionId] = createSignal(null); + const [pendingDeleteConnection, setPendingDeleteConnection] = + createSignal(null); + const [form, setForm] = createSignal(createEmptyFormState()); + const [saving, setSaving] = createSignal(false); + const [testing, setTesting] = createSignal(false); + const [deleting, setDeleting] = createSignal(false); + + const editingConnection = createMemo( + () => connections().find((connection) => connection.id === editingConnectionId()) ?? null, + ); + + const loadConnections = async () => { + setLoading(true); + setLoadingError(null); + try { + const nextConnections = await VMwareAPI.listConnections(); + setConnections(nextConnections); + setFeatureDisabled(false); + setFeatureDisabledMessage(''); + } catch (error) { + if (apiErrorStatus(error) === 404) { + setFeatureDisabled(true); + setFeatureDisabledMessage( + getErrorMessage(error, 'VMware integration has been explicitly disabled'), + ); + setConnections([]); + return; + } + const message = getErrorMessage(error, 'Failed to load VMware connections'); + setLoadingError(message); + logger.error('[VMware Settings] Failed to load connections', error); + } finally { + setLoading(false); + } + }; + + onMount(() => { + void loadConnections(); + }); + + const openCreateDialog = () => { + setEditingConnectionId(null); + setForm(createEmptyFormState()); + setDialogOpen(true); + }; + + const openEditDialog = (connection: VMwareConnection) => { + setEditingConnectionId(connection.id); + setForm(buildFormStateFromConnection(connection)); + setDialogOpen(true); + }; + + const resetDialogState = () => { + setDialogOpen(false); + setEditingConnectionId(null); + setForm(createEmptyFormState()); + }; + + const closeDialog = () => { + if (saving() || testing()) return; + resetDialogState(); + }; + + const openDeleteDialog = (connection: VMwareConnection) => { + setPendingDeleteConnection(connection); + setDeleteDialogOpen(true); + }; + + const resetDeleteDialogState = () => { + setDeleteDialogOpen(false); + setPendingDeleteConnection(null); + }; + + const closeDeleteDialog = () => { + if (deleting()) return; + resetDeleteDialogState(); + }; + + const updateForm = (patch: Partial) => + setForm((current) => ({ ...current, ...patch })); + + const testCurrentForm = async () => { + setTesting(true); + try { + const payload = buildConnectionInput(form()); + if (editingConnectionId()) { + await VMwareAPI.testSavedConnection(editingConnectionId()!, payload); + } else { + await VMwareAPI.testConnection(payload); + } + notificationStore.success('VMware connection successful'); + return true; + } catch (error) { + const message = getErrorMessage(error, 'VMware connection failed'); + notificationStore.error(message); + logger.error('[VMware Settings] Connection test failed', error); + return false; + } finally { + setTesting(false); + } + }; + + const testSavedConnection = async (connection: VMwareConnection) => { + setTesting(true); + try { + await VMwareAPI.testSavedConnection(connection.id); + notificationStore.success( + `VMware connection successful for ${connection.name || connection.host}`, + ); + } catch (error) { + const message = getErrorMessage(error, 'VMware connection failed'); + notificationStore.error(message); + logger.error('[VMware Settings] Saved connection test failed', error); + } finally { + setTesting(false); + await loadConnections(); + } + }; + + const saveCurrentForm = async () => { + setSaving(true); + try { + const payload = buildConnectionInput(form()); + if (editingConnectionId()) { + await VMwareAPI.updateConnection(editingConnectionId()!, payload); + notificationStore.success('VMware connection updated'); + } else { + await VMwareAPI.createConnection(payload); + notificationStore.success('VMware connection added'); + } + resetDialogState(); + await loadConnections(); + } catch (error) { + const message = getErrorMessage(error, 'Failed to save VMware connection'); + notificationStore.error(message); + logger.error('[VMware Settings] Save failed', error); + } finally { + setSaving(false); + } + }; + + const deletePendingConnection = async () => { + const connection = pendingDeleteConnection(); + if (!connection) return; + setDeleting(true); + try { + await VMwareAPI.deleteConnection(connection.id); + notificationStore.success(`Removed ${connection.name || connection.host}`); + resetDeleteDialogState(); + await loadConnections(); + } catch (error) { + const message = getErrorMessage(error, 'Failed to remove VMware connection'); + notificationStore.error(message); + logger.error('[VMware Settings] Delete failed', error); + } finally { + setDeleting(false); + } + }; + + return { + closeDeleteDialog, + closeDialog, + connections, + deleteDialogOpen, + deletePendingConnection, + deleting, + dialogOpen, + editingConnection, + featureDisabled, + featureDisabledMessage, + form, + loadConnections, + loading, + loadingError, + openCreateDialog, + openDeleteDialog, + openEditDialog, + pendingDeleteConnection, + saveCurrentForm, + saving, + testCurrentForm, + testSavedConnection, + testing, + updateForm, + }; +} + +export type VMwareSettingsPanelState = ReturnType; + +export default useVMwareSettingsPanelState; diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index f80898f01..b3d10eca6 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -32,6 +32,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/truenas" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/updates" + "github.com/rcourtman/pulse-go-rewrite/internal/vmware" authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" @@ -137,6 +138,62 @@ func TestContract_TrueNASSavedConnectionTestsUpdateRuntimeSummary(t *testing.T) } } +func TestContract_VMwareConnectionsDisabledMessageIsExplicit(t *testing.T) { + setVMwareFeatureForTest(t, false) + handler, _ := newVMwareHandlersForTest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 when VMware integration is disabled, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "explicitly disabled") { + t.Fatalf("expected explicit disable message, got %s", rec.Body.String()) + } +} + +func TestContract_VMwareSavedConnectionTestsUpdateRuntimeSummary(t *testing.T) { + setVMwareFeatureForTest(t, true) + + connection := config.VMwareVCenterInstance{ + ID: "conn-1", + Name: "lab-vcenter", + Host: "vcsa.lab.local", + Port: 443, + Username: "administrator@vsphere.local", + Password: "super-secret", + Enabled: true, + } + handler, persistence := newVMwareHandlersForTest(t) + if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil { + t.Fatalf("seed vmware config: %v", err) + } + handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { + return &fakeVMwareClient{ + testConnection: func(context.Context) (*vmware.InventorySummary, error) { + return &vmware.InventorySummary{Hosts: 3, VMs: 20, Datastores: 4, VIRelease: "8.0.3"}, nil + }, + }, nil + } + + req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil) + rec := httptest.NewRecorder() + handler.HandleTestSavedConnection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + status := handler.runtimeStatus(connection.ID) + if status.Test == nil || status.Test.LastSuccessAt == nil { + t.Fatalf("expected saved manual test to refresh runtime summary, got %+v", status.Test) + } + if status.Observed == nil || status.Observed.VMs != 20 { + t.Fatalf("expected saved manual test to refresh observed summary, got %+v", status.Observed) + } +} + func TestContract_SSOTestRejectsMetadataURLWithUserinfo(t *testing.T) { called := make(chan struct{}, 1) metadataServer := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 0cceb81ed..db589ce0e 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -265,6 +265,9 @@ var bareRouteAllowlist = []string{ "/api/truenas/connections", "/api/truenas/connections/test", "/api/truenas/connections/", + "/api/vmware/connections", + "/api/vmware/connections/test", + "/api/vmware/connections/", "/api/health", "/api/login", "/api/logout", @@ -390,8 +393,13 @@ var allRouteAllowlist = []string{ "/api/truenas/connections", "/api/truenas/connections/test", "/api/truenas/connections/", + "/api/vmware/connections", + "/api/vmware/connections/test", + "/api/vmware/connections/", "/api/admin/profiles/", "/api/config/system", + "/api/system/settings/telemetry-preview", + "/api/system/settings/telemetry-reset-id", "/api/system/mock-mode", "/api/license/status", "/api/webhooks/stripe", diff --git a/internal/api/router.go b/internal/api/router.go index 1f258199c..aac006976 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -52,6 +52,7 @@ import ( unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rcourtman/pulse-go-rewrite/internal/vmware" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" "github.com/rcourtman/pulse-go-rewrite/pkg/auth" @@ -70,6 +71,7 @@ type Router struct { alertHandlers *AlertHandlers configHandlers *ConfigHandlers trueNASHandlers *TrueNASHandlers + vmwareHandlers *VMwareHandlers notificationHandlers *NotificationHandlers notificationQueueHandlers *NotificationQueueHandlers dockerAgentHandlers *DockerAgentHandlers @@ -350,6 +352,9 @@ func (r *Router) setupRoutes() { getMonitor: r.configHandlers.getMonitor, getPoller: func(context.Context) *monitoring.TrueNASPoller { return r.trueNASPoller }, } + r.vmwareHandlers = &VMwareHandlers{ + getPersistence: r.configHandlers.getPersistence, + } recoveryManager := recoverymanager.New(r.multiTenant) r.recoveryHandlers = NewRecoveryHandlers(recoveryManager) if r.mtMonitor != nil { @@ -373,6 +378,7 @@ func (r *Router) setupRoutes() { } if mock.IsMockEnabled() { truenas.SetFeatureEnabled(true) + vmware.SetFeatureEnabled(true) mockTrueNASProvider := truenas.NewDefaultProvider() adapter := trueNASRecordsAdapter{provider: mockTrueNASProvider} r.resourceHandlers.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, adapter) diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go index 9d9410a24..fb9345de1 100644 --- a/internal/api/router_routes_registration.go +++ b/internal/api/router_routes_registration.go @@ -223,6 +223,53 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) { } }) + // VMware vCenter connection management + r.mux.HandleFunc("/api/vmware/connections", func(w http.ResponseWriter, req *http.Request) { + if r.vmwareHandlers == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil) + return + } + + switch req.Method { + case http.MethodGet: + RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.vmwareHandlers.HandleList))(w, req) + case http.MethodPost: + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleAdd))(w, req) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + r.mux.HandleFunc("/api/vmware/connections/test", func(w http.ResponseWriter, req *http.Request) { + if r.vmwareHandlers == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil) + return + } + + if req.Method == http.MethodPost { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleTestConnection))(w, req) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + r.mux.HandleFunc("/api/vmware/connections/", func(w http.ResponseWriter, req *http.Request) { + if r.vmwareHandlers == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil) + return + } + + if req.Method == http.MethodPost && strings.HasSuffix(strings.Trim(req.URL.Path, "/"), "/test") { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleTestSavedConnection))(w, req) + } else if req.Method == http.MethodDelete { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleDelete))(w, req) + } else if req.Method == http.MethodPut { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleUpdate))(w, req) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + // Config Profile Routes - Protected by Admin Auth, Settings Scope, and Pro License // SECURITY: Require settings:write scope to prevent low-privilege tokens from modifying agent profiles // r.configProfileHandler.ServeHTTP implements http.Handler, so we wrap it diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 8ba4940c8..898aa7778 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -1756,6 +1756,38 @@ func TestTrueNASConnectionMutationsRequireSettingsWriteScope(t *testing.T) { } } +func TestVMwareConnectionMutationsRequireSettingsWriteScope(t *testing.T) { + rawToken := "vmware-mutate-token-123.12345678" + record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) + cfg := newTestConfigWithTokens(t, record) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + paths := []struct { + method string + path string + body string + }{ + {method: http.MethodPost, path: "/api/vmware/connections", body: `{}`}, + {method: http.MethodPost, path: "/api/vmware/connections/test", body: `{}`}, + {method: http.MethodPut, path: "/api/vmware/connections/conn-1", body: `{}`}, + {method: http.MethodDelete, path: "/api/vmware/connections/conn-1", body: ""}, + {method: http.MethodPost, path: "/api/vmware/connections/conn-1/test", body: ""}, + } + + for _, tc := range paths { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code) + } + if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { + t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) + } + } +} + func TestConfigExportRequiresSettingsReadScope(t *testing.T) { rawToken := "config-export-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) diff --git a/internal/api/vmware_handlers.go b/internal/api/vmware_handlers.go new file mode 100644 index 000000000..febe8b4cb --- /dev/null +++ b/internal/api/vmware_handlers.go @@ -0,0 +1,643 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/vmware" +) + +const vmwareConnectionsPathPrefix = "/api/vmware/connections/" + +// VMwareHandlers manages VMware vCenter connection CRUD and connectivity checks. +type VMwareHandlers struct { + getPersistence func(ctx context.Context) *config.ConfigPersistence + newClient func(vmware.ClientConfig) (vmwareClient, error) + + statusMu sync.RWMutex + statuses map[string]vmwareConnectionRuntimeStatus +} + +type vmwareClient interface { + TestConnection(ctx context.Context) (*vmware.InventorySummary, error) + Close() +} + +type vmwareConnectionResponse struct { + config.VMwareVCenterInstance + Test *vmwareConnectionTestStatus `json:"test,omitempty"` + Observed *vmwareConnectionObservedStatus `json:"observed,omitempty"` +} + +type vmwareConnectionTestStatus struct { + LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"` + LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"` + LastError *vmwareConnectionTestError `json:"lastError,omitempty"` +} + +type vmwareConnectionTestError struct { + At *time.Time `json:"at,omitempty"` + Message string `json:"message,omitempty"` + Category string `json:"category,omitempty"` +} + +type vmwareConnectionObservedStatus struct { + CollectedAt *time.Time `json:"collectedAt,omitempty"` + Hosts int `json:"hosts"` + VMs int `json:"vms"` + Datastores int `json:"datastores"` + VIRelease string `json:"viRelease,omitempty"` +} + +type vmwareConnectionRuntimeStatus struct { + Test *vmwareConnectionTestStatus + Observed *vmwareConnectionObservedStatus +} + +// HandleAdd stores a new VMware vCenter connection. +func (h *VMwareHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) { + if !h.featureEnabled(w) { + return + } + if mock.IsMockEnabled() { + writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + defer r.Body.Close() + + var instance config.VMwareVCenterInstance + if err := json.NewDecoder(r.Body).Decode(&instance); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()}) + return + } + + instance.ID = strings.TrimSpace(instance.ID) + if instance.ID == "" { + instance.ID = config.NewVMwareVCenterInstance().ID + } + normalizeVMwareInstance(&instance) + + if err := instance.Validate(); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) + return + } + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + + instances, err := persistence.LoadVMwareConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()}) + return + } + + instances = append(instances, instance) + if err := persistence.SaveVMwareConfig(instances); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusCreated, instance.Redacted()) +} + +// HandleList returns all configured VMware connections with sensitive fields redacted. +func (h *VMwareHandlers) HandleList(w http.ResponseWriter, r *http.Request) { + if !h.featureEnabled(w) { + return + } + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + + instances, err := persistence.LoadVMwareConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()}) + return + } + + responses := make([]vmwareConnectionResponse, 0, len(instances)) + for i := range instances { + item := instances[i] + item.ApplyDefaults() + status := h.runtimeStatus(strings.TrimSpace(item.ID)) + responses = append(responses, vmwareConnectionResponse{ + VMwareVCenterInstance: item.Redacted(), + Test: status.Test, + Observed: status.Observed, + }) + } + + writeJSON(w, http.StatusOK, responses) +} + +// HandleDelete removes a configured VMware vCenter connection by ID. +func (h *VMwareHandlers) HandleDelete(w http.ResponseWriter, r *http.Request) { + if !h.featureEnabled(w) { + return + } + if mock.IsMockEnabled() { + writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil) + return + } + + connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path) + if !ok { + writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil) + return + } + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + + instances, err := persistence.LoadVMwareConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()}) + return + } + + index := -1 + for i := range instances { + if strings.TrimSpace(instances[i].ID) == connectionID { + index = i + break + } + } + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil) + return + } + + instances = append(instances[:index], instances[index+1:]...) + if err := persistence.SaveVMwareConfig(instances); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()}) + return + } + + h.clearRuntimeStatus(connectionID) + + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "id": connectionID, + }) +} + +// HandleUpdate replaces a configured VMware vCenter connection by ID while +// preserving unchanged masked secrets from the stored record. +func (h *VMwareHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) { + if !h.featureEnabled(w) { + return + } + if mock.IsMockEnabled() { + writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil) + return + } + + connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path) + if !ok { + writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + defer r.Body.Close() + + var instance config.VMwareVCenterInstance + if err := json.NewDecoder(r.Body).Decode(&instance); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()}) + return + } + + instance.ID = connectionID + normalizeVMwareInstance(&instance) + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + + instances, err := persistence.LoadVMwareConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()}) + return + } + + index := -1 + for i := range instances { + if strings.TrimSpace(instances[i].ID) == connectionID { + index = i + break + } + } + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil) + return + } + + instance.PreserveMaskedSecrets(instances[index]) + if err := instance.Validate(); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) + return + } + + instances[index] = instance + if err := persistence.SaveVMwareConfig(instances); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, instance.Redacted()) +} + +// HandleTestConnection validates connectivity for a proposed VMware vCenter connection. +func (h *VMwareHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) { + if !h.featureEnabled(w) { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + defer r.Body.Close() + + var instance config.VMwareVCenterInstance + if err := json.NewDecoder(r.Body).Decode(&instance); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()}) + return + } + + normalizeVMwareInstance(&instance) + if err := instance.Validate(); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) + return + } + + h.writeConnectionTestResult(w, r, instance) +} + +// HandleTestSavedConnection validates connectivity for one saved VMware +// connection using the server-owned stored secret material instead of relying +// on frontend redaction placeholders. +func (h *VMwareHandlers) HandleTestSavedConnection(w http.ResponseWriter, r *http.Request) { + if !h.featureEnabled(w) { + return + } + + connectionID, ok := vmwareConnectionIDFromTestPath(r.URL.Path) + if !ok { + writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil) + return + } + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + + instances, err := persistence.LoadVMwareConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()}) + return + } + + for i := range instances { + instance := instances[i] + if strings.TrimSpace(instance.ID) != connectionID { + continue + } + normalizeVMwareInstance(&instance) + payload, hasPayload, ok := decodeOptionalVMwareInstanceRequest(w, r) + if !ok { + return + } + if hasPayload { + payload.ID = connectionID + normalizeVMwareInstance(&payload) + payload.PreserveMaskedSecrets(instance) + if err := payload.Validate(); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) + return + } + instance = payload + } + + summary, invalidConfig, err := h.testConnectionInstance(r, instance) + if err != nil { + if !hasPayload { + h.recordTestFailure(connectionID, err, time.Now().UTC()) + } + h.writeConnectionFailure(w, invalidConfig, err) + return + } + if !hasPayload { + h.recordTestSuccess(connectionID, summary, time.Now().UTC()) + } + writeJSON(w, http.StatusOK, map[string]any{"success": true}) + return + } + + writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil) +} + +func (h *VMwareHandlers) writeConnectionTestResult( + w http.ResponseWriter, + r *http.Request, + instance config.VMwareVCenterInstance, +) { + _, invalidConfig, err := h.testConnectionInstance(r, instance) + if err != nil { + h.writeConnectionFailure(w, invalidConfig, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": true}) +} + +func (h *VMwareHandlers) writeConnectionFailure(w http.ResponseWriter, invalidConfig bool, err error) { + if invalidConfig { + writeErrorResponse(w, http.StatusBadRequest, "vmware_invalid_config", "Invalid VMware vCenter connection configuration", connectionFailureDetails(err)) + return + } + writeErrorResponse(w, http.StatusBadRequest, "vmware_connection_failed", "Failed to connect to VMware vCenter", connectionFailureDetails(err)) +} + +func connectionFailureDetails(err error) map[string]string { + if err == nil { + return nil + } + details := map[string]string{"error": err.Error()} + if category := connectionErrorCategory(err); category != "" { + details["category"] = category + } + return details +} + +func connectionErrorCategory(err error) string { + if err == nil { + return "" + } + if connectionErr, ok := err.(*vmware.ConnectionError); ok { + return strings.TrimSpace(connectionErr.Category) + } + return "" +} + +func (h *VMwareHandlers) testConnectionInstance( + r *http.Request, + instance config.VMwareVCenterInstance, +) (*vmware.InventorySummary, bool, error) { + newClient := h.newClient + if newClient == nil { + newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { return vmware.NewClient(cfg) } + } + + client, err := newClient(vmware.ClientConfig{ + Host: instance.Host, + Port: instance.Port, + Username: instance.Username, + Password: instance.Password, + InsecureSkipVerify: instance.InsecureSkipVerify, + Timeout: 10 * time.Second, + }) + if err != nil { + return nil, true, err + } + defer client.Close() + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + summary, err := client.TestConnection(ctx) + if err != nil { + return nil, false, err + } + return summary, false, nil +} + +func normalizeVMwareInstance(instance *config.VMwareVCenterInstance) { + if instance == nil { + return + } + instance.Name = strings.TrimSpace(instance.Name) + instance.Host = strings.TrimSpace(instance.Host) + instance.Username = strings.TrimSpace(instance.Username) + instance.Password = strings.TrimSpace(instance.Password) + instance.ApplyDefaults() +} + +func decodeOptionalVMwareInstanceRequest( + w http.ResponseWriter, + r *http.Request, +) (config.VMwareVCenterInstance, bool, bool) { + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()}) + return config.VMwareVCenterInstance{}, false, false + } + if len(bytes.TrimSpace(body)) == 0 { + return config.VMwareVCenterInstance{}, false, true + } + + var instance config.VMwareVCenterInstance + if err := json.Unmarshal(body, &instance); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()}) + return config.VMwareVCenterInstance{}, false, false + } + + return instance, true, true +} + +func (h *VMwareHandlers) featureEnabled(w http.ResponseWriter) bool { + if vmware.IsFeatureEnabled() { + return true + } + writeErrorResponse(w, http.StatusNotFound, "vmware_disabled", "VMware integration has been explicitly disabled", nil) + return false +} + +func (h *VMwareHandlers) persistenceForRequest(w http.ResponseWriter, ctx context.Context) *config.ConfigPersistence { + if h == nil || h.getPersistence == nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil) + return nil + } + persistence := h.getPersistence(ctx) + if persistence == nil { + writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil) + return nil + } + return persistence +} + +func (h *VMwareHandlers) runtimeStatus(connectionID string) vmwareConnectionRuntimeStatus { + if h == nil { + return vmwareConnectionRuntimeStatus{} + } + h.statusMu.RLock() + defer h.statusMu.RUnlock() + if h.statuses == nil { + return vmwareConnectionRuntimeStatus{} + } + status, ok := h.statuses[connectionID] + if !ok { + return vmwareConnectionRuntimeStatus{} + } + return cloneVMwareRuntimeStatus(status) +} + +func (h *VMwareHandlers) recordTestSuccess(connectionID string, summary *vmware.InventorySummary, at time.Time) { + if h == nil { + return + } + h.statusMu.Lock() + defer h.statusMu.Unlock() + if h.statuses == nil { + h.statuses = make(map[string]vmwareConnectionRuntimeStatus) + } + current := h.statuses[connectionID] + current.Test = &vmwareConnectionTestStatus{ + LastAttemptAt: timePointer(at), + LastSuccessAt: timePointer(at), + } + if summary != nil { + current.Observed = &vmwareConnectionObservedStatus{ + CollectedAt: timePointer(at), + Hosts: summary.Hosts, + VMs: summary.VMs, + Datastores: summary.Datastores, + VIRelease: strings.TrimSpace(summary.VIRelease), + } + } + h.statuses[connectionID] = current +} + +func (h *VMwareHandlers) recordTestFailure(connectionID string, err error, at time.Time) { + if h == nil { + return + } + h.statusMu.Lock() + defer h.statusMu.Unlock() + if h.statuses == nil { + h.statuses = make(map[string]vmwareConnectionRuntimeStatus) + } + current := h.statuses[connectionID] + test := current.Test + if test == nil { + test = &vmwareConnectionTestStatus{} + } + test.LastAttemptAt = timePointer(at) + test.LastError = &vmwareConnectionTestError{ + At: timePointer(at), + Message: err.Error(), + Category: connectionErrorCategory(err), + } + current.Test = test + h.statuses[connectionID] = current +} + +func (h *VMwareHandlers) clearRuntimeStatus(connectionID string) { + if h == nil { + return + } + h.statusMu.Lock() + defer h.statusMu.Unlock() + if h.statuses == nil { + return + } + delete(h.statuses, connectionID) +} + +func cloneVMwareRuntimeStatus(status vmwareConnectionRuntimeStatus) vmwareConnectionRuntimeStatus { + cloned := vmwareConnectionRuntimeStatus{} + if status.Test != nil { + test := *status.Test + if test.LastAttemptAt != nil { + test.LastAttemptAt = timePointer(*test.LastAttemptAt) + } + if test.LastSuccessAt != nil { + test.LastSuccessAt = timePointer(*test.LastSuccessAt) + } + if test.LastError != nil { + errCopy := *test.LastError + if errCopy.At != nil { + errCopy.At = timePointer(*errCopy.At) + } + test.LastError = &errCopy + } + cloned.Test = &test + } + if status.Observed != nil { + observed := *status.Observed + if observed.CollectedAt != nil { + observed.CollectedAt = timePointer(*observed.CollectedAt) + } + cloned.Observed = &observed + } + return cloned +} + +func timePointer(value time.Time) *time.Time { + v := value + return &v +} + +func vmwareConnectionIDFromPath(path string) (string, bool) { + if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) { + return "", false + } + + raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/") + if raw == "" || strings.Contains(raw, "/") { + return "", false + } + + connectionID, err := url.PathUnescape(raw) + if err != nil { + return "", false + } + connectionID = strings.TrimSpace(connectionID) + if connectionID == "" || strings.Contains(connectionID, "/") { + return "", false + } + return connectionID, true +} + +func vmwareConnectionIDFromTestPath(path string) (string, bool) { + if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) { + return "", false + } + + raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/") + if !strings.HasSuffix(raw, "/test") { + return "", false + } + raw = strings.TrimSuffix(raw, "/test") + if raw == "" || strings.Contains(raw, "/") { + return "", false + } + + connectionID, err := url.PathUnescape(raw) + if err != nil { + return "", false + } + connectionID = strings.TrimSpace(connectionID) + if connectionID == "" || strings.Contains(connectionID, "/") { + return "", false + } + return connectionID, true +} diff --git a/internal/api/vmware_handlers_test.go b/internal/api/vmware_handlers_test.go new file mode 100644 index 000000000..ea994f593 --- /dev/null +++ b/internal/api/vmware_handlers_test.go @@ -0,0 +1,547 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/vmware" +) + +type fakeVMwareClient struct { + testConnection func(context.Context) (*vmware.InventorySummary, error) +} + +func (c *fakeVMwareClient) TestConnection(ctx context.Context) (*vmware.InventorySummary, error) { + if c == nil || c.testConnection == nil { + return &vmware.InventorySummary{}, nil + } + return c.testConnection(ctx) +} + +func (c *fakeVMwareClient) Close() {} + +func TestVMwareHandlers_HandleAdd_Success(t *testing.T) { + setVMwareFeatureForTest(t, true) + setMockModeForVMwareTest(t, false) + + handler, persistence := newVMwareHandlersForTest(t) + + body := marshalVMwareRequest(t, map[string]any{ + "name": "lab-vcenter", + "host": "vcsa.lab.local", + "port": 443, + "username": "administrator@vsphere.local", + "password": "super-secret", + "enabled": true, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleAdd(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var created config.VMwareVCenterInstance + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if created.ID == "" { + t.Fatalf("expected generated ID, got empty") + } + if created.Password != "********" { + t.Fatalf("expected password redacted, got %q", created.Password) + } + + stored, err := persistence.LoadVMwareConfig() + if err != nil { + t.Fatalf("load saved config: %v", err) + } + if len(stored) != 1 { + t.Fatalf("expected 1 saved instance, got %d", len(stored)) + } + if stored[0].Password != "super-secret" { + t.Fatalf("expected unredacted password persisted, got %q", stored[0].Password) + } +} + +func TestVMwareHandlers_HandleAdd_ValidationAndFeatureGate(t *testing.T) { + t.Run("missing host", func(t *testing.T) { + setVMwareFeatureForTest(t, true) + setMockModeForVMwareTest(t, false) + handler, _ := newVMwareHandlersForTest(t) + + body := marshalVMwareRequest(t, map[string]any{ + "username": "administrator@vsphere.local", + "password": "super-secret", + }) + req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleAdd(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("feature disabled", func(t *testing.T) { + setVMwareFeatureForTest(t, false) + setMockModeForVMwareTest(t, false) + handler, _ := newVMwareHandlersForTest(t) + + body := marshalVMwareRequest(t, map[string]any{ + "host": "vcsa.lab.local", + "username": "administrator@vsphere.local", + "password": "super-secret", + }) + req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleAdd(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "explicitly disabled") { + t.Fatalf("expected explicit disable message, got %s", rec.Body.String()) + } + }) +} + +func TestVMwareHandlers_HandleList_RedactsSensitiveFieldsAndIncludesRuntimeSummary(t *testing.T) { + setVMwareFeatureForTest(t, true) + + handler, persistence := newVMwareHandlersForTest(t) + if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{ + { + ID: "vc-1", + Name: "lab-a", + Host: "vcsa-a.lab.local", + Port: 443, + Username: "administrator@vsphere.local", + Password: "secret-a", + InsecureSkipVerify: false, + Enabled: true, + }, + }); err != nil { + t.Fatalf("seed vmware config: %v", err) + } + + recordedAt := time.Date(2026, 3, 30, 10, 11, 12, 0, time.UTC) + handler.recordTestSuccess("vc-1", &vmware.InventorySummary{ + Hosts: 3, + VMs: 42, + Datastores: 6, + VIRelease: "8.0.3", + }, recordedAt) + + req := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var listed []vmwareConnectionResponse + if err := json.NewDecoder(rec.Body).Decode(&listed); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(listed) != 1 { + t.Fatalf("expected 1 listed instance, got %d", len(listed)) + } + if listed[0].Password != "********" { + t.Fatalf("expected password to be redacted, got %q", listed[0].Password) + } + if listed[0].Test == nil || listed[0].Test.LastSuccessAt == nil { + t.Fatalf("expected saved test runtime summary, got %+v", listed[0].Test) + } + if listed[0].Observed == nil { + t.Fatalf("expected observed summary, got nil") + } + if listed[0].Observed.Hosts != 3 || listed[0].Observed.VMs != 42 || listed[0].Observed.Datastores != 6 { + t.Fatalf("unexpected observed counts: %+v", listed[0].Observed) + } + if listed[0].Observed.VIRelease != "8.0.3" { + t.Fatalf("unexpected VI release: %+v", listed[0].Observed) + } +} + +func TestVMwareHandlers_HandleDelete_RemovesAndClearsRuntimeSummary(t *testing.T) { + setVMwareFeatureForTest(t, true) + setMockModeForVMwareTest(t, false) + + handler, persistence := newVMwareHandlersForTest(t) + if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{ + {ID: "alpha", Host: "vcsa-a.lab.local", Username: "admin", Password: "a"}, + {ID: "beta", Host: "vcsa-b.lab.local", Username: "admin", Password: "b"}, + }); err != nil { + t.Fatalf("seed vmware config: %v", err) + } + handler.recordTestSuccess("alpha", &vmware.InventorySummary{Hosts: 1}, time.Now().UTC()) + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/vmware/connections/alpha", nil) + deleteRec := httptest.NewRecorder() + handler.HandleDelete(deleteRec, deleteReq) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", deleteRec.Code, deleteRec.Body.String()) + } + + stored, err := persistence.LoadVMwareConfig() + if err != nil { + t.Fatalf("load persisted config: %v", err) + } + if len(stored) != 1 || stored[0].ID != "beta" { + t.Fatalf("expected only beta to remain, got %+v", stored) + } + if status := handler.runtimeStatus("alpha"); status.Test != nil || status.Observed != nil { + t.Fatalf("expected runtime summary to be cleared, got %+v", status) + } +} + +func TestVMwareHandlers_HandleUpdate_PreservesMaskedSecretsAndReplacesFields(t *testing.T) { + setVMwareFeatureForTest(t, true) + setMockModeForVMwareTest(t, false) + + handler, persistence := newVMwareHandlersForTest(t) + if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{ + { + ID: "alpha", + Name: "old-name", + Host: "old.lab.local", + Port: 443, + Username: "administrator@vsphere.local", + Password: "super-secret", + InsecureSkipVerify: false, + Enabled: true, + }, + }); err != nil { + t.Fatalf("seed vmware config: %v", err) + } + + body := marshalVMwareRequest(t, map[string]any{ + "id": "ignored-id", + "name": "new-name", + "host": "new.lab.local", + "port": 8443, + "username": "operator@vsphere.local", + "password": "********", + "insecureSkipVerify": true, + "enabled": true, + }) + + req := httptest.NewRequest(http.MethodPut, "/api/vmware/connections/alpha", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleUpdate(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var updated config.VMwareVCenterInstance + if err := json.NewDecoder(rec.Body).Decode(&updated); err != nil { + t.Fatalf("decode update response: %v", err) + } + if updated.ID != "alpha" { + t.Fatalf("expected path ID to win, got %q", updated.ID) + } + if updated.Password != "********" { + t.Fatalf("expected password to remain redacted, got %q", updated.Password) + } + + stored, err := persistence.LoadVMwareConfig() + if err != nil { + t.Fatalf("load persisted config: %v", err) + } + if len(stored) != 1 { + t.Fatalf("expected 1 stored instance, got %d", len(stored)) + } + if stored[0].Host != "new.lab.local" || stored[0].Port != 8443 { + t.Fatalf("expected updated endpoint to persist, got %+v", stored[0]) + } + if stored[0].Password != "super-secret" { + t.Fatalf("expected masked password to preserve stored secret, got %q", stored[0].Password) + } + if !stored[0].InsecureSkipVerify { + t.Fatalf("expected insecureSkipVerify update to persist") + } +} + +func TestVMwareHandlers_HandleTestConnection_SuccessAndFailure(t *testing.T) { + setVMwareFeatureForTest(t, true) + + handler, _ := newVMwareHandlersForTest(t) + var gotConfig vmware.ClientConfig + handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { + gotConfig = cfg + return &fakeVMwareClient{ + testConnection: func(context.Context) (*vmware.InventorySummary, error) { + return &vmware.InventorySummary{Hosts: 1, VMs: 2, Datastores: 3, VIRelease: "8.0.3"}, nil + }, + }, nil + } + + successBody := marshalVMwareRequest(t, map[string]any{ + "host": "vcsa.lab.local", + "port": 8443, + "username": "administrator@vsphere.local", + "password": "super-secret", + }) + successReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(successBody)) + successRec := httptest.NewRecorder() + handler.HandleTestConnection(successRec, successReq) + + if successRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", successRec.Code, successRec.Body.String()) + } + if gotConfig.Host != "vcsa.lab.local" || gotConfig.Port != 8443 { + t.Fatalf("unexpected client config: %+v", gotConfig) + } + + handler.newClient = nil + failureBody := marshalVMwareRequest(t, map[string]any{ + "host": "http://127.0.0.1/path", + "username": "administrator@vsphere.local", + "password": "super-secret", + }) + failureReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(failureBody)) + failureRec := httptest.NewRecorder() + handler.HandleTestConnection(failureRec, failureReq) + + if failureRec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for bad host, got %d: %s", failureRec.Code, failureRec.Body.String()) + } + + handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { + return &fakeVMwareClient{ + testConnection: func(context.Context) (*vmware.InventorySummary, error) { + return nil, errors.New("boom") + }, + }, nil + } + errorBody := marshalVMwareRequest(t, map[string]any{ + "host": "vcsa.lab.local", + "username": "administrator@vsphere.local", + "password": "super-secret", + }) + errorReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(errorBody)) + errorRec := httptest.NewRecorder() + handler.HandleTestConnection(errorRec, errorReq) + + if errorRec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for failing connection, got %d: %s", errorRec.Code, errorRec.Body.String()) + } +} + +func TestVMwareHandlers_HandleTestSavedConnection_UsesStoredSecretsAndUpdatesRuntimeSummary(t *testing.T) { + setVMwareFeatureForTest(t, true) + + connection := config.VMwareVCenterInstance{ + ID: "conn-1", + Name: "lab-vcenter", + Host: "vcsa.lab.local", + Port: 443, + Username: "administrator@vsphere.local", + Password: "super-secret", + InsecureSkipVerify: false, + Enabled: true, + } + handler, persistence := newVMwareHandlersForTest(t) + if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil { + t.Fatalf("seed vmware config: %v", err) + } + + var gotConfig vmware.ClientConfig + handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { + gotConfig = cfg + return &fakeVMwareClient{ + testConnection: func(context.Context) (*vmware.InventorySummary, error) { + return &vmware.InventorySummary{Hosts: 4, VMs: 25, Datastores: 5, VIRelease: "8.0.3"}, nil + }, + }, nil + } + + req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil) + rec := httptest.NewRecorder() + handler.HandleTestSavedConnection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if gotConfig.Host != "vcsa.lab.local" || gotConfig.Password != "super-secret" { + t.Fatalf("unexpected saved client config: %+v", gotConfig) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil) + listRec := httptest.NewRecorder() + handler.HandleList(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list expected 200, got %d: %s", listRec.Code, listRec.Body.String()) + } + + var listed []vmwareConnectionResponse + if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(listed) != 1 || listed[0].Test == nil || listed[0].Test.LastSuccessAt == nil { + t.Fatalf("expected saved retest to update runtime status, got %+v", listed) + } + if listed[0].Observed == nil || listed[0].Observed.VMs != 25 { + t.Fatalf("expected saved retest to update observed summary, got %+v", listed[0].Observed) + } +} + +func TestVMwareHandlers_HandleTestSavedConnection_UpdatesRuntimeSummaryFailure(t *testing.T) { + setVMwareFeatureForTest(t, true) + + connection := config.VMwareVCenterInstance{ + ID: "conn-1", + Host: "vcsa.lab.local", + Username: "administrator@vsphere.local", + Password: "super-secret", + Enabled: true, + } + handler, persistence := newVMwareHandlersForTest(t) + if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil { + t.Fatalf("seed vmware config: %v", err) + } + handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { + return &fakeVMwareClient{ + testConnection: func(context.Context) (*vmware.InventorySummary, error) { + return nil, &vmware.ConnectionError{Category: "auth", Message: "authentication failed"} + }, + }, nil + } + + req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil) + rec := httptest.NewRecorder() + handler.HandleTestSavedConnection(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + + status := handler.runtimeStatus(connection.ID) + if status.Test == nil || status.Test.LastError == nil { + t.Fatalf("expected saved retest failure to update runtime summary, got %+v", status.Test) + } + if status.Test.LastError.Message != "authentication failed" || status.Test.LastError.Category != "auth" { + t.Fatalf("unexpected failure summary: %+v", status.Test.LastError) + } +} + +func TestVMwareHandlers_HandleTestSavedConnection_MergesEditedPayloadWithoutOverwritingRuntimeSummary(t *testing.T) { + setVMwareFeatureForTest(t, true) + + handler, persistence := newVMwareHandlersForTest(t) + if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{ + { + ID: "conn-1", + Name: "lab-vcenter", + Host: "vcsa.lab.local", + Port: 443, + Username: "administrator@vsphere.local", + Password: "super-secret", + InsecureSkipVerify: false, + Enabled: true, + }, + }); err != nil { + t.Fatalf("seed vmware config: %v", err) + } + + previousAt := time.Date(2026, 3, 30, 9, 0, 0, 0, time.UTC) + handler.recordTestSuccess("conn-1", &vmware.InventorySummary{Hosts: 1, VMs: 2, Datastores: 3, VIRelease: "8.0.2.0"}, previousAt) + + var gotConfig vmware.ClientConfig + handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { + gotConfig = cfg + return &fakeVMwareClient{ + testConnection: func(context.Context) (*vmware.InventorySummary, error) { + return &vmware.InventorySummary{Hosts: 9, VMs: 99, Datastores: 12, VIRelease: "8.0.3"}, nil + }, + }, nil + } + + body := marshalVMwareRequest(t, map[string]any{ + "name": "edited-vcenter", + "host": "edited.lab.local", + "port": 8443, + "username": "operator@vsphere.local", + "password": "********", + "insecureSkipVerify": true, + "enabled": true, + }) + req := httptest.NewRequest( + http.MethodPost, + "/api/vmware/connections/conn-1/test", + bytes.NewReader(body), + ) + rec := httptest.NewRecorder() + handler.HandleTestSavedConnection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if gotConfig.Host != "edited.lab.local" || gotConfig.Port != 8443 { + t.Fatalf("expected edited endpoint, got %+v", gotConfig) + } + if gotConfig.Password != "super-secret" { + t.Fatalf("expected stored password to be reused, got %+v", gotConfig) + } + + status := handler.runtimeStatus("conn-1") + if status.Observed == nil || status.Observed.VMs != 2 || status.Observed.VIRelease != "8.0.2.0" { + t.Fatalf("expected existing runtime summary to remain unchanged, got %+v", status.Observed) + } + if status.Test == nil || status.Test.LastSuccessAt == nil || !status.Test.LastSuccessAt.Equal(previousAt) { + t.Fatalf("expected existing last success timestamp to remain unchanged, got %+v", status.Test) + } +} + +func newVMwareHandlersForTest(t *testing.T) (*VMwareHandlers, *config.ConfigPersistence) { + t.Helper() + + persistence := config.NewConfigPersistence(t.TempDir()) + handler := &VMwareHandlers{ + getPersistence: func(context.Context) *config.ConfigPersistence { return persistence }, + } + + return handler, persistence +} + +func setVMwareFeatureForTest(t *testing.T, enabled bool) { + t.Helper() + previous := vmware.IsFeatureEnabled() + vmware.SetFeatureEnabled(enabled) + t.Cleanup(func() { + vmware.SetFeatureEnabled(previous) + }) +} + +func setMockModeForVMwareTest(t *testing.T, enabled bool) { + t.Helper() + previous := mock.IsMockEnabled() + mock.SetEnabled(enabled) + t.Cleanup(func() { + mock.SetEnabled(previous) + }) +} + +func marshalVMwareRequest(t *testing.T, payload map[string]any) []byte { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + return body +} diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 34cd07c83..843714955 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -33,6 +33,7 @@ type ConfigPersistence struct { appriseFile string nodesFile string trueNASFile string + vmwareFile string systemFile string ssoFile string apiTokensFile string @@ -95,6 +96,7 @@ type resolvedConfigPersistencePaths struct { appriseFile string nodesFile string trueNASFile string + vmwareFile string systemFile string ssoFile string apiTokensFile string @@ -143,6 +145,10 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers if err != nil { return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve truenas.enc: %w", err) } + vmwareFile, err := resolveLeaf("vmware.enc") + if err != nil { + return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve vmware.enc: %w", err) + } systemFile, err := resolveLeaf("system.json") if err != nil { return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve system.json: %w", err) @@ -199,6 +205,7 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers appriseFile: appriseFile, nodesFile: nodesFile, trueNASFile: trueNASFile, + vmwareFile: vmwareFile, systemFile: systemFile, ssoFile: ssoFile, apiTokensFile: apiTokensFile, @@ -248,6 +255,7 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) { appriseFile: resolvedPaths.appriseFile, nodesFile: resolvedPaths.nodesFile, trueNASFile: resolvedPaths.trueNASFile, + vmwareFile: resolvedPaths.vmwareFile, systemFile: resolvedPaths.systemFile, ssoFile: resolvedPaths.ssoFile, apiTokensFile: resolvedPaths.apiTokensFile, @@ -604,6 +612,18 @@ func (c *ConfigPersistence) LoadTrueNASConfig() ([]TrueNASInstance, error) { return loadSlice[TrueNASInstance](c, c.trueNASFile, true) } +// SaveVMwareConfig persists VMware vCenter instance configuration to encrypted +// storage. +func (c *ConfigPersistence) SaveVMwareConfig(instances []VMwareVCenterInstance) error { + return saveJSON(c, c.vmwareFile, instances, true) +} + +// LoadVMwareConfig loads VMware vCenter instance configuration from encrypted +// storage. +func (c *ConfigPersistence) LoadVMwareConfig() ([]VMwareVCenterInstance, error) { + return loadSlice[VMwareVCenterInstance](c, c.vmwareFile, true) +} + // SaveAPITokens persists API token metadata to disk. func (c *ConfigPersistence) SaveAPITokens(tokens []APITokenRecord) error { c.mu.Lock() diff --git a/internal/config/vmware.go b/internal/config/vmware.go new file mode 100644 index 000000000..d0ea3cecc --- /dev/null +++ b/internal/config/vmware.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/google/uuid" +) + +const vmwareSensitiveMask = "********" +const defaultVMwarePort = 443 + +// IsVMwareSensitiveMask reports whether the value is the redacted placeholder +// used by the VMware settings API. +func IsVMwareSensitiveMask(value string) bool { + return strings.TrimSpace(value) == vmwareSensitiveMask +} + +// VMwareVCenterInstance represents a configured VMware vCenter endpoint. +type VMwareVCenterInstance struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` + Enabled bool `json:"enabled"` +} + +// NewVMwareVCenterInstance returns a new instance with generated ID and sane +// defaults. +func NewVMwareVCenterInstance() VMwareVCenterInstance { + return VMwareVCenterInstance{ + ID: uuid.NewString(), + Port: defaultVMwarePort, + Enabled: true, + } +} + +// ApplyDefaults normalizes legacy zero-value config onto the canonical stored +// defaults without changing explicitly configured values. +func (v *VMwareVCenterInstance) ApplyDefaults() { + if v == nil { + return + } + if v.Port <= 0 { + v.Port = defaultVMwarePort + } +} + +// Validate performs required VMware configuration checks. +func (v *VMwareVCenterInstance) Validate() error { + if v == nil { + return fmt.Errorf("vmware vcenter instance is required") + } + if strings.TrimSpace(v.Host) == "" { + return fmt.Errorf("vmware vcenter host is required") + } + if strings.TrimSpace(v.Username) == "" || strings.TrimSpace(v.Password) == "" { + return fmt.Errorf("vmware credentials are required: provide username and password") + } + return nil +} + +// Redacted returns a copy with sensitive credentials masked. +func (v *VMwareVCenterInstance) Redacted() VMwareVCenterInstance { + if v == nil { + return VMwareVCenterInstance{} + } + redacted := *v + if strings.TrimSpace(redacted.Password) != "" { + redacted.Password = vmwareSensitiveMask + } + return redacted +} + +// PreserveMaskedSecrets restores stored credentials when an update payload uses +// the API redaction placeholder for unchanged secret fields. +func (v *VMwareVCenterInstance) PreserveMaskedSecrets(existing VMwareVCenterInstance) { + if v == nil { + return + } + if IsVMwareSensitiveMask(v.Password) { + v.Password = existing.Password + } +} diff --git a/internal/vmware/client.go b/internal/vmware/client.go new file mode 100644 index 000000000..2a860d6f1 --- /dev/null +++ b/internal/vmware/client.go @@ -0,0 +1,387 @@ +package vmware + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "strconv" + "strings" + "sync/atomic" + "time" +) + +const ( + // FeatureVMware allows explicit opt-out of the default-on VMware vCenter + // platform integration. + FeatureVMware = "PULSE_ENABLE_VMWARE" + defaultTimeout = 10 * time.Second +) + +var featureVMwareEnabled atomic.Bool + +func init() { + featureVMwareEnabled.Store(parseFeatureEnabled(os.Getenv(FeatureVMware))) +} + +// IsFeatureEnabled returns whether the VMware vCenter integration is enabled. +func IsFeatureEnabled() bool { + return featureVMwareEnabled.Load() +} + +// SetFeatureEnabled allows tests to control the feature flag. +func SetFeatureEnabled(enabled bool) { + featureVMwareEnabled.Store(enabled) +} + +func parseFeatureEnabled(raw string) bool { + switch strings.TrimSpace(strings.ToLower(raw)) { + case "", "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return true + } +} + +// ConnectionError classifies VMware connection failures for API consumers. +type ConnectionError struct { + Category string + Message string +} + +func (e *ConnectionError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +// InventorySummary captures the minimum read-side floor proven by a successful +// VMware connection test. +type InventorySummary struct { + Hosts int + VMs int + Datastores int + VIRelease string +} + +// ClientConfig configures a VMware vCenter client. +type ClientConfig struct { + Host string + Port int + Username string + Password string + InsecureSkipVerify bool + Timeout time.Duration +} + +// Client executes phase-1 VMware connection validation. +type Client struct { + baseURL *url.URL + httpClient *http.Client + username string + password string +} + +// NewClient constructs a VMware client from saved connection input. +func NewClient(cfg ClientConfig) (*Client, error) { + baseURL, err := normalizeBaseURL(cfg.Host, cfg.Port) + if err != nil { + return nil, &ConnectionError{Category: "invalid_config", Message: err.Error()} + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("create cookie jar: %w", err) + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.InsecureSkipVerify, // operator-controlled onboarding setting + }, + } + return &Client{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: timeout, + Transport: transport, + Jar: jar, + }, + username: strings.TrimSpace(cfg.Username), + password: strings.TrimSpace(cfg.Password), + }, nil +} + +// Close releases idle resources held by the underlying HTTP client. +func (c *Client) Close() { + if c == nil || c.httpClient == nil { + return + } + if transport, ok := c.httpClient.Transport.(*http.Transport); ok { + transport.CloseIdleConnections() + } +} + +// TestConnection validates both the Automation API and VI JSON API families and +// returns a minimal inventory summary on success. +func (c *Client) TestConnection(ctx context.Context) (*InventorySummary, error) { + automationSessionID, err := c.createAutomationSession(ctx) + if err != nil { + return nil, err + } + hosts, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/host", "host inventory") + if err != nil { + return nil, err + } + vms, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/vm", "vm inventory") + if err != nil { + return nil, err + } + datastores, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/datastore", "datastore inventory") + if err != nil { + return nil, err + } + release, sessionManagerMoID, err := c.resolveVIJSONRelease(ctx) + if err != nil { + return nil, err + } + if err := c.loginVIJSON(ctx, release, sessionManagerMoID); err != nil { + return nil, err + } + return &InventorySummary{ + Hosts: len(hosts), + VMs: len(vms), + Datastores: len(datastores), + VIRelease: release, + }, nil +} + +func normalizeBaseURL(rawHost string, port int) (*url.URL, error) { + host := strings.TrimSpace(rawHost) + if host == "" { + return nil, fmt.Errorf("vmware vcenter host is required") + } + if !strings.Contains(host, "://") { + host = "https://" + host + } + parsed, err := url.Parse(host) + if err != nil { + return nil, fmt.Errorf("invalid vmware vcenter host: %w", err) + } + if parsed.Scheme != "https" { + return nil, fmt.Errorf("vmware vcenter connections must use https") + } + if parsed.Host == "" { + return nil, fmt.Errorf("vmware vcenter host is required") + } + if parsed.Path != "" && parsed.Path != "/" { + return nil, fmt.Errorf("vmware vcenter host must not include a path") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("vmware vcenter host must not include query or fragment data") + } + if parsed.Port() == "" { + if port <= 0 { + port = 443 + } + parsed.Host = net.JoinHostPort(parsed.Hostname(), strconv.Itoa(port)) + } + parsed.Path = "" + return parsed, nil +} + +func (c *Client) createAutomationSession(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String()+"/api/session", nil) + if err != nil { + return "", fmt.Errorf("build automation session request: %w", err) + } + req.SetBasicAuth(c.username, c.password) + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return "", classifyTransportError("automation session", err) + } + defer resp.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return "", fmt.Errorf("read automation session response: %w", readErr) + } + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + default: + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return "", &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the Automation API session"} + } + return "", &ConnectionError{ + Category: "endpoint", + Message: fmt.Sprintf("VMware Automation API session request failed with HTTP %d", resp.StatusCode), + } + } + var sessionID string + if err := json.Unmarshal(body, &sessionID); err != nil || strings.TrimSpace(sessionID) == "" { + return "", &ConnectionError{Category: "endpoint", Message: "VMware Automation API returned an invalid session payload"} + } + return strings.TrimSpace(sessionID), nil +} + +func (c *Client) listAutomationResources( + ctx context.Context, + sessionID string, + path string, + label string, +) ([]json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.String()+path, nil) + if err != nil { + return nil, fmt.Errorf("build %s request: %w", label, err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("vmware-api-session-id", sessionID) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, classifyTransportError(label, err) + } + defer resp.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return nil, fmt.Errorf("read %s response: %w", label, readErr) + } + switch resp.StatusCode { + case http.StatusOK: + case http.StatusUnauthorized: + return nil, &ConnectionError{Category: "auth", Message: fmt.Sprintf("VMware authentication failed while reading %s", label)} + case http.StatusForbidden: + return nil, &ConnectionError{Category: "permission", Message: fmt.Sprintf("VMware permissions are insufficient for %s", label)} + default: + return nil, &ConnectionError{ + Category: "endpoint", + Message: fmt.Sprintf("VMware %s request failed with HTTP %d", label, resp.StatusCode), + } + } + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + return nil, &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware %s response was not valid JSON", label)} + } + return items, nil +} + +func (c *Client) resolveVIJSONRelease(ctx context.Context) (string, string, error) { + releases := []string{"9.0.0.0", "8.0.3", "8.0.2.0", "8.0.1.0"} + var lastErr error + for _, release := range releases { + moID, err := c.fetchSessionManagerMoID(ctx, release) + if err == nil { + return release, moID, nil + } + lastErr = err + connectionErr, ok := err.(*ConnectionError) + if !ok || connectionErr.Category != "endpoint" { + return "", "", err + } + } + if lastErr != nil { + return "", "", lastErr + } + return "", "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API release negotiation failed"} +} + +func (c *Client) fetchSessionManagerMoID(ctx context.Context, release string) (string, error) { + endpoint := fmt.Sprintf("%s/sdk/vim25/%s/ServiceInstance/ServiceInstance/content", c.baseURL.String(), release) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("build vi-json service instance request: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return "", classifyTransportError("vi-json service content", err) + } + defer resp.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return "", fmt.Errorf("read vi-json service content response: %w", readErr) + } + if resp.StatusCode == http.StatusNotFound { + return "", &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API release %s is unavailable", release)} + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API service-instance request failed with HTTP %d", resp.StatusCode)} + } + var payload struct { + SessionManager struct { + Value string `json:"value"` + } `json:"sessionManager"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response was not valid JSON"} + } + moID := strings.TrimSpace(payload.SessionManager.Value) + if moID == "" { + return "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response did not include a session manager reference"} + } + return moID, nil +} + +func (c *Client) loginVIJSON(ctx context.Context, release string, sessionManagerMoID string) error { + body, err := json.Marshal(map[string]string{ + "userName": c.username, + "password": c.password, + }) + if err != nil { + return fmt.Errorf("marshal vi-json login request: %w", err) + } + endpoint := fmt.Sprintf("%s/sdk/vim25/%s/SessionManager/%s/Login", c.baseURL.String(), release, sessionManagerMoID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("build vi-json login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return classifyTransportError("vi-json login", err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + switch resp.StatusCode { + case http.StatusOK: + if strings.TrimSpace(resp.Header.Get("vmware-api-session-id")) == "" { + return &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API login succeeded without returning a session id"} + } + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the VI JSON API session"} + default: + return &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API login failed with HTTP %d", resp.StatusCode)} + } +} + +func classifyTransportError(stage string, err error) error { + if err == nil { + return nil + } + lower := strings.ToLower(err.Error()) + if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") { + return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)} + } + var unknownAuthority *x509.UnknownAuthorityError + if errors.As(err, &unknownAuthority) { + return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)} + } + var netErr net.Error + if errors.As(err, &netErr) { + return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware network error during %s", stage)} + } + return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware connection failed during %s", stage)} +} diff --git a/tests/integration/tests/22-vmware-settings-platform-connections.spec.ts b/tests/integration/tests/22-vmware-settings-platform-connections.spec.ts new file mode 100644 index 000000000..915c3e66d --- /dev/null +++ b/tests/integration/tests/22-vmware-settings-platform-connections.spec.ts @@ -0,0 +1,398 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test as base, expect } from '@playwright/test'; +import { createAuthenticatedStorageState } from './helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type WorkerFixtures = { + authStorageStatePath: string; +}; + +const SCREENSHOT_PATH = path.resolve( + __dirname, + '..', + '..', + 'tmp', + 'vmware-settings-platform-connections.png', +); +const WORKFLOW_SCREENSHOT_PATH = path.resolve( + __dirname, + '..', + '..', + 'tmp', + 'vmware-settings-platform-workflow.png', +); + +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', + `vmware-settings-platform-connections-${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' }], +}); + +test.describe('VMware platform connections settings', () => { + test.setTimeout(180_000); + + test('renders the platform-connections workspace with the VMware integration shell', async ({ + page, + }) => { + const healthyAt = new Date(Date.now() - 5 * 60_000).toISOString(); + const failingAt = new Date(Date.now() - 2 * 60_000).toISOString(); + + await page.route('**/api/vmware/connections', async (route) => { + if (route.request().method() !== 'GET') { + await route.continue(); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 'vmware-1', + name: 'Primary vCenter', + host: 'vcsa-primary.local', + port: 443, + username: 'administrator@vsphere.local', + password: '********', + insecureSkipVerify: false, + enabled: true, + test: { + lastSuccessAt: healthyAt, + }, + observed: { + collectedAt: healthyAt, + hosts: 6, + vms: 120, + datastores: 18, + viRelease: '8.0.3', + }, + }, + { + id: 'vmware-2', + name: 'Staging vCenter', + host: 'vcsa-staging.local', + port: 443, + username: 'operator@vsphere.local', + password: '********', + insecureSkipVerify: true, + enabled: true, + test: { + lastAttemptAt: failingAt, + lastError: { + at: failingAt, + message: 'authentication failed', + category: 'auth', + }, + }, + }, + ]), + }); + }); + + await page.goto('/settings/infrastructure/platforms/vmware', { + waitUntil: 'domcontentloaded', + }); + await page.waitForURL(/\/settings\/infrastructure\/platforms\/vmware/, { + timeout: 15_000, + }); + + await expect( + page.getByRole('heading', { level: 1, name: 'Infrastructure Operations' }), + ).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Platform connections' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.getByRole('tab', { name: 'VMware' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.getByRole('tab', { name: 'TrueNAS' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + await expect(page.getByRole('tab', { name: 'Proxmox' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + + await expect(page.getByText('VMware vSphere platform integration')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Add VMware connection' })).toBeVisible(); + + const primaryCard = page.getByTestId('vmware-connection-vmware-1'); + await expect(primaryCard).toContainText('Primary vCenter'); + await expect(primaryCard).toContainText('Validated'); + await expect(primaryCard).toContainText('6 hosts'); + await expect(primaryCard).toContainText('120 vms'); + await expect(primaryCard).toContainText('18 datastores'); + await expect(primaryCard).toContainText('VI JSON 8.0.3'); + + const failingCard = page.getByTestId('vmware-connection-vmware-2'); + await expect(failingCard).toContainText('Staging vCenter'); + await expect(failingCard).toContainText('Validation failing'); + await expect(failingCard).toContainText('authentication failed'); + await expect(failingCard).toContainText('Skip TLS verification'); + + fs.mkdirSync(path.dirname(SCREENSHOT_PATH), { recursive: true }); + await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); + }); + + test('adds, edits, retests, and deletes VMware connections through the canonical settings workflow', async ({ + page, + }) => { + const validatedAt = new Date(Date.now() - 60_000).toISOString(); + let connections: Record[] = []; + let draftTestPayload: Record | null = null; + let createPayload: Record | null = null; + let updatePayload: Record | null = null; + let draftTestCalls = 0; + const savedTestRequests: Array<{ path: string; payload: Record | null }> = []; + const deletePaths: string[] = []; + + await page.route('**/api/vmware/connections**', async (route) => { + const request = route.request(); + const method = request.method(); + const pathname = new URL(request.url()).pathname; + + if (pathname === '/api/vmware/connections' && method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(connections), + }); + return; + } + + if (pathname === '/api/vmware/connections/test' && method === 'POST') { + draftTestCalls += 1; + draftTestPayload = JSON.parse(request.postData() || '{}'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + return; + } + + if (pathname === '/api/vmware/connections' && method === 'POST') { + createPayload = JSON.parse(request.postData() || '{}'); + connections = [ + { + id: 'conn-1', + name: createPayload.name, + host: createPayload.host, + port: createPayload.port, + username: createPayload.username, + password: '********', + insecureSkipVerify: createPayload.insecureSkipVerify, + enabled: createPayload.enabled, + test: { + lastSuccessAt: validatedAt, + }, + observed: { + collectedAt: validatedAt, + hosts: 4, + vms: 48, + datastores: 8, + viRelease: '8.0.3', + }, + }, + ]; + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(connections[0]), + }); + return; + } + + if (pathname === '/api/vmware/connections/conn-1/test' && method === 'POST') { + savedTestRequests.push({ + path: pathname, + payload: request.postData() ? JSON.parse(request.postData() || '{}') : null, + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + return; + } + + if (pathname === '/api/vmware/connections/conn-1' && method === 'PUT') { + updatePayload = JSON.parse(request.postData() || '{}'); + connections = [ + { + id: 'conn-1', + name: updatePayload.name, + host: updatePayload.host, + port: updatePayload.port, + username: updatePayload.username, + password: '********', + insecureSkipVerify: updatePayload.insecureSkipVerify, + enabled: updatePayload.enabled, + test: { + lastSuccessAt: validatedAt, + }, + observed: { + collectedAt: validatedAt, + hosts: 4, + vms: 48, + datastores: 8, + viRelease: '8.0.3', + }, + }, + ]; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(connections[0]), + }); + return; + } + + if (pathname === '/api/vmware/connections/conn-1' && method === 'DELETE') { + deletePaths.push(pathname); + connections = []; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, id: 'conn-1' }), + }); + return; + } + + await route.continue(); + }); + + await page.goto('/settings/infrastructure/platforms/vmware', { + waitUntil: 'domcontentloaded', + }); + await page.waitForURL(/\/settings\/infrastructure\/platforms\/vmware/, { + timeout: 15_000, + }); + + await expect(page.getByText('No VMware connections yet')).toBeVisible(); + + await page.getByRole('button', { name: 'Add VMware connection' }).click(); + const dialog = page.getByRole('dialog', { name: 'Add VMware connection' }); + await expect(dialog).toBeVisible(); + + await dialog.getByPlaceholder('lab-vcenter').fill('Lab vCenter'); + await dialog.getByPlaceholder('vcsa.lab.local').fill('vcsa.lab.local'); + await dialog.getByPlaceholder('443').fill('443'); + await dialog.getByPlaceholder('administrator@vsphere.local').fill( + 'administrator@vsphere.local', + ); + await dialog.locator('input[type="password"]').first().fill('super-secret'); + + await dialog.getByRole('button', { name: 'Test connection' }).click(); + await expect.poll(() => draftTestPayload).toMatchObject({ + name: 'Lab vCenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: 'super-secret', + enabled: true, + insecureSkipVerify: false, + }); + + await dialog.getByRole('button', { name: 'Add connection' }).click(); + + const connectionCard = page.getByTestId('vmware-connection-conn-1'); + await expect(connectionCard).toBeVisible(); + await expect(connectionCard).toContainText('Lab vCenter'); + await expect(connectionCard).toContainText('Validated'); + await expect.poll(() => createPayload).toMatchObject({ + name: 'Lab vCenter', + host: 'vcsa.lab.local', + port: 443, + username: 'administrator@vsphere.local', + password: 'super-secret', + enabled: true, + insecureSkipVerify: false, + }); + + await connectionCard.getByRole('button', { name: 'Edit' }).click(); + const editDialog = page.getByRole('dialog', { name: 'Edit VMware connection' }); + await expect(editDialog).toBeVisible(); + await expect( + editDialog.getByPlaceholder('Saved password retained unless replaced'), + ).toBeVisible(); + + await editDialog.getByPlaceholder('lab-vcenter').fill('Lab vCenter Edited'); + await editDialog.getByPlaceholder('vcsa.lab.local').fill('edited.lab.local'); + await editDialog.getByPlaceholder('443').fill('8443'); + await editDialog.getByPlaceholder('administrator@vsphere.local').fill('operator@vsphere.local'); + await editDialog.getByLabel('Skip TLS verification').check(); + + await editDialog.getByRole('button', { name: 'Test connection' }).click(); + await expect.poll(() => savedTestRequests[0]).toMatchObject({ + path: '/api/vmware/connections/conn-1/test', + payload: { + name: 'Lab vCenter Edited', + host: 'edited.lab.local', + port: 8443, + username: 'operator@vsphere.local', + password: '********', + enabled: true, + insecureSkipVerify: true, + }, + }); + await expect.poll(() => draftTestCalls).toBe(1); + + await editDialog.getByRole('button', { name: 'Save connection' }).click(); + await expect.poll(() => updatePayload).toMatchObject({ + name: 'Lab vCenter Edited', + host: 'edited.lab.local', + port: 8443, + username: 'operator@vsphere.local', + password: '********', + enabled: true, + insecureSkipVerify: true, + }); + await expect(connectionCard).toContainText('Lab vCenter Edited'); + + await connectionCard.getByRole('button', { name: 'Test' }).click(); + await expect.poll(() => savedTestRequests).toEqual([ + expect.objectContaining({ + path: '/api/vmware/connections/conn-1/test', + payload: expect.objectContaining({ + host: 'edited.lab.local', + password: '********', + insecureSkipVerify: true, + }), + }), + { path: '/api/vmware/connections/conn-1/test', payload: null }, + ]); + await expect.poll(() => draftTestCalls).toBe(1); + + await connectionCard.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete connection' }).click(); + await expect.poll(() => deletePaths).toEqual(['/api/vmware/connections/conn-1']); + await expect(page.getByText('No VMware connections yet')).toBeVisible(); + + fs.mkdirSync(path.dirname(WORKFLOW_SCREENSHOT_PATH), { recursive: true }); + await page.screenshot({ path: WORKFLOW_SCREENSHOT_PATH, fullPage: true }); + }); +});