mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #1995 from rcourtman/maintainer/20260908T214026Z
Improve update guidance, webhook parity, and frontend security
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+155
-649
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+1
-1
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user