diff --git a/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js index e1c9e56dd0..21667a043d 100644 --- a/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js +++ b/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js @@ -17,14 +17,65 @@ */ /** - * Secret management behaviour for LLM provider create/update flows. + * Secret management behaviour for LLM provider create and update flows. * - * Covers: - * TC-57 Add provider with plaintext API key → POST /secrets called, placeholder stored + * Create flow (TC-57 – TC-59, TC-63): + * TC-57 Add provider with plaintext API key → POST /secrets called, placeholder stored, + * plaintext absent from the provider detail page * TC-58 Re-save provider already holding a placeholder → POST /secrets NOT called * TC-59 POST /secrets 500 → provider creation aborted, no provider created + * TC-63 GET /secrets never returns "value"/"encryptedValue" for any secret in the org + * + * Update flow via Connection tab (TC-60 – TC-62, TC-64): + * TC-60 Edit credential with a new plaintext value → POST /secrets called, PUT /llm-providers + * body holds placeholder, plaintext absent from page + * TC-61 Edit credential by typing an explicit placeholder → POST /secrets NOT called, + * PUT /llm-providers fires with the placeholder as-is + * TC-62 POST /secrets 500 during edit → PUT /llm-providers NOT called, error shown + * TC-64 Same as TC-60 but reached via the provider list (nav → card → Connection tab) + * instead of staying on the just-created provider's own detail page */ -describe('AI Workspace — LLM provider secret management', () => { + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function toSlug(value) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function navigateToAddProvider() { + cy.get('[data-cyid="nav-service-provider"]', { timeout: 30000 }) + .should('be.visible') + .click(); + cy.get('[data-cyid="add-new-provider-button"]', { timeout: 30000 }) + .should('be.visible') + .click(); +} + +function selectOpenAITemplate() { + cy.get('[data-cyid="provider-template-openai-card"]', { timeout: 30000 }) + .should('be.visible') + .click(); +} + +function fillProviderForm(name, apiKey) { + cy.get('[data-cyid="provider-name-input"] input:visible', { timeout: 30000 }) + .should('be.visible') + .clear() + .type(name); + cy.get('[data-cyid="provider-api-key-input"] input:visible').type(apiKey); +} + +// --------------------------------------------------------------------------- +// CREATE flow +// --------------------------------------------------------------------------- + +describe('AI Workspace — LLM provider secret management (create flow)', () => { const suffix = Date.now().toString().slice(-8); const orgHandle = Cypress.env('ORG_HANDLE'); const providerName = `E2E Secret Provider ${suffix}`; @@ -55,8 +106,7 @@ describe('AI Workspace — LLM provider secret management', () => { }) .then((response) => { expect(response.status).to.eq(200); - const orgs = response.body?.list ?? []; - organizationId = orgs[0]?.id ?? ''; + organizationId = response.body?.list?.[0]?.id ?? ''; expect(organizationId).to.not.equal(''); }); }); @@ -109,6 +159,11 @@ describe('AI Workspace — LLM provider secret management', () => { 'match', new RegExp(`^/organizations/${orgHandle}/service-provider/[^/]+$`) ); + + // Plaintext key must never appear anywhere on the provider detail page. + cy.get('body').invoke('text').then((text) => { + expect(text, 'plaintext key absent from provider detail page').not.to.include('sk-tc57-plaintext-key'); + }); }); // ------------------------------------------------------------------------- @@ -120,9 +175,7 @@ describe('AI Workspace — LLM provider secret management', () => { cy.request({ method: 'POST', url: '/api/proxy/api/v0.9/secrets', - headers: { - Authorization: `Bearer ${authToken}`, - }, + headers: { Authorization: `Bearer ${authToken}` }, form: true, body: { id: existingHandle, @@ -204,38 +257,327 @@ describe('AI Workspace — LLM provider secret management', () => { }); }); - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // TC-63: GET /secrets never returns "value"/"encryptedValue" for any secret + // in the org (encryption proof, not scoped to a single just-created secret). + // ------------------------------------------------------------------------- + it('TC-63: GET /secrets never exposes plaintext value for any secret in the org', () => { + const handle = `${toSlug(providerName)}-tc63-api-key`; - function navigateToAddProvider() { - cy.get('[data-cyid="nav-service-provider"]', { timeout: 30000 }) - .should('be.visible') + cy.request({ + method: 'POST', + url: '/api/proxy/api/v0.9/secrets', + headers: { Authorization: `Bearer ${authToken}` }, + form: true, + body: { + id: handle, + displayName: `${providerName} TC-63 API Key`, + value: 'sk-tc63-encryption-proof', + type: 'GENERIC', + }, + failOnStatusCode: false, + }).then((r) => { + expect(r.status).to.be.oneOf([200, 201, 409]); + }); + + // Fetch the single secret by handle (not the paginated list — the org + // accumulates many secrets across the suite and the list defaults to + // limit=25, which can miss the one just created). + cy.request({ + url: `/api/proxy/api/v0.9/secrets/${encodeURIComponent(handle)}?organizationId=${encodeURIComponent(organizationId)}`, + headers: { Authorization: `Bearer ${authToken}` }, + }).then((r) => { + expect(r.status).to.eq(200); + const secret = r.body; + + expect(secret, `handle "${handle}" found`).to.exist; + expect(secret).to.have.property('id', handle); + expect(secret, 'no "value" field').not.to.have.property('value'); + expect(secret, 'no "encryptedValue" field').not.to.have.property('encryptedValue'); + + cy.request({ + method: 'DELETE', + url: `/api/proxy/api/v0.9/secrets/${encodeURIComponent(handle)}?organizationId=${encodeURIComponent(organizationId)}`, + headers: { Authorization: `Bearer ${authToken}` }, + failOnStatusCode: false, + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// UPDATE flow (Connection tab) +// --------------------------------------------------------------------------- + +describe('AI Workspace — LLM provider secret management (update flow)', () => { + const suffix = Date.now().toString().slice(-8); + const orgHandle = Cypress.env('ORG_HANDLE'); + const providerName = `E2E Secret Update Provider ${suffix}`; + const INITIAL_KEY = `sk-update-initial-${suffix}`; + const UPDATED_KEY = `sk-update-new-${suffix}`; + + let authToken = ''; + let organizationId = ''; + let providerId = ''; + + // Create a fresh provider for each test, navigate to its Connection tab. + // This avoids cross-hook variable sharing issues with test isolation. + beforeEach(() => { + cy.login(); + + cy.request({ + method: 'POST', + url: '/api/proxy/api/portal/v0.9/auth/login', + form: true, + body: { + username: Cypress.env('ADMIN_USER'), + password: Cypress.env('ADMIN_PASSWORD'), + }, + }).then((r) => { authToken = r.body?.token ?? ''; }); + + cy.then(() => + cy.request({ + url: '/api/proxy/api/v0.9/organizations', + headers: { Authorization: `Bearer ${authToken}` }, + }) + ).then((r) => { organizationId = r.body?.list?.[0]?.id ?? ''; }); + + // Sweep any stale E2E providers (e.g. if a previous test's afterEach delete failed). + cy.then(() => cy.sweepE2EProviders(authToken, organizationId)); + + cy.intercept('POST', /\/llm-providers(\?|$)/).as('setupProvider'); + + cy.get('[data-cyid="nav-service-provider"]', { timeout: 30000 }).should('be.visible').click(); + cy.get('[data-cyid="add-new-provider-button"]', { timeout: 30000 }).should('be.visible').click(); + cy.get('[data-cyid="provider-template-openai-card"]', { timeout: 30000 }).should('be.visible').click(); + cy.get('[data-cyid="provider-name-input"] input:visible', { timeout: 30000 }) + .should('be.visible').clear().type(providerName); + cy.get('[data-cyid="provider-api-key-input"] input:visible').type(INITIAL_KEY); + cy.get('[data-cyid="add-provider-button"]').should('not.be.disabled').click(); + + cy.wait('@setupProvider', { timeout: 20000 }).then((pi) => { + providerId = pi.response.body?.id ?? ''; + }); + + // Match on the create response, not the URL: the redirect lands a beat + // after the click, and a URL scrape can race onto the transient "new" + // route — exclude it explicitly so the wait doesn't resolve early. + cy.location('pathname', { timeout: 30000 }).should( + 'match', + new RegExp(`^/organizations/${orgHandle}/service-provider/(?!new$)[^/]+$`) + ); + + cy.contains('[role="tab"]', 'Connection', { timeout: 15000 }).click(); + cy.contains('label', 'Credentials', { timeout: 15000 }).should('be.visible'); + // Wait for provider data to load: the credential field must be in the masked + // state before any test interacts with it. Without this, handleUpdateCredential + // returns early because provider is still null/loading. + cy.contains('label', 'Credentials').parent().find('input', { timeout: 15000 }) + .should('have.value', '******'); + }); + + afterEach(() => { + if (authToken && organizationId && providerId) { + cy.request({ + method: 'DELETE', + url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}?organizationId=${encodeURIComponent(organizationId)}`, + headers: { Authorization: `Bearer ${authToken}` }, + failOnStatusCode: false, + }); + providerId = ''; + } + }); + + // ------------------------------------------------------------------------- + // TC-60: Edit credential with new plaintext → secret created, placeholder in PUT body + // ------------------------------------------------------------------------- + it('TC-60: editing the credential creates a new secret and stores the placeholder in the provider PUT body', () => { + cy.intercept('POST', '**/secrets').as('createSecret'); + cy.intercept('PUT', /\/llm-providers\/[^/?]+(\?|$)/).as('updateProvider'); + + // Click the Credentials field to clear the masked value, then type the new key. + cy.contains('label', 'Credentials') + .parent() + .find('input') .click(); - cy.get('[data-cyid="add-new-provider-button"]', { timeout: 30000 }) - .should('be.visible') + + // After focus the field clears itself (masked → empty). + cy.contains('label', 'Credentials') + .parent() + .find('input') + .should('have.value', ''); + + cy.contains('label', 'Credentials') + .parent() + .find('input') + .type(UPDATED_KEY); + + // Click the Save button that appears at the bottom when there are unsaved changes. + cy.contains('button', 'Save').click(); + + // Assert: POST /secrets fires before the provider PUT. + cy.wait('@createSecret', { timeout: 20000 }).then((si) => { + expect(si.response.statusCode, 'POST /secrets status').to.be.oneOf([200, 201]); + // New secret handle is a UUID. + const secretId = si.response.body?.id ?? ''; + expect(secretId).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + // Server never echoes the plaintext back. + expect(JSON.stringify(si.response.body)).not.to.include(UPDATED_KEY); + cy.log(`✅ Secret created: id="${secretId}"`); + }); + + // Assert: PUT /llm-providers carries a placeholder, not the plaintext key. + cy.wait('@updateProvider', { timeout: 20000 }).then((pi) => { + expect(pi.response.statusCode, 'PUT /llm-providers status').to.be.oneOf([200, 201]); + const authValue = pi.request.body?.upstream?.main?.auth?.value ?? ''; + expect(authValue, 'PUT body has placeholder').to.include('{{ secret "'); + expect(authValue, 'PUT body does NOT have plaintext key').not.to.include(UPDATED_KEY); + cy.log(`✅ Placeholder in PUT body: ${authValue}`); + }); + + // Plaintext key must not appear anywhere on the page. + cy.get('body').invoke('text').then((text) => { + expect(text, 'plaintext absent from page').not.to.include(UPDATED_KEY); + }); + }); + + // ------------------------------------------------------------------------- + // TC-61: Edit credential by typing an explicit placeholder → POST /secrets NOT called + // ------------------------------------------------------------------------- + it('TC-61: typing a placeholder value skips secret creation and sends the placeholder directly', () => { + const explicitHandle = `${toSlug(providerName)}-api-key`; + + // Pre-create the secret so the platform-api accepts the placeholder in the PUT. + // cy.request() bypasses cy.intercept(), so this won't affect secretCallCount below. + cy.request({ + method: 'POST', + url: '/api/proxy/api/v0.9/secrets', + headers: { Authorization: `Bearer ${authToken}` }, + form: true, + body: { + id: explicitHandle, + displayName: `${providerName} API Key`, + value: 'sk-tc61-explicit-handle-value', + type: 'GENERIC', + }, + failOnStatusCode: false, + }).then((r) => { + expect(r.status).to.be.oneOf([200, 201, 409]); + }); + + let secretCallCount = 0; + cy.intercept('POST', '**/secrets', (req) => { + secretCallCount += 1; + req.continue(); + }); + cy.intercept('PUT', /\/llm-providers\/[^/?]+(\?|$)/).as('updateProvider'); + + cy.contains('label', 'Credentials') + .parent() + .find('input') .click(); - } - function selectOpenAITemplate() { - cy.get('[data-cyid="provider-template-openai-card"]', { timeout: 30000 }) - .should('be.visible') + cy.contains('label', 'Credentials') + .parent() + .find('input') + .should('have.value', ''); + + cy.contains('label', 'Credentials') + .parent() + .find('input') + .type(`{{ secret "${explicitHandle}" }}`, { parseSpecialCharSequences: false }); + + // Click the Save button at the bottom — typing a placeholder skips createSecret. + cy.contains('button', 'Save').click(); + + // PUT fires with the typed placeholder. + cy.wait('@updateProvider', { timeout: 20000 }).then((pi) => { + expect(pi.response.statusCode, 'PUT /llm-providers status').to.be.oneOf([200, 201]); + const authValue = pi.request.body?.upstream?.main?.auth?.value ?? ''; + expect(authValue, 'PUT body carries the typed placeholder').to.include('{{ secret "'); + cy.wrap(null).then(() => { + expect(secretCallCount, 'POST /secrets not called').to.equal(0); + }); + }); + }); + + // ------------------------------------------------------------------------- + // TC-62: POST /secrets 500 during edit → PUT /llm-providers NOT called, error shown + // ------------------------------------------------------------------------- + it('TC-62: aborts the credential update when POST /secrets returns 500', () => { + cy.intercept('POST', '**/secrets', { + statusCode: 500, + body: { error: 'simulated vault failure' }, + }).as('failSecret'); + + let providerCallCount = 0; + cy.intercept('PUT', /\/llm-providers\/[^/?]+(\?|$)/, (req) => { + providerCallCount += 1; + req.continue(); + }); + + cy.contains('label', 'Credentials') + .parent() + .find('input') .click(); - } - function fillProviderForm(name, apiKey) { - cy.get('[data-cyid="provider-name-input"] input:visible', { timeout: 30000 }) - .should('be.visible') - .clear() - .type(name); - cy.get('[data-cyid="provider-api-key-input"] input:visible').type(apiKey); - } -}); + cy.contains('label', 'Credentials') + .parent() + .find('input') + .should('have.value', ''); -function toSlug(value) { - return value - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} + cy.contains('label', 'Credentials') + .parent() + .find('input') + .type('sk-tc62-will-fail'); + + // Click Save — the stubbed 500 on POST /secrets should abort the update. + cy.contains('button', 'Save').click(); + + cy.wait('@failSecret'); + + // Error notification must be visible. + cy.get('[data-testid="aiworkspace-snackbar-notification"]', { timeout: 15000 }) + .should('be.visible'); + + // Provider PUT must not have fired. + cy.wrap(null).then(() => { + expect(providerCallCount, 'PUT /llm-providers not called').to.equal(0); + }); + }); + + // ------------------------------------------------------------------------- + // TC-64: Same rotation as TC-60, but reached via the provider list (nav → + // card → Connection tab) instead of staying on the freshly created + // provider's own detail page — exercises the list → card navigation path. + // ------------------------------------------------------------------------- + it('TC-64: updating the API key after navigating from the provider list rotates the secret', () => { + const UPDATED_KEY_VIA_LIST = `sk-update-via-list-${suffix}`; + + cy.intercept('POST', '**/secrets').as('createSecret'); + cy.intercept('PUT', /\/llm-providers\/[^/?]+(\?|$)/).as('updateProvider'); + + cy.get('[data-cyid="nav-service-provider"]', { timeout: 30000 }).should('be.visible').click(); + cy.get(`[data-cyid="provider-card-${providerId}"]`, { timeout: 30000 }).should('be.visible').click(); + cy.contains('[role="tab"]', 'Connection', { timeout: 15000 }).click(); + cy.contains('label', 'Credentials', { timeout: 15000 }).parent().find('input', { timeout: 15000 }) + .should('have.value', '******'); + + cy.contains('label', 'Credentials').parent().find('input').click(); + cy.contains('label', 'Credentials').parent().find('input').should('have.value', ''); + cy.contains('label', 'Credentials').parent().find('input').type(UPDATED_KEY_VIA_LIST); + cy.contains('button', 'Save').click(); + + cy.wait('@createSecret', { timeout: 20000 }).then((si) => { + expect(si.response.statusCode, 'POST /secrets status').to.be.oneOf([200, 201]); + expect(JSON.stringify(si.response.body), 'plaintext not in secret response').not.to.include(UPDATED_KEY_VIA_LIST); + }); + + cy.wait('@updateProvider', { timeout: 20000 }).then((pi) => { + expect(pi.response.statusCode, 'PUT /llm-providers status').to.be.oneOf([200, 201]); + const authValue = pi.request.body?.upstream?.main?.auth?.value ?? ''; + expect(authValue, 'PUT body has placeholder').to.include('{{ secret "'); + expect(authValue, 'PUT body does NOT have plaintext key').not.to.include(UPDATED_KEY_VIA_LIST); + }); + }); +}); diff --git a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js index ef645f6a27..eebd41af1b 100644 --- a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js +++ b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js @@ -20,7 +20,8 @@ * Secret management behaviour for MCP server (external server) create flows. * * Covers: - * TC-80 Create MCP server with auth → POST /secrets called, placeholder stored + * TC-80 Create MCP server with auth → POST /secrets called, placeholder stored, + * plaintext absent from the created server's page * TC-81 fetchServerInfo validation → POST /secrets NOT called * TC-82 Re-submit form with existing placeholder → POST /secrets NOT called * TC-83 POST /secrets 500 → MCP server creation aborted, no server created @@ -121,6 +122,11 @@ describe('AI Workspace — MCP server secret management', () => { interception.response.body?.handle ?? serverId; }); + + // Plaintext auth value must never appear anywhere on the created server's page. + cy.get('body').invoke('text').then((text) => { + expect(text, 'plaintext absent from page').not.to.include('tok-tc80-plaintext'); + }); }); // --------------------------------------------------------------------------- diff --git a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/004-mcp-secret-management-update.cy.js b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/004-mcp-secret-management-update.cy.js new file mode 100644 index 0000000000..679f774907 --- /dev/null +++ b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/004-mcp-secret-management-update.cy.js @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Secret management behaviour for MCP server (external server) update flow. + * + * There is no UI to change upstream.main.auth.value after creation; the + * credential is set only at create time. `auth.value` is `writeOnly` in the + * API (platform-api/resources/openapi.yaml), so GET /mcp-proxies/{id} never + * returns it — the Policies tab's locally cached server object never has it + * either. When the user saves policies, PUT /mcp-proxies/{id} re-sends that + * object, so the request body legitimately omits auth.value while keeping + * auth.header/auth.type intact. The backend's preserveMCPUpstreamAuthValue + * (platform-api/internal/service/mcp.go) restores the existing stored value + * from the DB whenever the incoming value is empty, so the persisted + * credential is not lost. These tests verify that: + * + * TC-96 Saving policies with existing auth → PUT /mcp-proxies keeps the + * auth block's header/type (but omits value, by design) and + * POST /secrets is NOT called + * TC-97 Saving policies on a server created WITHOUT auth → PUT /mcp-proxies + * has no auth block and POST /secrets is NOT called + */ + +describe('AI Workspace — MCP server secret management (update / policy-save flow)', () => { + const suffix = Date.now().toString().slice(-8); + const projectName = `E2E MCP Update Secret Project ${suffix}`; + const serverName = `E2E MCP Update Server ${suffix}`; + + let authToken = ''; + let organizationId = ''; + let createdProjectId = ''; + let createdServerId = ''; + + // Create a project + MCP server via UI once before all tests. + beforeEach(() => { + cy.login(); + + cy.request({ + method: 'POST', + url: '/api/proxy/api/portal/v0.9/auth/login', + form: true, + body: { + username: Cypress.env('ADMIN_USER'), + password: Cypress.env('ADMIN_PASSWORD'), + }, + }).then((r) => { authToken = r.body?.token ?? ''; }); + + cy.then(() => + cy.request({ + url: '/api/proxy/api/v0.9/organizations', + headers: { Authorization: `Bearer ${authToken}` }, + }) + ).then((r) => { organizationId = r.body?.list?.[0]?.id ?? ''; }); + + cy.intercept('POST', '**/projects').as('setupProject'); + cy.intercept('POST', '**/secrets').as('setupSecret'); + cy.intercept('POST', /\/mcp-proxies(\?|$)/).as('setupServer'); + + cy.contains('Projects', { timeout: 30000 }).should('be.visible').click(); + cy.contains('button, a', /Create Project|Add New Project/, { timeout: 30000 }) + .should('be.visible') + .click(); + cy.get('input[placeholder="My AI Project"]', { timeout: 30000 }) + .should('be.visible') + .type(projectName); + cy.get('textarea[placeholder="Short description of the project."]') + .type('MCP update secret test project'); + cy.contains('button', 'Create').should('not.be.disabled').click(); + cy.wait('@setupProject', { timeout: 20000 }).then((pi) => { + createdProjectId = pi.response.body?.id ?? ''; + }); + + cy.contains(projectName, { timeout: 30000 }).should('be.visible').click(); + cy.contains('MCP Proxies', { timeout: 30000 }).should('be.visible').click(); + cy.contains('button, a', 'Create MCP Proxy', { timeout: 30000 }) + .should('be.visible') + .click(); + + cy.intercept('POST', '**/fetch-server-info*', { + statusCode: 200, + body: { + serverInfo: { name: 'Stub MCP Server', version: '1.0.0' }, + tools: [], + resources: [], + prompts: [], + }, + }).as('stubFetch'); + + cy.contains('Create MCP Proxy from Endpoint', { timeout: 30000 }).should('be.visible'); + cy.get('input[placeholder="Enter URL of Your MCP Proxy"]', { timeout: 15000 }) + .should('be.visible') + .type('https://sample.mcp.example.com/mcp'); + cy.contains('Advanced Configurations', { timeout: 10000 }).click(); + cy.get('input[placeholder="Header"]', { timeout: 10000 }) + .should('be.visible') + .type('Authorization'); + cy.get('input[placeholder="Value"]', { timeout: 10000 }) + .should('be.visible') + .type('Bearer tok-setup-key'); + + cy.contains('button', 'Fetch Server Info', { timeout: 15000 }) + .should('be.visible') + .click(); + cy.wait('@stubFetch'); + + cy.contains('button', 'Next', { timeout: 15000 }).should('be.visible').click(); + + cy.get('input[placeholder="WSO2 MCP Proxy"]', { timeout: 15000 }) + .should('be.visible') + .clear() + .type(serverName); + + cy.contains('button', 'Create', { timeout: 15000 }).should('not.be.disabled').click(); + + cy.wait('@setupSecret', { timeout: 20000 }); + cy.wait('@setupServer', { timeout: 20000 }).then((pi) => { + createdServerId = pi.response.body?.id ?? ''; + }); + + cy.location('pathname', { timeout: 30000 }).should('match', /\/mcp-proxy\/[^/]+$/); + cy.contains('[role="tab"]', 'Policies', { timeout: 15000 }).click(); + }); + + afterEach(() => { + if (createdServerId && authToken) { + cy.request({ + method: 'DELETE', + url: `/api/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId)}`, + headers: { Authorization: `Bearer ${authToken}` }, + failOnStatusCode: false, + }); + createdServerId = ''; + } + if (createdProjectId && authToken) { + cy.request({ + method: 'DELETE', + url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(createdProjectId)}`, + headers: { Authorization: `Bearer ${authToken}` }, + failOnStatusCode: false, + }); + createdProjectId = ''; + } + }); + + // ------------------------------------------------------------------------- + // TC-96: Saving policies when server has an existing auth → + // PUT keeps the auth block's header/type (value is correctly + // omitted — writeOnly and never present in the locally cached + // server object), no POST /secrets called. + // ------------------------------------------------------------------------- + it('TC-96: saving policies keeps the existing auth header/type without creating a new secret', () => { + let secretCallCount = 0; + cy.intercept('POST', '**/secrets', (req) => { secretCallCount += 1; req.continue(); }); + cy.intercept('PUT', /\/mcp-proxies\/[^/?]+(\?|$)/).as('updateServer'); + + // Save/Cancel are disabled until the policy list actually changes (dirty-check + // on selectedPolicies vs initialPolicies), so add one policy to enable Save. + cy.intercept('GET', '**/policies*').as('getPolicies'); + cy.contains('button', 'Add Policies', { timeout: 15000 }).click(); + cy.wait('@getPolicies', { timeout: 20000 }); + cy.contains('CORS', { timeout: 15000 }).click(); + cy.get('[data-testid="policy-param-submit"]', { timeout: 15000 }).should('not.be.disabled').click(); + + cy.contains('button', 'Save', { timeout: 15000 }).should('not.be.disabled').click(); + + cy.wait('@updateServer', { timeout: 20000 }).then((pi) => { + expect(pi.response.statusCode, 'PUT /mcp-proxies status').to.be.oneOf([200, 201]); + const auth = pi.request.body?.upstream?.main?.auth; + // The auth block structure must survive the save — header/type intact. + expect(auth?.header, 'PUT body keeps the auth header').to.equal('Authorization'); + expect(auth?.type, 'PUT body keeps the auth type').to.equal('header'); + // value is writeOnly (never returned by GET), so the PUT correctly omits + // it; the backend's preserveMCPUpstreamAuthValue restores the stored + // value from the DB when it sees an empty value on update. + expect(auth?.value, 'PUT body omits value (writeOnly, not a data loss)').to.be.undefined; + // Plaintext must NOT appear. + const bodyStr = JSON.stringify(pi.request.body); + expect(bodyStr, 'PUT body has no plaintext key').not.to.include('tok-setup-key'); + // Verify no secret was created. + cy.wrap(null).then(() => { + expect(secretCallCount, 'POST /secrets not called').to.equal(0); + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// TC-97: Separate describe — server WITHOUT auth, save policies +// --------------------------------------------------------------------------- + +describe('AI Workspace — MCP server secret management (update / no-auth server)', () => { + const suffix2 = (Date.now() + 1).toString().slice(-8); + const projectName2 = `E2E MCP Update NoAuth Project ${suffix2}`; + const serverName2 = `E2E MCP Update NoAuth Server ${suffix2}`; + + let authToken2 = ''; + let organizationId2 = ''; + let createdProjectId2 = ''; + let createdServerId2 = ''; + + beforeEach(() => { + cy.login(); + + cy.request({ + method: 'POST', + url: '/api/proxy/api/portal/v0.9/auth/login', + form: true, + body: { + username: Cypress.env('ADMIN_USER'), + password: Cypress.env('ADMIN_PASSWORD'), + }, + }).then((r) => { authToken2 = r.body?.token ?? ''; }); + + cy.then(() => + cy.request({ + url: '/api/proxy/api/v0.9/organizations', + headers: { Authorization: `Bearer ${authToken2}` }, + }) + ).then((r) => { organizationId2 = r.body?.list?.[0]?.id ?? ''; }); + + cy.intercept('POST', '**/projects').as('setupProject2'); + cy.intercept('POST', /\/mcp-proxies(\?|$)/).as('setupServer2'); + + cy.contains('Projects', { timeout: 30000 }).should('be.visible').click(); + cy.contains('button, a', /Create Project|Add New Project/, { timeout: 30000 }) + .should('be.visible') + .click(); + cy.get('input[placeholder="My AI Project"]', { timeout: 30000 }) + .should('be.visible') + .type(projectName2); + cy.get('textarea[placeholder="Short description of the project."]') + .type('MCP update no-auth test project'); + cy.contains('button', 'Create').should('not.be.disabled').click(); + cy.wait('@setupProject2', { timeout: 20000 }).then((pi) => { + createdProjectId2 = pi.response.body?.id ?? ''; + }); + + cy.contains(projectName2, { timeout: 30000 }).should('be.visible').click(); + cy.contains('MCP Proxies', { timeout: 30000 }).should('be.visible').click(); + cy.contains('button, a', 'Create MCP Proxy', { timeout: 30000 }) + .should('be.visible') + .click(); + + cy.intercept('POST', '**/fetch-server-info*', { + statusCode: 200, + body: { + serverInfo: { name: 'Stub MCP Server', version: '1.0.0' }, + tools: [], + resources: [], + prompts: [], + }, + }).as('stubFetch2'); + + cy.contains('Create MCP Proxy from Endpoint', { timeout: 30000 }).should('be.visible'); + cy.get('input[placeholder="Enter URL of Your MCP Proxy"]', { timeout: 15000 }) + .should('be.visible') + .type('https://sample.mcp.example.com/mcp'); + + cy.contains('button', 'Fetch Server Info', { timeout: 15000 }) + .should('be.visible') + .click(); + cy.wait('@stubFetch2'); + + cy.contains('button', 'Next', { timeout: 15000 }).should('be.visible').click(); + + cy.get('input[placeholder="WSO2 MCP Proxy"]', { timeout: 15000 }) + .should('be.visible') + .clear() + .type(serverName2); + + cy.contains('button', 'Create', { timeout: 15000 }).should('not.be.disabled').click(); + + cy.wait('@setupServer2', { timeout: 20000 }).then((pi) => { + createdServerId2 = pi.response.body?.id ?? ''; + }); + + cy.location('pathname', { timeout: 30000 }).should('match', /\/mcp-proxy\/[^/]+$/); + cy.contains('[role="tab"]', 'Policies', { timeout: 15000 }).click(); + }); + + afterEach(() => { + if (createdServerId2 && authToken2) { + cy.request({ + method: 'DELETE', + url: `/api/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId2)}`, + headers: { Authorization: `Bearer ${authToken2}` }, + failOnStatusCode: false, + }); + createdServerId2 = ''; + } + if (createdProjectId2 && authToken2) { + cy.request({ + method: 'DELETE', + url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(createdProjectId2)}`, + headers: { Authorization: `Bearer ${authToken2}` }, + failOnStatusCode: false, + }); + createdProjectId2 = ''; + } + }); + + // ------------------------------------------------------------------------- + // TC-97: Saving policies when server has NO auth → + // PUT has no auth block and no POST /secrets called + // ------------------------------------------------------------------------- + it('TC-97: saving policies on a server without auth does not create a secret and omits auth from PUT', () => { + let secretCallCount = 0; + cy.intercept('POST', '**/secrets', (req) => { secretCallCount += 1; req.continue(); }); + cy.intercept('PUT', /\/mcp-proxies\/[^/?]+(\?|$)/).as('updateServer'); + + // Save/Cancel are disabled until the policy list actually changes (dirty-check + // on selectedPolicies vs initialPolicies), so add one policy to enable Save. + cy.intercept('GET', '**/policies*').as('getPolicies2'); + cy.contains('button', 'Add Policies', { timeout: 15000 }).click(); + cy.wait('@getPolicies2', { timeout: 20000 }); + cy.contains('CORS', { timeout: 15000 }).click(); + cy.get('[data-testid="policy-param-submit"]', { timeout: 15000 }).should('not.be.disabled').click(); + + cy.contains('button', 'Save', { timeout: 15000 }).should('not.be.disabled').click(); + + cy.wait('@updateServer', { timeout: 20000 }).then((pi) => { + expect(pi.response.statusCode, 'PUT /mcp-proxies status').to.be.oneOf([200, 201]); + const body = pi.request.body; + expect(body?.upstream?.main?.auth, 'no auth block in PUT body').to.be.undefined; + expect(JSON.stringify(body), 'no placeholder in PUT body').not.to.include('{{ secret "'); + cy.wrap(null).then(() => { + expect(secretCallCount, 'POST /secrets not called').to.equal(0); + }); + }); + }); +}); diff --git a/portals/ai-workspace/src/contexts/MCP/MCPProxiesContext.tsx b/portals/ai-workspace/src/contexts/MCP/MCPProxiesContext.tsx index 401237d67c..69f6b6354c 100644 --- a/portals/ai-workspace/src/contexts/MCP/MCPProxiesContext.tsx +++ b/portals/ai-workspace/src/contexts/MCP/MCPProxiesContext.tsx @@ -31,6 +31,13 @@ import type { UpdateMCPServerRequest, } from '../../utils/types'; import { mcpProxiesApis } from '../../apis/MCP/mcpProxiesApis'; +import { + createSecret, + deleteSecret, + buildSecretPlaceholder, + generateSecretHandle, + extractSecretHandle, +} from '../../apis/secretApis'; import { useAppShell } from '../AppShellContext'; import { PLATFORM_API_BASE_URL } from '../../config.env'; import { logger } from '../../utils/logger'; @@ -153,11 +160,60 @@ export function MCPServersProvider({ children }: MCPServersProviderProps) { throw new Error('Organization ID is missing'); } try { + // If the upstream auth value is a new plain-text credential (not already a + // placeholder), create a new secret and substitute the placeholder before + // persisting. After a successful update the old secret is deleted best-effort. + let updatesPayload = updates; + const authValue = updates.upstream?.main?.auth?.value; + const isAlreadyPlaceholder = + typeof authValue === 'string' && authValue.includes('{{ secret '); + + if (authValue && !isAlreadyPlaceholder) { + const secretHandle = generateSecretHandle(); + const secretResponse = await createSecret({ + id: secretHandle, + displayName: `${mcpServerId} upstream auth`, + description: `Auto-generated secret for MCP server ${mcpServerId}`, + value: authValue, + type: 'GENERIC', + }); + logger.info('Created new secret for MCP server update', { secretHandle, mcpServerId }); + + updatesPayload = { + ...updates, + upstream: { + ...updates.upstream, + main: { + ...updates.upstream?.main, + url: updates.upstream?.main?.url ?? '', + auth: { + ...updates.upstream?.main?.auth, + value: buildSecretPlaceholder(secretResponse.id), + }, + }, + }, + }; + } + const updatedMCPServer = await mcpProxiesApis.updateMCPServer( mcpServerId, - updates, + updatesPayload, apimBaseUrl ); + + // Best-effort: delete the old secret after the update succeeds. + if (authValue && !isAlreadyPlaceholder) { + const currentServer = mcpServersResponse.list.find((s) => s.id === mcpServerId); + const oldHandle = currentServer?.upstream?.main?.auth?.value + ? extractSecretHandle(currentServer.upstream.main.auth.value) + : null; + if (oldHandle) { + deleteSecret(oldHandle).catch((err) => { + logger.warn('Could not delete old secret after MCP server update', { oldHandle, err }); + }); + } + } + setMCPServersResponse((prev) => ({ ...prev, list: prev.list.map((mcpServer) => @@ -170,7 +226,7 @@ export function MCPServersProvider({ children }: MCPServersProviderProps) { throw err; } }, - [organizationId, apimBaseUrl] + [organizationId, apimBaseUrl, mcpServersResponse.list] ); const deleteMCPServer = useCallback( diff --git a/portals/ai-workspace/src/contexts/MCP/MCPProxyContext.tsx b/portals/ai-workspace/src/contexts/MCP/MCPProxyContext.tsx index 52fc0ba2d3..bfa6618743 100644 --- a/portals/ai-workspace/src/contexts/MCP/MCPProxyContext.tsx +++ b/portals/ai-workspace/src/contexts/MCP/MCPProxyContext.tsx @@ -19,6 +19,13 @@ import React, { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react'; import type { MCPServer, UpdateMCPServerRequest } from '../../utils/types'; import { mcpProxiesApis } from '../../apis/MCP/mcpProxiesApis'; +import { + createSecret, + deleteSecret, + buildSecretPlaceholder, + generateSecretHandle, + extractSecretHandle, +} from '../../apis/secretApis'; import { useAppShell } from '../AppShellContext'; import { PLATFORM_API_BASE_URL } from '../../config.env'; import { logger } from '../../utils/logger'; @@ -99,14 +106,62 @@ export function MCPServerProvider({ children, mcpServerId }: MCPServerProviderPr throw new Error('MCP Server ID or Organization ID is missing'); } try { - const updatedMCPServer = await mcpProxiesApis.updateMCPServer(mcpServerId, updates, apimBaseUrl); + // If the upstream auth value is a new plain-text credential (not already a + // placeholder), create a new secret and substitute the placeholder before + // persisting. After a successful update the old secret is deleted best-effort. + let updatesPayload = updates; + const authValue = updates.upstream?.main?.auth?.value; + const isAlreadyPlaceholder = + typeof authValue === 'string' && authValue.includes('{{ secret '); + + if (authValue && !isAlreadyPlaceholder) { + const secretHandle = generateSecretHandle(); + const secretResponse = await createSecret({ + id: secretHandle, + displayName: `${mcpServerId} upstream auth`, + description: `Auto-generated secret for MCP server ${mcpServerId}`, + value: authValue, + type: 'GENERIC', + }); + logger.info('Created new secret for MCP server update', { secretHandle, mcpServerId }); + + updatesPayload = { + ...updates, + upstream: { + ...updates.upstream, + main: { + ...updates.upstream?.main, + url: updates.upstream?.main?.url ?? mcpServer?.upstream?.main?.url ?? '', + auth: { + ...updates.upstream?.main?.auth, + value: buildSecretPlaceholder(secretResponse.id), + }, + }, + }, + }; + } + + const updatedMCPServer = await mcpProxiesApis.updateMCPServer(mcpServerId, updatesPayload, apimBaseUrl); setMCPServer(updatedMCPServer); + + // Best-effort: delete the old secret after the update succeeds. + if (authValue && !isAlreadyPlaceholder) { + const oldHandle = mcpServer?.upstream?.main?.auth?.value + ? extractSecretHandle(mcpServer.upstream.main.auth.value) + : null; + if (oldHandle) { + deleteSecret(oldHandle).catch((err) => { + logger.warn('Could not delete old secret after MCP server update', { oldHandle, err }); + }); + } + } + return updatedMCPServer; } catch (err) { logger.error('Failed to update MCP server:', err); throw err; } - }, [mcpServerId, organizationId, apimBaseUrl]); + }, [mcpServerId, organizationId, apimBaseUrl, mcpServer]); const deleteMCPServer = useCallback(async (): Promise => { if (!mcpServerId || !organizationId) { diff --git a/portals/ai-workspace/src/contexts/llmProvider/LLMProviderContext.tsx b/portals/ai-workspace/src/contexts/llmProvider/LLMProviderContext.tsx index 173a08602e..200a5ede81 100644 --- a/portals/ai-workspace/src/contexts/llmProvider/LLMProviderContext.tsx +++ b/portals/ai-workspace/src/contexts/llmProvider/LLMProviderContext.tsx @@ -24,6 +24,13 @@ import type { APIKeyListResponse, } from '../../utils/types'; import * as llmProviderApis from '../../apis/llmProviderApis'; +import { + createSecret, + deleteSecret, + buildSecretPlaceholder, + generateSecretHandle, + extractSecretHandle, +} from '../../apis/secretApis'; import { useAppShell } from '../AppShellContext'; import { PLATFORM_API_BASE_URL } from '../../config.env'; import { logger } from '../../utils/logger'; @@ -114,14 +121,63 @@ export function LLMProviderProvider({ children, providerId }: LLMProviderProvide throw new Error('Provider ID or Organization ID is missing'); } try { - const updatedProvider = await llmProviderApis.updateLLMProvider(providerId, updates, organizationId, PLATFORM_API_BASE_URL); + // If the upstream auth value is a new plain-text credential (not already a + // placeholder), create a new secret and substitute the placeholder before + // persisting. After a successful provider update the old secret is deleted + // best-effort so the gateway is not left with a dangling reference. + let updatesPayload = updates; + const authValue = updates.upstream?.main?.auth?.value; + const isAlreadyPlaceholder = + typeof authValue === 'string' && authValue.includes('{{ secret '); + + if (authValue && !isAlreadyPlaceholder) { + const secretHandle = generateSecretHandle(); + const secretResponse = await createSecret({ + id: secretHandle, + displayName: `${providerId} API Key`, + description: `Auto-generated secret for LLM provider ${providerId}`, + value: authValue, + type: 'GENERIC', + }); + logger.info('Created new secret for LLM provider update', { secretHandle, providerId }); + + updatesPayload = { + ...updates, + upstream: { + ...updates.upstream, + main: { + ...updates.upstream?.main, + url: updates.upstream?.main?.url ?? provider?.upstream?.main?.url ?? '', + auth: { + ...updates.upstream?.main?.auth, + value: buildSecretPlaceholder(secretResponse.id), + }, + }, + }, + }; + } + + const updatedProvider = await llmProviderApis.updateLLMProvider(providerId, updatesPayload, organizationId, PLATFORM_API_BASE_URL); setProvider(updatedProvider); + + // Best-effort: delete the old secret only after the provider update succeeds. + if (authValue && !isAlreadyPlaceholder) { + const oldHandle = provider?.upstream?.main?.auth?.value + ? extractSecretHandle(provider.upstream.main.auth.value) + : null; + if (oldHandle) { + deleteSecret(oldHandle).catch((err) => { + logger.warn('Could not delete old secret after provider update', { oldHandle, err }); + }); + } + } + return updatedProvider; } catch (err) { logger.error('Failed to update LLM provider:', err); throw err; } - }, [providerId, organizationId, PLATFORM_API_BASE_URL]); + }, [providerId, organizationId, PLATFORM_API_BASE_URL, provider]); const deleteProvider = useCallback(async (): Promise => { if (!providerId || !organizationId) { diff --git a/portals/ai-workspace/src/contexts/proxy/ProxiesContext.tsx b/portals/ai-workspace/src/contexts/proxy/ProxiesContext.tsx index 9b98663263..3594c650c5 100644 --- a/portals/ai-workspace/src/contexts/proxy/ProxiesContext.tsx +++ b/portals/ai-workspace/src/contexts/proxy/ProxiesContext.tsx @@ -31,6 +31,13 @@ import type { ProxiesResponse, } from '../../utils/types'; import * as proxyApis from '../../apis/proxyApis'; +import { + createSecret, + deleteSecret, + buildSecretPlaceholder, + generateSecretHandle, + extractSecretHandle, +} from '../../apis/secretApis'; import { useAppShell } from '../AppShellContext'; import { PLATFORM_API_BASE_URL } from '../../config.env'; import { logger } from '../../utils/logger'; @@ -168,12 +175,57 @@ export function ProxiesProvider({ children }: ProxiesProviderProps) { throw new Error('Organization ID is missing'); } try { + // If the provider auth value is a new plain-text credential (not already + // a placeholder), create a new secret and substitute the placeholder. + // After a successful proxy update the old secret is deleted best-effort. + let updatesPayload = updates; + const providerAuth = typeof updates.provider === 'object' ? updates.provider?.auth : undefined; + const authValue = providerAuth?.value; + const isAlreadyPlaceholder = + typeof authValue === 'string' && authValue.includes('{{ secret '); + + if (authValue && !isAlreadyPlaceholder) { + const secretHandle = generateSecretHandle(); + const secretResponse = await createSecret({ + id: secretHandle, + displayName: `${proxyId} provider API Key`, + description: `Auto-generated secret for LLM proxy ${proxyId}`, + value: authValue, + type: 'GENERIC', + }); + logger.info('Created new secret for LLM proxy update', { secretHandle, proxyId }); + + updatesPayload = { + ...updates, + provider: { + ...(typeof updates.provider === 'object' ? updates.provider : { id: updates.provider ?? '' }), + auth: { + ...providerAuth, + value: buildSecretPlaceholder(secretResponse.id), + }, + }, + }; + } + const updatedProxy = await proxyApis.updateProxy( proxyId, - updates, + updatesPayload, organizationId, apimBaseUrl ); + + // Best-effort: delete the old secret after the proxy update succeeds. + if (authValue && !isAlreadyPlaceholder) { + const currentProxy = proxiesResponse.list.find((p) => p.id === proxyId); + const existingAuth = typeof currentProxy?.provider === 'object' ? currentProxy?.provider?.auth : undefined; + const oldHandle = existingAuth?.value ? extractSecretHandle(existingAuth.value) : null; + if (oldHandle) { + deleteSecret(oldHandle).catch((err) => { + logger.warn('Could not delete old secret after proxy update', { oldHandle, err }); + }); + } + } + setProxiesResponse((prev) => ({ ...prev, list: prev.list.map((proxy) => @@ -190,7 +242,7 @@ export function ProxiesProvider({ children }: ProxiesProviderProps) { throw err; } }, - [organizationId, apimBaseUrl] + [organizationId, apimBaseUrl, proxiesResponse.list] ); const deleteProxy = useCallback( diff --git a/portals/ai-workspace/src/contexts/proxy/ProxyContext.tsx b/portals/ai-workspace/src/contexts/proxy/ProxyContext.tsx index f2151d0d05..243973095d 100644 --- a/portals/ai-workspace/src/contexts/proxy/ProxyContext.tsx +++ b/portals/ai-workspace/src/contexts/proxy/ProxyContext.tsx @@ -26,6 +26,13 @@ import type { } from '../../utils/types'; import * as proxyApis from '../../apis/proxyApis'; import * as llmProxiesApis from '../../apis/llmProxiesApis'; +import { + createSecret, + deleteSecret, + buildSecretPlaceholder, + generateSecretHandle, + extractSecretHandle, +} from '../../apis/secretApis'; import { useAppShell } from '../AppShellContext'; import { PLATFORM_API_BASE_URL } from '../../config.env'; import { logger } from '../../utils/logger'; @@ -120,14 +127,59 @@ export function ProxyProvider({ children, proxyId }: ProxyProviderProps) { throw new Error('Proxy ID or Organization ID is missing'); } try { - const updatedProxy = await proxyApis.updateProxy(proxyId, updates, organizationId, apimBaseUrl); + // If the provider auth value is a new plain-text credential (not already a + // placeholder), create a new secret and substitute the placeholder before + // persisting. After a successful proxy update the old secret is deleted + // best-effort so the gateway is not left with a dangling reference. + let updatesPayload = updates; + const providerAuth = typeof updates.provider === 'object' ? updates.provider?.auth : undefined; + const authValue = providerAuth?.value; + const isAlreadyPlaceholder = + typeof authValue === 'string' && authValue.includes('{{ secret '); + + if (authValue && !isAlreadyPlaceholder) { + const secretHandle = generateSecretHandle(); + const secretResponse = await createSecret({ + id: secretHandle, + displayName: `${proxyId} provider API Key`, + description: `Auto-generated secret for LLM proxy ${proxyId}`, + value: authValue, + type: 'GENERIC', + }); + logger.info('Created new secret for LLM proxy update', { secretHandle, proxyId }); + + updatesPayload = { + ...updates, + provider: { + ...(typeof updates.provider === 'object' ? updates.provider : { id: updates.provider ?? '' }), + auth: { + ...providerAuth, + value: buildSecretPlaceholder(secretResponse.id), + }, + }, + }; + } + + const updatedProxy = await proxyApis.updateProxy(proxyId, updatesPayload, organizationId, apimBaseUrl); setProxy(updatedProxy); + + // Best-effort: delete the old secret only after the proxy update succeeds. + if (authValue && !isAlreadyPlaceholder) { + const existingAuth = typeof proxy?.provider === 'object' ? proxy?.provider?.auth : undefined; + const oldHandle = existingAuth?.value ? extractSecretHandle(existingAuth.value) : null; + if (oldHandle) { + deleteSecret(oldHandle).catch((err) => { + logger.warn('Could not delete old secret after proxy update', { oldHandle, err }); + }); + } + } + return updatedProxy; } catch (err) { logger.error('Failed to update proxy:', err); throw err; } - }, [proxyId, organizationId, apimBaseUrl]); + }, [proxyId, organizationId, apimBaseUrl, proxy]); const deleteProxy = useCallback(async (): Promise => { if (!proxyId || !organizationId) {