Merge pull request #1995 from rcourtman/maintainer/20260908T214026Z

Improve update guidance, webhook parity, and frontend security
This commit is contained in:
pulse-triage[bot]
2026-09-08 23:39:50 +00:00
committed by GitHub
26 changed files with 700 additions and 691 deletions
@@ -2828,3 +2828,14 @@ The presentation unit tests cover the label and terminal guidance.
cards in Chromium at desktop, tablet and phone widths, asserting the copy,
absence of page errors and horizontal overflow. Scripted props are component
evidence only, not installed notification delivery.
### Webhook test/save custom-field parity
The webhook form applies the same service-specific custom-field normalisation
when testing unsaved data as when saving it. In particular, manually entered
Pushover app_token/user_token aliases become token/user with the existing
canonical-value precedence. Existing-field editing remains normalised on load.
This changes neither delivery scheduling nor the meaning of a successful test.
The registered WebhookConfig regression covers alias test/save payload equality;
the browser fixture exercises the real form at desktop and phone widths with
synthetic callbacks, not a hosted Pushover destination or installed delivery.
@@ -3359,6 +3359,18 @@ stages pin the Docker Official Images Linux amd64 manifest
the checked-in toolchain files and release-script guards must reject an older
compiler so local, exact-candidate, provider control-plane, and container builds
cannot silently reintroduce the vulnerable runtime.
The frontend development-tool dependency boundary must retain patched Vitest
and mocker versions at or above `4.1.11`, with the V8 coverage provider matched
to the runner, and a `js-yaml` override at or above `4.3.2`. These floors prevent
reintroducing redirect-mock file reads (GHSA-82fw-gwwq-j7x9) and empty-merge CPU
exhaustion (GHSA-2883-xcg3-v3hh) through the locked development graph. The
`frontend-modern/src/security/__tests__/dependencySecurity.test.ts` proof must
check manifest floors and all installed lockfile copies, including nested
copies; a successful production build alone does not establish this property.
Vitest migration must preserve test assertions while using constructable
constructor mocks and explicit standalone mock-history cleanup. Dependency
updates must retain the required full-graph audit rather than suppressing it.
That same dev-runtime dependency-manifest boundary now also owns the maintained
Docker engine module floor. `go.mod`, `go.sum`, and
`internal/cloudcp/docker/manager.go` must route hosted runtime orchestration
+11 -9
View File
@@ -1,16 +1,16 @@
{
"version": 1,
"base_sha": "550b2d80a175cc66ab4e7ac269b7ec350e101611",
"verified_at": "2026-09-08T11:54:21Z",
"base_sha": "6f3547bc5fbb10ddf313a8cd2f184b6359173a59",
"verified_at": "2026-09-08T22:29:31.643744Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/utils/alertDestinationsPresentation.ts"
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts"
],
"content_sha256": {
"frontend-modern/src/utils/alertDestinationsPresentation.ts": "67f6f9a1c98345949e162bf06a38760e61457aa1e8a2130913646647a4f29e88"
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "14f0dceb069fe52b9a47100c50998ae53a2c5c8a29a524e342f7458546cb4a19"
},
"routes": [
"/qualification (real health and delivery-log cards, scripted props)"
"/qualification (real WebhookConfigForm and hook; synthetic callbacks)"
],
"viewports": [
{
@@ -27,11 +27,13 @@
}
],
"states": [
"Retained terminal server_error health diagnosis and failed delivery log entry"
"Unsaved manually entered Pushover aliases",
"Test callback payload",
"Save callback payload"
],
"interactions": [
"Load both cards at desktop, tablet and phone widths; assert label, guidance, no page errors or horizontal overflow."
"Click Test then Add Webhook at each width; assert both canonical token/user payloads, no page errors or horizontal overflow."
],
"notes": "Phone screenshot visually inspected; screenshots retained at /tmp/pulse-server-error-copy. Initial fixture omitted alertIds and timed out; corrected fixture passed all three widths. No installed delivery or receipt claim.",
"command": "pulse-heavy-run -- node scripts/check-delivery-server-error-copy.mjs"
"notes": "Real component with Vite dev transform in Chromium, not production build or installed/provider delivery. Phone screenshot visually inspected; synthetic credentials only. Screenshots /tmp/pulse-webhook-parity.",
"command": "pulse-heavy-run -- node scripts/check-webhook-test-save-parity.mjs"
}
+155 -649
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -66,7 +66,7 @@
"seroval": "^1.4.1",
"seroval-plugins": "^1.4.1",
"@babel/core": "^7.29.6",
"js-yaml": "^4.2.0"
"js-yaml": "^4.3.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
@@ -77,7 +77,7 @@
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"@vitest/coverage-v8": "^3.2.6",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.0",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.0.0",
@@ -93,6 +93,6 @@
"vite": "^6.4.3",
"vite-plugin-solid": "^2.8.0",
"vite-plugin-sri-gen": "^1.3.2",
"vitest": "^3.2.6"
"vitest": "^4.1.11"
}
}
@@ -79,8 +79,8 @@ const mockProviders = [gmailProvider, sendgridProvider, outlookProvider];
// --- Tests ---
describe('EmailProviderSelect', () => {
let onChangeMock: ReturnType<typeof vi.fn>;
let onTestMock: ReturnType<typeof vi.fn>;
let onChangeMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let onTestMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
beforeEach(() => {
onChangeMock = vi.fn();
@@ -168,17 +168,17 @@ interface DefaultProps {
title: string;
resources: Resource[];
columns: string[];
onEdit: ReturnType<typeof vi.fn>;
onSaveEdit: ReturnType<typeof vi.fn>;
onCancelEdit: ReturnType<typeof vi.fn>;
onRemoveOverride: ReturnType<typeof vi.fn>;
onEdit: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
onSaveEdit: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
onCancelEdit: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
onRemoveOverride: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
editingId: () => string | null;
editingThresholds: () => Record<string, number | undefined>;
setEditingThresholds: ReturnType<typeof vi.fn>;
formatMetricValue: ReturnType<typeof vi.fn>;
hasActiveAlert: ReturnType<typeof vi.fn>;
setEditingThresholds: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
formatMetricValue: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
hasActiveAlert: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
editingNote: () => string;
setEditingNote: ReturnType<typeof vi.fn>;
setEditingNote: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
}
function makeProps(
@@ -1,3 +1,5 @@
import { createRoot } from 'solid-js';
import { useWebhookConfigState } from './useWebhookConfigState';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import webhookConfigSource from '@/components/Alerts/WebhookConfig.tsx?raw';
@@ -102,10 +104,10 @@ const mockTemplates: WebhookTemplate[] = [
// --- Tests ---
describe('WebhookConfig', () => {
let onAddMock: ReturnType<typeof vi.fn>;
let onUpdateMock: ReturnType<typeof vi.fn>;
let onDeleteMock: ReturnType<typeof vi.fn>;
let onTestMock: ReturnType<typeof vi.fn>;
let onAddMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let onUpdateMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let onDeleteMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let onTestMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
beforeEach(() => {
onAddMock = vi.fn();
@@ -1580,3 +1582,41 @@ describe('WebhookConfig', () => {
expect(payloadTextareaAfter.value).toBe('');
});
});
describe('webhook test/save parity', () => {
it('normalises manually entered Pushover aliases identically for test and save', () => {
createRoot((dispose) => {
try {
const onTest = vi.fn();
const onAdd = vi.fn();
const state = useWebhookConfigState({
webhooks: [],
onTest,
onAdd,
onUpdate: vi.fn(),
onDelete: vi.fn(),
});
state.openAddForm();
state.setFormData((data) => ({
...data,
name: 'Example',
url: 'https://example.invalid',
service: 'pushover',
}));
for (const [index, key] of ['app_token', 'user_token'].entries()) {
state.addCustomFieldInput();
state.updateCustomFieldInput(index, { key, value: `synthetic-${index}` });
}
state.testWebhookForm();
state.saveWebhook();
expect(onAdd.mock.calls[0][0].customFields).toEqual({
token: 'synthetic-0',
user: 'synthetic-1',
});
expect(onTest.mock.calls[0][1].customFields).toEqual(onAdd.mock.calls[0][0].customFields);
} finally {
dispose();
}
});
});
});
@@ -284,7 +284,7 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
const testPayload = {
...restFormData,
headers,
customFields,
customFields: normalizeAlertWebhookCustomFields(data.service, customFields),
template: payloadTemplate ?? restFormData.template ?? '',
};
const tempId = editingId() || 'temp-new-webhook';
@@ -65,12 +65,12 @@ const currentExportDate = (): string => new Date().toISOString().split('T')[0];
describe('useDiagnosticsPanelState', () => {
let useDiagnosticsPanelState: UseDiagnosticsPanelStateModule['useDiagnosticsPanelState'];
let apiFetchJSONMock: ReturnType<typeof vi.fn>;
let showErrorMock: ReturnType<typeof vi.fn>;
let showSuccessMock: ReturnType<typeof vi.fn>;
let createObjectURLMock: ReturnType<typeof vi.fn>;
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
let anchorClickMock: ReturnType<typeof vi.fn>;
let apiFetchJSONMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let showErrorMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let showSuccessMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let createObjectURLMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let revokeObjectURLMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let anchorClickMock: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
let createdAnchor: HTMLAnchorElement | null;
let originalCreateObjectURL: typeof URL.createObjectURL | undefined;
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined;
@@ -52,6 +52,6 @@ describe('NodeDrawerOverview update evidence', () => {
/>
));
expect(screen.getByText('Unavailable · Sys.Audit permission required')).toBeInTheDocument();
expect(screen.getByText('Unavailable · Update check access denied')).toBeInTheDocument();
});
});
@@ -84,6 +84,8 @@ function getTagDotRing(dot: Element) {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
// Vitest 4 restores spies without clearing standalone vi.fn call history.
vi.clearAllMocks();
// Reset dark mode to default (false) for isolation
darkModeMock.mockReturnValue(false);
});
@@ -1,6 +1,7 @@
import { batch, createRoot, createSignal } from 'solid-js';
import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildProxmoxPageModel } from '@/features/proxmox/proxmoxPageModel';
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
import useUnifiedResourcesSource from '../useUnifiedResources.ts?raw';
@@ -1339,6 +1340,54 @@ describe('useUnifiedResources', () => {
dispose();
});
it('keeps an agent-plus-docker NAS out of the Proxmox table after API projection', async () => {
// #1930's supplied field shape, with entirely synthetic identities and no
// addresses or token metadata. This does not reproduce the earlier screenshot.
apiFetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{
...v2Resource,
id: 'synthetic-nas',
name: 'synthetic-nas',
technology: 'linux',
sources: ['agent', 'docker'],
platformScopes: ['agent', 'docker'],
canonicalIdentity: { displayName: 'synthetic-nas', platformId: 'synthetic-nas' },
identity: { hostnames: ['synthetic-nas'] },
agent: { platform: 'linux', osName: 'Synology DSM' },
},
{
...v2Resource,
id: 'synthetic-pve',
sources: ['proxmox'],
proxmox: { nodeName: 'synthetic-pve', clusterName: 'synthetic-cluster' },
},
],
}),
});
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({ query: 'type=agent', cacheKey: 'nas-boundary' });
});
try {
await result!.refetch();
const resources = result!.resources();
const nas = resources.find((resource) => resource.id === 'synthetic-nas');
expect(nas).toBeDefined();
expect(nas?.clusterId).toBeUndefined();
expect(nas?.proxmox).toBeUndefined();
expect(buildProxmoxPageModel(resources).pveNodes.map((node) => node.id)).toEqual([
'synthetic-pve',
]);
} finally {
dispose();
}
});
it('projects proxmox clusterId from the shared cluster helper', async () => {
apiFetchMock.mockResolvedValueOnce({
ok: true,
@@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest';
interface PackageManifest {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
overrides: Record<string, string>;
}
interface PackageLock {
@@ -58,6 +60,34 @@ const nanoidIsPatched = (version: string): boolean => {
};
describe('frontend dependency security floors', () => {
it('keeps Vitest and its mocker above the redirect-mock file-read floor', () => {
// GHSA-82fw-gwwq-j7x9: the maintained 4.x fix starts at 4.1.11.
expect(manifest.devDependencies.vitest).toBe('^4.1.11');
expect(manifest.devDependencies['@vitest/coverage-v8']).toBe('^4.1.11');
const runner = lockedVersions('vitest');
expect(runner).toHaveLength(1);
for (const name of ['vitest', '@vitest/mocker', '@vitest/coverage-v8']) {
const versions = lockedVersions(name);
expect(versions).not.toHaveLength(0);
for (const version of versions) {
expect(version).not.toContain('-');
expect(atLeast(version, [4, 1, 11]), `${name} ${version} is vulnerable`).toBe(true);
expect(version, `${name} must match the runner`).toBe(runner[0]);
}
}
});
it('keeps every js-yaml copy above the empty-merge CPU exhaustion floor', () => {
// GHSA-2883-xcg3-v3hh: our override keeps every transitive copy on patched 4.x.
expect(manifest.overrides['js-yaml']).toBe('^4.3.2');
const versions = lockedVersions('js-yaml');
expect(versions).not.toHaveLength(0);
for (const version of versions) {
expect(version).not.toContain('-');
expect(atLeast(version, [4, 3, 2]), `js-yaml ${version} is vulnerable`).toBe(true);
}
});
it('keeps DOMPurify above the hook-detachment XSS floor', () => {
expect(manifest.dependencies.dompurify).toBe('^3.4.13');
const versions = lockedVersions('dompurify');
@@ -26,7 +26,7 @@ interface MockWebSocketInstance {
let mockWsInstance: MockWebSocketInstance | null = null;
const MockWebSocket = vi.fn().mockImplementation((url: string): MockWebSocketInstance => {
const MockWebSocket = vi.fn().mockImplementation(function (url: string): MockWebSocketInstance {
const instance: MockWebSocketInstance = {
url,
readyState: 1,
@@ -51,7 +51,7 @@ describe('Proxmox update evidence presentation', () => {
pendingUpdatesReason: 'permission_denied',
}),
).toMatchObject({
value: 'Unavailable · Sys.Audit permission required',
value: 'Unavailable · Update check access denied',
current: false,
});
});
@@ -25,7 +25,7 @@ import type { ResourceMetadataChangedDetail } from '@/utils/resourceMetadataEven
const win: Window & typeof globalThis = window;
describe('dispatchResourceMetadataChanged', () => {
let listener: ReturnType<typeof vi.fn>;
let listener: ReturnType<typeof vi.fn<(...args: any[]) => any>>;
beforeEach(() => {
listener = vi.fn();
@@ -16,7 +16,7 @@ type UpdateEvidenceNode = Pick<
const reasonLabel = (reason: Node['pendingUpdatesReason']): string => {
switch (reason) {
case 'permission_denied':
return 'Sys.Audit permission required';
return 'Update check access denied';
case 'source_unavailable':
return 'Proxmox source unavailable';
case 'check_failed':
@@ -23,7 +23,9 @@ func newChangePasswordRouter(t *testing.T) (*Router, *config.Config) {
t.Fatalf("bcrypt: %v", err)
}
cfg := &config.Config{DataPath: dir, ConfigPath: dir, AuthUser: "admin", AuthPass: string(hashed)}
return NewRouter(cfg, nil, nil, nil, nil, "1.0.0"), cfg
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
cleanupTestRouter(t, router)
return router, cfg
}
func changePasswordRequest(t *testing.T, current string) *http.Request {
@@ -87,3 +89,27 @@ func TestChangePasswordBasicAuthPathUnaffected(t *testing.T) {
t.Fatalf("Basic Auth change-password must not be refused by the session gate, got 403 (body %s)", rec.Body.String())
}
}
func TestChangePasswordFixtureOwnsRouterLifecycle(t *testing.T) {
var router *Router
workerDone := make(chan struct{})
t.Run("fixture", func(t *testing.T) {
router, _ = newChangePasswordRouter(t)
router.startLifecycleWorker(func() {
<-router.lifecycleCtx.Done()
close(workerDone)
})
})
// Ensure a failing regression does not itself leave workers running.
cleanupTestRouter(t, router)
select {
case <-router.lifecycleCtx.Done():
default:
t.Fatal("password-change fixture left router lifecycle active")
}
select {
case <-workerDone:
default:
t.Fatal("password-change fixture did not join its router worker")
}
}
@@ -43,7 +43,9 @@ func newConfigTransferTestRouter(t *testing.T, hosted bool, sso *config.SSOConfi
t.Fatalf("save synthetic SSO config: %v", err)
}
}
return NewRouter(cfg, nil, nil, nil, nil, "test")
router := NewRouter(cfg, nil, nil, nil, nil, "test")
cleanupTestRouter(t, router)
return router
}
func enabledConfigTransferSSO(providerType config.SSOProviderType) *config.SSOConfig {
@@ -129,6 +131,7 @@ func TestConfigTransferEnvironmentOIDCAndSSOLoadFailureFailClosed(t *testing.T)
}
cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir}
router := NewRouter(cfg, nil, nil, nil, nil, "test")
cleanupTestRouter(t, router)
if !router.ssoAuthenticationLoadFailed() {
t.Fatal("precondition: corrupt persisted SSO was not recorded as a load failure")
}
@@ -220,6 +223,7 @@ func TestConfigTransferAuthorizedInstanceModesReachHandler(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", dataDir)
cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir, AuthUser: "admin", AuthPass: hash}
router := NewRouter(cfg, nil, nil, nil, nil, "test")
cleanupTestRouter(t, router)
for _, path := range []string{"/api/config/export", "/api/config/import"} {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, path, body)
@@ -266,6 +270,7 @@ func TestConfigTransferAuthorizedInstanceModesReachHandler(t *testing.T) {
ProxyAuthRoleHeader: "X-Proxy-Roles", ProxyAuthAdminRole: "admin",
}
router := NewRouter(cfg, nil, nil, nil, nil, "test")
cleanupTestRouter(t, router)
for _, path := range []string{"/api/config/export", "/api/config/import"} {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, path, body)
@@ -289,6 +294,7 @@ func TestConfigTransferTokenScopesAndOrganizationBinding(t *testing.T) {
cfg := newTestConfigWithTokens(t, readRecord, writeRecord)
t.Setenv("PULSE_DATA_DIR", cfg.DataPath)
router := NewRouter(cfg, nil, nil, nil, nil, "test")
cleanupTestRouter(t, router)
tests := []struct {
name string
@@ -331,6 +337,7 @@ func TestConfigTransferTokenScopesAndOrganizationBinding(t *testing.T) {
boundCfg := newTestConfigWithTokens(t, boundRecord)
t.Setenv("PULSE_DATA_DIR", boundCfg.DataPath)
boundRouter := NewRouter(boundCfg, nil, nil, nil, nil, "test")
cleanupTestRouter(t, boundRouter)
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, "/api/config/export", body)
req.Header.Set("X-API-Token", boundRaw)
@@ -404,6 +411,7 @@ func TestConfigTransferTenantSessionsRequireManagement(t *testing.T) {
mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil)
t.Cleanup(mtm.Stop)
router := NewRouter(cfg, nil, mtm, nil, nil, "test")
cleanupTestRouter(t, router)
tests := []struct {
user string
@@ -517,6 +525,7 @@ func TestDeniedConfigTransferDoesNotMutateOrReload(t *testing.T) {
reloadCalls++
return nil
}, "test")
cleanupTestRouter(t, router)
requests := []struct {
path string
@@ -547,3 +556,29 @@ func TestDeniedConfigTransferDoesNotMutateOrReload(t *testing.T) {
t.Fatalf("denied import mutated live config: %+v", cfg)
}
}
func TestConfigTransferFixtureOwnsRouterLifecycle(t *testing.T) {
var router *Router
workerDone := make(chan struct{})
t.Run("fixture", func(t *testing.T) {
router = newConfigTransferTestRouter(t, false, nil)
router.startLifecycleWorker(func() {
<-router.lifecycleCtx.Done()
close(workerDone)
})
})
// Retain cleanup even if the helper regresses.
defer router.shutdownBackgroundWorkers()
defer router.ShutdownResourceStores()
defer router.ShutdownRBAC()
select {
case <-router.lifecycleCtx.Done():
default:
t.Fatal("config-transfer helper left router lifecycle active")
}
select {
case <-workerDone:
default:
t.Fatal("config-transfer helper did not join its router worker")
}
}
@@ -378,7 +378,7 @@ func (m *Monitor) applyNodePendingUpdates(ctx context.Context, instanceName stri
Err(err).
Str("node", node.Node).
Str("instance", instanceName).
Msg("Could not check pending apt updates (may require Sys.Audit permission)")
Msg("Could not check pending apt updates with configured Proxmox credentials")
modelNode.PendingUpdatesReason = pendingUpdatesFailureReason(err)
if hasCached {
modelNode.PendingUpdates = cached.count
@@ -0,0 +1,64 @@
package unifiedresources
import (
"encoding/json"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
// A cluster label is a projection of provider association, not subnet membership.
// Keep the public discriminator covered: link hints themselves are not JSON fields.
func TestNASClusterProjectionPublicEvidence(t *testing.T) {
for _, linked := range []bool{false, true} {
name := "unlinked-shared-bridge"
if linked {
name = "retained-reciprocal-link"
}
t.Run(name, func(t *testing.T) {
node := models.Node{ID: "pve-node", Name: "pve", Instance: "cluster", ClusterName: "cluster", IsClusterMember: true, Host: "https://192.0.2.10:8006", NetworkInterfaces: []models.HostNetworkInterface{{Name: "docker0", Addresses: []string{"172.17.0.1/16"}}}}
host := models.Host{ID: "nas-agent", MachineID: "nas-machine", Hostname: "nas", NetworkInterfaces: []models.HostNetworkInterface{{Name: "eth0", Addresses: []string{"198.51.100.20/24"}}, {Name: "docker0", Addresses: []string{"172.17.0.1/16"}}}}
if linked {
host.LinkedNodeID = node.ID
host.NodeLinkSource = "manual"
node.LinkedAgentID = host.ID
}
registry := NewRegistry(NewMemoryStore())
registry.IngestSnapshot(models.StateSnapshot{Nodes: []models.Node{node}, Hosts: []models.Host{host}})
found := false
for _, resource := range registry.ListForPresentation() {
if resource.Agent == nil || resource.Agent.AgentID != host.ID {
continue
}
found = true
if (resource.Proxmox != nil) != linked {
t.Fatalf("provider projection linked=%v: %+v", linked, resource.Proxmox)
}
if !linked && resource.Identity.ClusterName != "" {
t.Fatalf("unlinked NAS inherited cluster %q", resource.Identity.ClusterName)
}
raw, err := json.Marshal(resource)
if err != nil {
t.Fatal(err)
}
var public map[string]any
if err := json.Unmarshal(raw, &public); err != nil {
t.Fatal(err)
}
agent := public["agent"].(map[string]any)
if _, exposed := agent["linkedNodeId"]; exposed {
t.Fatal("internal link hint unexpectedly exposed")
}
if linked {
provider := public["proxmox"].(map[string]any)
if provider["nodeName"] != node.Name || provider["clusterName"] != node.ClusterName {
t.Fatalf("missing public association discriminator: %v", provider)
}
}
}
if !found {
t.Fatal("NAS agent missing from presentation")
}
})
}
}
+3 -1
View File
@@ -2944,7 +2944,9 @@ type AptPackage struct {
}
// GetNodePendingUpdates returns the list of pending apt updates for a node
// Requires Sys.Audit permission on /nodes/{node}
// Access is evaluated by Proxmox for the configured credential on /nodes/{node}.
// Do not infer access from general node-audit permission; endpoint requirements
// can differ from other monitoring reads.
func (c *Client) GetNodePendingUpdates(ctx context.Context, node string) ([]AptPackage, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/apt/update", node))
if err != nil {
@@ -0,0 +1,64 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// A cluster entry endpoint may proxy a different target node. Neither the
// selected endpoint nor its successful inventory read grants apt access.
func TestClusterClient_PendingUpdatesConfiguredCredentialAndTarget(t *testing.T) {
for _, status := range []int{http.StatusOK, http.StatusForbidden} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
var reads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("unexpected method %s", r.Method)
}
if r.Header.Get("Authorization") != "PVEAPIToken=monitor@pve!readonly=synthetic" {
t.Error("configured token was not preserved")
}
if r.Header.Get("Cookie") != "" {
t.Error("unexpected browser/session credential")
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api2/json/nodes":
fmt.Fprint(w, `{"data":[{"node":"entry","status":"online"},{"node":"target","status":"online"}]}`)
case "/api2/json/nodes/target/apt/update":
reads.Add(1)
w.WriteHeader(status)
if status == http.StatusOK {
fmt.Fprint(w, `{"data":[{"Package":"example","Version":"2"}]}`)
} else {
fmt.Fprint(w, `{"message":"Permission check failed"}`)
}
default:
t.Errorf("unexpected request path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "monitor@pve!readonly", TokenValue: "synthetic"}
cc := NewClusterClient("routing-test", cfg, []string{server.URL}, nil)
updates, err := cc.GetNodePendingUpdates(context.Background(), "target")
if status == http.StatusOK {
if err != nil || len(updates) != 1 || updates[0].Package != "example" {
t.Fatalf("unexpected successful result: %v, %v", updates, err)
}
} else if err == nil || extractStatusCode(err.Error()) != http.StatusForbidden || len(updates) != 0 {
t.Fatalf("denial not preserved: %v, %v", updates, err)
}
if reads.Load() != 1 {
t.Fatalf("apt reads = %d; expected one without auth retry", reads.Load())
}
if !cc.GetHealthStatus()[server.URL] {
t.Error("apt permission failure must not poison cluster endpoint health")
}
})
}
}
+81
View File
@@ -0,0 +1,81 @@
// Isolated real-browser component qualification; no installed backend or delivery claim.
import { build, preview } from "../frontend-modern/node_modules/vite/dist/node/index.js";
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { chromium } from "@playwright/test";
import { resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const root = resolve("frontend-modern");
process.chdir(root);
const fixture = `
import { render } from 'solid-js/web';
import { createSignal } from 'solid-js';
import { NodeDrawerOverview } from '/src/components/Workloads/NodeDrawerOverview';
import '/src/index.css';
const base = {id:'lab-pve1',name:'pve1',instance:'lab',status:'online',type:'node',cpu:0,memory:{total:1024,used:256,free:768,usage:25},disk:{total:1024,used:256,free:768,usage:25},uptime:3600,loadAverage:[],kernelVersion:'6.8.12',pveVersion:'9.0.1',cpuInfo:{model:'CPU',cores:4,sockets:1,mhz:'2400'},lastSeen:new Date().toISOString(),connectionHealth:'healthy'};
const states = {
not_checked:{pendingUpdatesStatus:'not_checked',pendingUpdatesReason:'permission_denied'},
unavailable:{pendingUpdatesStatus:'unavailable',pendingUpdatesReason:'permission_denied'},
stale:{pendingUpdates:1,pendingUpdatesStatus:'stale',pendingUpdatesReason:'permission_denied',pendingUpdatesCheckedAt:new Date().toISOString()},
zero:{pendingUpdates:0,pendingUpdatesStatus:'checked',pendingUpdatesCheckedAt:new Date().toISOString()},
positive:{pendingUpdates:1,pendingUpdatesStatus:'checked',pendingUpdatesCheckedAt:new Date().toISOString()}
};
const [state,setState] = createSignal('unavailable');
render(() => <main class="p-4"><nav>{Object.keys(states).map(key => <button class="p-2" onClick={() => setState(key)}>{key}</button>)}</nav><NodeDrawerOverview node={{...base,...states[state()]}} /></main>, document.getElementById('root'));
`;
const config = {
root,
configFile: false,
optimizeDeps: {
noDiscovery: true,
entries: [],
esbuildOptions: { target: "esnext" },
},
esbuild: { target: "esnext" },
plugins: [
solid(),
{
name: "update-access-fixture",
resolveId(id) {
if (id === "/update-access-fixture.tsx" || id === resolve(root, "qualification.html")) return id;
},
load(id) {
if (id === "/update-access-fixture.tsx") return fixture;
if (id === resolve(root, "qualification.html")) return '<div id="root"></div><script type="module" src="/update-access-fixture.tsx"></script>';
},
},
],
resolve: { alias: { "@": resolve(root, "src") } },
build: { target: "esnext", outDir: "/tmp/pulse-update-access-build", emptyOutDir: true, rollupOptions: { input: resolve(root, "qualification.html") } },
};
await build(config);
const server = await preview({root, configFile:false, build:{outDir:"/tmp/pulse-update-access-build"}, preview:{host:"127.0.0.1",port:5198,strictPort:true}});
let browser;
try {
browser = await chromium.launch({ headless: true });
mkdirSync("/tmp/pulse-update-access-copy", { recursive: true });
for (const width of [1440, 900, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto("http://127.0.0.1:5198/qualification.html");
for (const state of ['unavailable', 'stale', 'zero', 'positive', 'not_checked', 'unavailable']) {
await page.getByRole('button', {name:state,exact:true}).click();
const expected = state === 'not_checked' ? 'Not checked · Update check access denied' : state === 'unavailable' ? 'Unavailable · Update check access denied' : state === 'stale' ? /1 pending · stale/ : state === 'zero' ? /No pending updates · checked/ : /1 pending · checked/;
await page.getByText(expected,{exact:typeof expected === 'string'}).waitFor();
if (state === 'stale') assert.match(await page.getByText(/1 pending · stale/).getAttribute('title'), /Update check access denied/);
assert.equal(await page.getByText('Sys.Audit permission required',{exact:false}).count(),0);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
assert.deepEqual(errors, []);
await page.screenshot({path: `/tmp/pulse-update-access-copy/${width}-${state}.png`, fullPage:true});
}
await page.close();
}
console.log(JSON.stringify({result:'passed',viewports:[1440,900,390],scope:'Real NodeDrawerOverview, Chromium, scripted props; no installed delivery claim'}));
} finally {
await browser?.close();
await new Promise(resolve => server.httpServer.close(resolve));
}
@@ -0,0 +1,85 @@
// Isolated real-browser component qualification; no installed backend or delivery claim.
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { chromium } from "@playwright/test";
import { resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const root = resolve("frontend-modern");
process.chdir(root);
const fixture = `
import { render } from 'solid-js/web';
import { useWebhookConfigState } from '/src/components/Alerts/useWebhookConfigState';
import { WebhookConfigForm } from '/src/components/Alerts/WebhookConfigForm';
import '/src/index.css';
render(() => {
const state = useWebhookConfigState({webhooks:[], onAdd: data => window.saved = data, onUpdate:()=>{}, onDelete:()=>{}, onTest: (_id,data) => window.tested = data});
state.openAddForm();
state.setFormData(data => ({...data,name:'Synthetic destination',url:'https://example.invalid',service:'pushover'}));
for (const [index,key] of ['app_token','user_token'].entries()) {
state.addCustomFieldInput();
state.updateCustomFieldInput(index,{key,value:'synthetic-'+index});
}
return <main class="p-4"><WebhookConfigForm {...state}/></main>;
}, document.getElementById('root'));
`;
const server = await createServer({
root,
configFile: false,
optimizeDeps: {
noDiscovery: true,
entries: [],
esbuildOptions: { target: "esnext" },
},
esbuild: { target: "esnext" },
plugins: [
solid(),
{
name: "ordering-fixture",
configureServer(s) {
s.middlewares.use((req, res, next) => {
if (req.url === "/qualification") {
res.setHeader("Content-Type", "text/html");
res.end(
'<div id="root"></div><script type="module" src="/ordering-fixture.tsx"></script>',
);
} else next();
});
},
resolveId(id) {
if (id === "/ordering-fixture.tsx") return id;
},
load(id) {
if (id === "/ordering-fixture.tsx") return fixture;
},
},
],
resolve: { alias: { "@": resolve(root, "src") } },
server: { host: "127.0.0.1", port: 5197, strictPort: true },
});
let browser;
try {
await server.listen();
browser = await chromium.launch({ headless: true });
mkdirSync("/tmp/pulse-webhook-parity", { recursive: true });
for (const width of [1440, 900, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto("http://127.0.0.1:5197/qualification");
await page.getByRole('button', {name:'Test', exact:true}).click();
assert.deepEqual(await page.evaluate(() => window.tested.customFields), {token:'synthetic-0',user:'synthetic-1'});
await page.screenshot({path: `/tmp/pulse-webhook-parity/${width}.png`, fullPage:true});
await page.getByRole('button', {name:'Add Webhook', exact:true}).click();
assert.deepEqual(await page.evaluate(() => window.saved.customFields), {token:'synthetic-0',user:'synthetic-1'});
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
assert.deepEqual(errors, []);
await page.close();
}
console.log(JSON.stringify({result:'passed',viewports:[1440,900,390],scope:'Real webhook form, Chromium, scripted props; no installed delivery claim'}));
} finally {
await browser?.close();
await server.close();
}