From 1bb23545b60d3d36633f91199fb4e550e433cf6e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:57:43 +0100 Subject: [PATCH] Require TrueNAS API key owner identity Change-source: pulse-maintainer --- ...ettingsPanelState.branchcov0722am.test.tsx | 14 +- .../useTrueNASSettingsPanelState.test.tsx | 24 +++ .../Settings/useTrueNASSettingsPanelState.ts | 9 +- internal/api/contract_test.go | 25 +-- internal/api/truenas_handlers_test.go | 158 ++++++++++++------ internal/config/truenas.go | 3 + internal/config/truenas_test.go | 11 +- 7 files changed, 173 insertions(+), 71 deletions(-) diff --git a/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.branchcov0722am.test.tsx b/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.branchcov0722am.test.tsx index 53ab40d6a..c2a08448c 100644 --- a/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.branchcov0722am.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.branchcov0722am.test.tsx @@ -75,7 +75,7 @@ describe('useTrueNASSettingsPanelState — error-message + dialog branch coverag await waitFor(() => expect(result.loading()).toBe(false)); result.openCreateDialog(); - result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + result.updateForm({ host: 'tower.local', apiKey: 'secret', username: 'pulse-monitor' }); const ok = await result.testCurrentForm(); expect(ok).toBe(false); @@ -102,7 +102,7 @@ describe('useTrueNASSettingsPanelState — error-message + dialog branch coverag await waitFor(() => expect(result.loading()).toBe(false)); result.openCreateDialog(); - result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + result.updateForm({ host: 'tower.local', apiKey: 'secret', username: 'pulse-monitor' }); await result.testCurrentForm(); expect(notificationStore.error).toHaveBeenCalledWith('actionable cause from details'); @@ -116,7 +116,7 @@ describe('useTrueNASSettingsPanelState — error-message + dialog branch coverag await waitFor(() => expect(result.loading()).toBe(false)); result.openCreateDialog(); - result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + result.updateForm({ host: 'tower.local', apiKey: 'secret', username: 'pulse-monitor' }); await result.testCurrentForm(); expect(notificationStore.error).toHaveBeenCalledWith('connection refused'); @@ -164,7 +164,7 @@ describe('useTrueNASSettingsPanelState — error-message + dialog branch coverag await waitFor(() => expect(result.loading()).toBe(false)); result.openCreateDialog(); - result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + result.updateForm({ host: 'tower.local', apiKey: 'secret', username: 'pulse-monitor' }); const ok = await result.testCurrentForm(); expect(ok).toBe(false); @@ -184,7 +184,7 @@ describe('useTrueNASSettingsPanelState — error-message + dialog branch coverag await waitFor(() => expect(result.loading()).toBe(false)); result.openCreateDialog(); - result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + result.updateForm({ host: 'tower.local', apiKey: 'secret', username: 'pulse-monitor' }); const preview = await result.previewCurrentForm(); expect(preview).toBeNull(); @@ -234,7 +234,7 @@ describe('useTrueNASSettingsPanelState — error-message + dialog branch coverag await waitFor(() => expect(result.loading()).toBe(false)); result.openCreateDialog(); - result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + result.updateForm({ host: 'tower.local', apiKey: 'secret', username: 'pulse-monitor' }); const testPromise = result.testCurrentForm(); await waitFor(() => expect(result.testing()).toBe(true)); @@ -260,7 +260,7 @@ describe('useTrueNASSettingsPanelState — error-message + dialog branch coverag await waitFor(() => expect(result.loading()).toBe(false)); result.openCreateDialog(); - result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + result.updateForm({ host: 'tower.local', apiKey: 'secret', username: 'pulse-monitor' }); const savePromise = result.saveCurrentForm(); await waitFor(() => expect(result.saving()).toBe(true)); diff --git a/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx b/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx index 00c55247b..c79119377 100644 --- a/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx @@ -183,6 +183,7 @@ describe('useTrueNASSettingsPanelState', () => { name: 'tower', host: 'truenas.local', apiKey: '********', + username: 'pulse-readonly', pollIntervalSeconds: 60, useHttps: true, insecureSkipVerify: false, @@ -195,6 +196,7 @@ describe('useTrueNASSettingsPanelState', () => { name: 'tower', host: 'truenas.local', apiKey: '********', + username: 'pulse-readonly', pollIntervalSeconds: 60, useHttps: true, insecureSkipVerify: false, @@ -229,6 +231,7 @@ describe('useTrueNASSettingsPanelState', () => { name: 'tower', host: 'truenas.local', apiKey: '********', + username: 'pulse-readonly', pollIntervalSeconds: 60, useHttps: true, insecureSkipVerify: false, @@ -270,6 +273,7 @@ describe('useTrueNASSettingsPanelState', () => { result.updateForm({ host: 'tower.local', apiKey: 'secret', + username: 'pulse-readonly', }); await result.saveCurrentForm(); @@ -283,6 +287,22 @@ describe('useTrueNASSettingsPanelState', () => { expect(notificationStore.success).toHaveBeenCalledWith('TrueNAS connection added'); }); + it('rejects API-key configuration without its owner username before an API call', async () => { + vi.mocked(TrueNASAPI.listConnections).mockResolvedValueOnce([] as never); + + const { result } = renderHook(() => useTrueNASSettingsPanelState()); + await waitFor(() => expect(result.loading()).toBe(false)); + + result.openCreateDialog(); + result.updateForm({ host: 'tower.local', apiKey: 'secret' }); + + expect(await result.testCurrentForm()).toBe(false); + expect(TrueNASAPI.testConnection).not.toHaveBeenCalled(); + expect(notificationStore.error).toHaveBeenCalledWith( + 'TrueNAS API key owner username is required for supported authentication', + ); + }); + it('previews monitored-system impact through the canonical TrueNAS preview path', async () => { vi.mocked(TrueNASAPI.listConnections).mockResolvedValueOnce([] as never); vi.mocked(TrueNASAPI.previewConnection).mockResolvedValueOnce({ @@ -323,6 +343,7 @@ describe('useTrueNASSettingsPanelState', () => { result.updateForm({ host: 'tower.local', apiKey: 'secret', + username: 'pulse-readonly', }); const preview = await result.previewCurrentForm(); @@ -358,6 +379,7 @@ describe('useTrueNASSettingsPanelState', () => { result.updateForm({ host: 'tower.local', apiKey: 'secret', + username: 'pulse-readonly', }); const preview = await result.previewCurrentForm(); @@ -393,6 +415,7 @@ describe('useTrueNASSettingsPanelState', () => { result.updateForm({ host: 'tower.local', apiKey: 'secret', + username: 'pulse-readonly', }); await result.previewCurrentForm(); await result.saveCurrentForm(); @@ -425,6 +448,7 @@ describe('useTrueNASSettingsPanelState', () => { result.updateForm({ host: 'tower.local', apiKey: 'secret', + username: 'pulse-readonly', }); await result.previewCurrentForm(); await result.saveCurrentForm(); diff --git a/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts b/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts index d1cde6694..2c3cdf843 100644 --- a/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts +++ b/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts @@ -146,6 +146,11 @@ const buildConnectionInput = (form: TrueNASConnectionFormState): TrueNASConnecti const name = form.name.trim(); const host = form.host.trim(); const fingerprint = form.fingerprint.trim(); + const username = form.username.trim(); + + if (form.authMode === 'apiKey' && !username) { + throw new Error('TrueNAS API key owner username is required for supported authentication'); + } const input: TrueNASConnectionInput = { ...(name ? { name } : {}), @@ -166,11 +171,11 @@ const buildConnectionInput = (form: TrueNASConnectionFormState): TrueNASConnecti if (form.authMode === 'apiKey') { input.apiKey = form.apiKey.trim() || (form.hasStoredApiKey ? REDACTED_SECRET : ''); - input.username = form.username.trim(); + input.username = username; input.password = ''; } else { input.apiKey = ''; - input.username = form.username.trim(); + input.username = username; input.password = form.password.trim() || (form.hasStoredPassword ? REDACTED_SECRET : ''); } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index fb6d8e181..132b7c12d 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -8502,9 +8502,10 @@ func TestContract_PlatformConnectionWritesIgnoreUsageUnavailableWithCapsRetired( ) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -8572,10 +8573,11 @@ func TestContract_DisabledPlatformConnectionWritesBypassUnavailableUsageGate(t * ) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", - "enabled": false, + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", + "enabled": false, }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -8603,6 +8605,7 @@ func TestContract_DisabledPlatformConnectionWritesBypassUnavailableUsageGate(t * Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", UseHTTPS: true, Enabled: true, }}); err != nil { @@ -8613,6 +8616,7 @@ func TestContract_DisabledPlatformConnectionWritesBypassUnavailableUsageGate(t * "name": "archive", "host": "archive.local", "apiKey": "********", + "username": "pulse-readonly", "useHttps": true, "enabled": false, }) @@ -8750,9 +8754,10 @@ func TestContract_PlatformConnectionPreviewPreservesCanonicalEnabledDefaults(t * setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(unifiedresources.NewMonitorAdapter(registry))) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/preview", bytes.NewReader(body)) rec := httptest.NewRecorder() diff --git a/internal/api/truenas_handlers_test.go b/internal/api/truenas_handlers_test.go index d94af1cc7..7f700ec04 100644 --- a/internal/api/truenas_handlers_test.go +++ b/internal/api/truenas_handlers_test.go @@ -39,9 +39,10 @@ func TestTrueNASHandlers_HandleAdd_Success(t *testing.T) { handler, persistence, _ := newTrueNASHandlersForTest(t, nil) body := marshalTrueNASRequest(t, map[string]any{ - "name": "nas", - "host": "nas.local", - "apiKey": "super-secret", + "name": "nas", + "host": "nas.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) @@ -82,7 +83,8 @@ func TestTrueNASHandlers_HandleAdd_ValidationAndFeatureGate(t *testing.T) { handler, _, _ := newTrueNASHandlersForTest(t, nil) body := marshalTrueNASRequest(t, map[string]any{ - "apiKey": "token", + "apiKey": "token", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -93,8 +95,8 @@ func TestTrueNASHandlers_HandleAdd_ValidationAndFeatureGate(t *testing.T) { } }) - t.Run("feature disabled", func(t *testing.T) { - setTrueNASFeatureForTest(t, false) + t.Run("API key owner username required", func(t *testing.T) { + setTrueNASFeatureForTest(t, true) setMockModeForTest(t, false) handler, _, _ := newTrueNASHandlersForTest(t, nil) @@ -106,6 +108,28 @@ func TestTrueNASHandlers_HandleAdd_ValidationAndFeatureGate(t *testing.T) { rec := httptest.NewRecorder() handler.HandleAdd(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "API key owner username is required") { + t.Fatalf("expected owner username guidance, got %s", rec.Body.String()) + } + }) + + t.Run("feature disabled", func(t *testing.T) { + setTrueNASFeatureForTest(t, false) + setMockModeForTest(t, false) + handler, _, _ := newTrueNASHandlersForTest(t, nil) + + body := marshalTrueNASRequest(t, map[string]any{ + "host": "nas.local", + "apiKey": "token", + "username": "pulse-readonly", + }) + req := httptest.NewRequest(http.MethodPost, "/api/truenas/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()) } @@ -150,8 +174,9 @@ func TestTrueNASHandlers_HandleAdd_BlocksNewCountedSystemAtLimit(t *testing.T) { } body := marshalTrueNASRequest(t, map[string]any{ - "host": "nas-b.local", - "apiKey": "b", + "host": "nas-b.local", + "apiKey": "b", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) @@ -194,9 +219,10 @@ func TestTrueNASHandlers_HandleAdd_ReturnsUnavailableWhenSupplementalInventoryNo bindUnavailableSupplementalUsageProviderForTest(t, monitor, unifiedresources.SourceTrueNAS, tc.reason) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -247,10 +273,11 @@ func TestTrueNASHandlers_HandleAdd_DoesNotCountDisabledConnectionAtLimit(t *test setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(unifiedresources.NewMonitorAdapter(registry))) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", - "enabled": false, + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", + "enabled": false, }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -286,10 +313,11 @@ func TestTrueNASHandlers_HandleAdd_AllowsDisabledConnectionWhenUsageUnavailable( ) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", - "enabled": false, + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", + "enabled": false, }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -338,9 +366,10 @@ func TestTrueNASHandlers_HandleAdd_AllowsCanonicalOverlapAtLimit(t *testing.T) { setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(unifiedresources.NewMonitorAdapter(registry))) body := marshalTrueNASRequest(t, map[string]any{ - "name": "archive", - "host": "archive.local", - "apiKey": "super-secret", + "name": "archive", + "host": "archive.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections", bytes.NewReader(body)) @@ -370,6 +399,7 @@ func TestTrueNASHandlers_HandleList_RedactsSensitiveFields(t *testing.T) { Name: "api-key-auth", Host: "nas-a.local", APIKey: "key-a", + Username: "pulse-readonly", UseHTTPS: true, Enabled: true, }, @@ -589,6 +619,7 @@ func TestTrueNASHandlers_HandleUpdate_PreservesMaskedSecretsAndReplacesFields(t Host: "old.local", Port: 443, APIKey: "super-secret", + Username: "pulse-readonly", UseHTTPS: true, InsecureSkipVerify: false, Enabled: true, @@ -603,6 +634,7 @@ func TestTrueNASHandlers_HandleUpdate_PreservesMaskedSecretsAndReplacesFields(t "host": "new.local", "port": 8443, "apiKey": "********", + "username": "pulse-readonly", "useHttps": true, "insecureSkipVerify": true, "enabled": true, @@ -657,6 +689,7 @@ func TestTrueNASHandlers_HandleUpdate_BlocksProjectedNetNewSystemAtLimit(t *test Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", UseHTTPS: true, Enabled: true, }, @@ -709,6 +742,7 @@ func TestTrueNASHandlers_HandleUpdate_BlocksProjectedNetNewSystemAtLimit(t *test "name": "archive", "host": "backup.local", "apiKey": "********", + "username": "pulse-readonly", "useHttps": true, "enabled": true, }) @@ -756,6 +790,7 @@ func TestTrueNASHandlers_HandleUpdate_ReturnsUnavailableWhenSupplementalInventor Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", UseHTTPS: true, Enabled: true, }, @@ -767,6 +802,7 @@ func TestTrueNASHandlers_HandleUpdate_ReturnsUnavailableWhenSupplementalInventor "name": "archive", "host": "backup.local", "apiKey": "********", + "username": "pulse-readonly", "useHttps": true, "enabled": true, }) @@ -807,6 +843,7 @@ func TestTrueNASHandlers_HandleUpdate_AllowsDisablingConnectionWhenUsageUnavaila Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", UseHTTPS: true, Enabled: true, }, @@ -818,6 +855,7 @@ func TestTrueNASHandlers_HandleUpdate_AllowsDisablingConnectionWhenUsageUnavaila "name": "archive", "host": "backup.local", "apiKey": "********", + "username": "pulse-readonly", "useHttps": true, "enabled": false, }) @@ -851,8 +889,9 @@ func TestTrueNASHandlers_HandleUpdate_UnknownID(t *testing.T) { handler, _, _ := newTrueNASHandlersForTest(t, nil) body := marshalTrueNASRequest(t, map[string]any{ - "host": "missing.local", - "apiKey": "secret", + "host": "missing.local", + "apiKey": "secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPut, "/api/truenas/connections/missing", bytes.NewReader(body)) @@ -879,6 +918,7 @@ func TestTrueNASHandlers_HandleTestConnection_SuccessAndFailure(t *testing.T) { "port": 80, "useHttps": false, "apiKey": "key", + "username": "pulse-readonly", }) successReq := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/test", bytes.NewReader(successBody)) successRec := httptest.NewRecorder() @@ -896,8 +936,9 @@ func TestTrueNASHandlers_HandleTestConnection_SuccessAndFailure(t *testing.T) { handler.newClient = nil failureBody := marshalTrueNASRequest(t, map[string]any{ - "host": "http://127.0.0.1:65536", - "apiKey": "key", + "host": "http://127.0.0.1:65536", + "apiKey": "key", + "username": "pulse-readonly", }) failureReq := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/test", bytes.NewReader(failureBody)) failureRec := httptest.NewRecorder() @@ -913,9 +954,10 @@ func TestTrueNASHandlers_HandleTestConnection_SuccessAndFailure(t *testing.T) { }, nil } errorBody := marshalTrueNASRequest(t, map[string]any{ - "host": "nas.local", - "port": 80, - "apiKey": "key", + "host": "nas.local", + "port": 80, + "apiKey": "key", + "username": "pulse-readonly", }) errorReq := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/test", bytes.NewReader(errorBody)) errorRec := httptest.NewRecorder() @@ -926,7 +968,7 @@ func TestTrueNASHandlers_HandleTestConnection_SuccessAndFailure(t *testing.T) { } } -func TestTrueNASHandlers_HandleTestSavedConnection_UsesStoredSecrets(t *testing.T) { +func TestTrueNASHandlers_HandleTestSavedConnection_UsesStoredLegacyAPIKey(t *testing.T) { setTrueNASFeatureForTest(t, true) connection := config.TrueNASInstance{ @@ -959,7 +1001,7 @@ func TestTrueNASHandlers_HandleTestSavedConnection_UsesStoredSecrets(t *testing. if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } - if gotConfig.Host != "truenas.local" || gotConfig.APIKey != "super-secret" || !gotConfig.UseHTTPS { + if gotConfig.Host != "truenas.local" || gotConfig.APIKey != "super-secret" || gotConfig.Username != "" || !gotConfig.UseHTTPS { t.Fatalf("unexpected saved client config: %+v", gotConfig) } summary := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})[connection.ID] @@ -1004,9 +1046,10 @@ func TestTrueNASHandlers_HandlePreviewConnection_ReturnsCanonicalImpact(t *testi setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(unifiedresources.NewMonitorAdapter(registry))) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/preview", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -1059,10 +1102,11 @@ func TestTrueNASHandlers_HandlePreviewConnection_ReturnsNoChangeForDisabledConne setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(unifiedresources.NewMonitorAdapter(registry))) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", - "enabled": false, + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", + "enabled": false, }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/preview", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -1095,9 +1139,10 @@ func TestTrueNASHandlers_HandlePreviewConnection_ReturnsUnavailableWhenSupplemen bindTestSupplementalUsageProvider(monitor, unifiedresources.SourceTrueNAS, provider) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/preview", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -1115,9 +1160,10 @@ func TestTrueNASHandlers_HandlePreviewConnection_ReturnsUnavailableWhenSupplemen provider.settleAtWithRecords(time.Now().UTC().Add(time.Minute), nil) body := marshalTrueNASRequest(t, map[string]any{ - "name": "tower", - "host": "tower.local", - "apiKey": "super-secret", + "name": "tower", + "host": "tower.local", + "apiKey": "super-secret", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/preview", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -1176,6 +1222,7 @@ func TestTrueNASHandlers_HandlePreviewSavedConnection_UsesReplacementProjection( Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", Enabled: true, UseHTTPS: true, }, @@ -1184,9 +1231,10 @@ func TestTrueNASHandlers_HandlePreviewSavedConnection_UsesReplacementProjection( } body := marshalTrueNASRequest(t, map[string]any{ - "name": "backup", - "host": "backup.local", - "apiKey": "********", + "name": "backup", + "host": "backup.local", + "apiKey": "********", + "username": "pulse-readonly", }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/conn-1/preview", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -1237,6 +1285,7 @@ func TestTrueNASHandlers_HandlePreviewSavedConnection_ReturnsRemovalForDisabledC Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", Enabled: true, UseHTTPS: true, }, @@ -1245,10 +1294,11 @@ func TestTrueNASHandlers_HandlePreviewSavedConnection_ReturnsRemovalForDisabledC } body := marshalTrueNASRequest(t, map[string]any{ - "name": "archive", - "host": "archive.local", - "apiKey": "********", - "enabled": false, + "name": "archive", + "host": "archive.local", + "apiKey": "********", + "username": "pulse-readonly", + "enabled": false, }) req := httptest.NewRequest(http.MethodPost, "/api/truenas/connections/conn-1/preview", bytes.NewReader(body)) rec := httptest.NewRecorder() @@ -1288,6 +1338,7 @@ func TestTrueNASHandlers_HandlePreviewSavedConnection_ReturnsUnavailableWhenSupp Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", Enabled: true, UseHTTPS: true, }, @@ -1315,6 +1366,7 @@ func TestTrueNASHandlers_HandlePreviewSavedConnection_ReturnsUnavailableWhenSupp Name: "archive", Host: "archive.local", APIKey: "super-secret", + Username: "pulse-readonly", Enabled: true, UseHTTPS: true, }, @@ -1338,6 +1390,7 @@ func TestTrueNASHandlers_HandleTestSavedConnection_UpdatesPollSummaryFailure(t * Host: "truenas.local", Port: 443, APIKey: "super-secret", + Username: "pulse-readonly", UseHTTPS: true, InsecureSkipVerify: false, Enabled: true, @@ -1381,6 +1434,7 @@ func TestTrueNASHandlers_HandleTestSavedConnection_MergesEditedPayloadWithStored Host: "truenas.local", Port: 443, APIKey: "super-secret", + Username: "pulse-readonly", UseHTTPS: true, InsecureSkipVerify: false, Fingerprint: "sha256:old", @@ -1402,6 +1456,7 @@ func TestTrueNASHandlers_HandleTestSavedConnection_MergesEditedPayloadWithStored "host": "tower-edited.local", "port": 8443, "apiKey": "********", + "username": "pulse-readonly", "useHttps": true, "insecureSkipVerify": true, "fingerprint": "sha256:new", @@ -1491,6 +1546,7 @@ func trueNASInstanceFromRawURL(t *testing.T, id string, rawURL string, enabled b Host: parsed.Hostname(), Port: port, APIKey: "test-api-key", + Username: "pulse-readonly", UseHTTPS: strings.EqualFold(parsed.Scheme, "https"), Enabled: enabled, PollIntervalSecs: 60, diff --git a/internal/config/truenas.go b/internal/config/truenas.go index 917e344c9..a091a63f5 100644 --- a/internal/config/truenas.go +++ b/internal/config/truenas.go @@ -95,6 +95,9 @@ func (t *TrueNASInstance) Validate() error { } if strings.TrimSpace(t.APIKey) != "" { + if strings.TrimSpace(t.Username) == "" { + return fmt.Errorf("truenas API key owner username is required for supported authentication") + } return nil } diff --git a/internal/config/truenas_test.go b/internal/config/truenas_test.go index c1b8b39fe..cd87a61c4 100644 --- a/internal/config/truenas_test.go +++ b/internal/config/truenas_test.go @@ -81,11 +81,20 @@ func TestTrueNASValidate(t *testing.T) { wantError: "truenas credentials are required", }, { - name: "api key auth", + name: "api key without owner username", instance: &TrueNASInstance{ Host: "nas.local", APIKey: "key", }, + wantError: "API key owner username is required", + }, + { + name: "api key auth", + instance: &TrueNASInstance{ + Host: "nas.local", + APIKey: "key", + Username: "pulse-monitor", + }, }, { name: "username password auth",