Skip to content

Commit c23e038

Browse files
committed
feat(headers): restore REST/SSE redaction and add deep-merge PATCH
User feedback on the previous commit: dropping REST/SSE redaction unblocked the UI but narrowed the threat model from PR #425 (a local process that holds the API key but not filesystem access could read other upstreams' Bearer tokens off the wire). The better trade-off is to keep both halves of PR #425 intact AND let the UI edit without round-tripping the redacted sentinel. Solution: deep-merge PATCH semantics so the client sends only the keys that changed. Redacted-but-untouched values stay out of the patch entirely, the backend keeps the real string on disk. Backend: - internal/httpapi/server.go::handlePatchServer — when the request body contains `headers` or `env`, MERGE into the existing stored map instead of replacing. New `headers_remove` / `env_remove` fields carry explicit deletes. Sending both is allowed (deletes apply after upserts). - internal/httpapi/server.go::AddServerRequest — adds the two `*_remove` fields with a comment documenting the new semantics. The add path ignores them. - internal/httpapi/server.go::handleGetServers — re-enable `redactServerHeaders` on both code paths. - internal/runtime/event_bus.go::emitServersChanged — re-enable redaction on SSE `servers.changed` payloads. SSE rides the same trust boundary as the REST GET. - internal/config/config.go::RevealSecretHeaders — restore the original PR #425 doc (REST + MCP both gated) with a new paragraph pointing at the deep-merge mechanism that makes the UI work anyway. Tests: - internal/httpapi/patch_server_test.go — 4 new tests pinning the merge semantics: - TestHandlePatchServer_HeadersDeepMerge — `headers: {X-New: v}` against existing `{Authorization, X-Trace}` preserves both original keys and adds X-New. - TestHandlePatchServer_HeadersRemove — `headers_remove: [...]` deletes the listed keys. - TestHandlePatchServer_HeadersSetAndRemove — both fields in one PATCH; deletes apply after upserts. - TestHandlePatchServer_EnvDeepMerge — same pattern for env. - internal/runtime/event_bus_payload_test.go — restored the original PR #425 assertion (`...RedactsSensitiveHeaders`). Frontend (Web UI): - ServerDetail.vue — `patchServerDiff(patch, action)` replaces the old `patchKVMap`. Each per-row UI action now sends a minimal targeted patch: - Edit one row: `{headers: {key: newValue}}` - Delete one row: `{headers_remove: [key]}` - Add one row: `{headers: {newKey: newValue}}` - Convert to secret: `{headers: {key: "${keyring:NAME}"}}` The redacted sentinel for unchanged keys never round-trips, by construction. - KVValueCell.vue — restore the `isBackendRedacted` branch. When the cell renders `***REDACTED***`, the reveal / Convert-to-secret buttons disappear (we don't hold the real value) and the cell shows the sentinel verbatim with a tooltip explaining that editing still works through the inline edit button. macOS Swift: - ServerDetailView.swift::saveEdits — switch from "send the full parsed map" to "diff against `server.headers` / `server.env` and send the diff". New private helper `diffKVMap(original:next:)` returns a `(set, remove)` tuple suitable for the deep-merge PATCH body. The same invariant holds: leaving a redacted line untouched in the textarea produces `next[k] == "***REDACTED***" == original[k]`, so the key stays out of both sides of the diff and the backend preserves the real value. - ServerDetailView.swift::kvRow — restore the `value != "***REDACTED***"` gate on the Convert-to-secret button (the sentinel isn't useful as a keyring payload). - ServerDetailView.swift::editHeaders doc — explain the new flow. End-to-end verification against the live local instance: $ curl -s -H "X-API-Key: ..." /api/v1/servers | jq '...synapbus.headers' → {"Authorization": "***REDACTED***"} # redacted ✓ $ jq '...synapbus.headers' ~/.mcpproxy/mcp_config.json → {"Authorization": "Bearer 1d386..."} # real on disk $ curl -X PATCH .../servers/synapbus -d '{"headers":{"X-Trace-Test":"merge-works"}}' $ jq '...synapbus.headers' ~/.mcpproxy/mcp_config.json → {"Authorization": "Bearer 1d386...", "X-Trace-Test": "merge-works"} # real token preserved ✓ $ curl -X PATCH .../servers/synapbus -d '{"headers_remove":["X-Trace-Test"]}' $ jq '...synapbus.headers' ~/.mcpproxy/mcp_config.json → {"Authorization": "Bearer 1d386..."} # X-Trace-Test deleted ✓ PR #425's E2E tests still pass (TestE2E_PatchDeepMergesEnvAndHeaders, TestE2E_MultipleEnableDisablePreservesConfig — both exercise the MCP tool which still redacts). PR #425's intent is fully preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 98a7ff1 commit c23e038

10 files changed

Lines changed: 459 additions & 128 deletions

File tree

frontend/src/components/KVValueCell.vue

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,30 +27,39 @@
2727
<span class="font-mono text-xs">env var: {{ envRefName }}</span>
2828
</span>
2929
</template>
30-
<!-- Literal value: masked by default to avoid shoulder-surfing
31-
leaks; click the eye to reveal the actual string returned by
32-
the REST API. The REST API serves plaintext header / env
33-
values (the API key is the gate at this trust boundary), so
34-
reveal always shows the real thing. -->
30+
<!-- Literal value path. Two sub-cases:
31+
1. Backend redacted the value (`***REDACTED***` sentinel). The
32+
real string isn't on the client; reveal would just expose
33+
the sentinel and Convert-to-secret has nothing to upload to
34+
the keyring. We surface a small "backend-redacted" hint
35+
instead so the user understands why those actions are
36+
disabled. Editing the value still works through the inline
37+
edit button — the PATCH endpoint deep-merges, so typing a
38+
new value replaces just that one header server-side.
39+
2. Plaintext literal: mask by default, eye to reveal, lock to
40+
convert. -->
3541
<template v-else>
36-
<code v-if="revealed" class="bg-base-200 px-1.5 py-0.5 rounded text-xs break-all">{{ rawValue }}</code>
37-
<code v-else class="bg-base-200 px-1.5 py-0.5 rounded text-xs text-base-content/50">{{ redactedPreview }}</code>
38-
<button
39-
class="btn btn-ghost btn-xs"
40-
:title="revealed ? 'Hide value' : 'Reveal value'"
41-
@click="revealed ? $emit('hide') : $emit('reveal')"
42-
>
43-
<span aria-hidden="true">{{ revealed ? '🙈' : '👁' }}</span>
44-
</button>
45-
<button
46-
class="btn btn-ghost btn-xs"
47-
title="Move value into the OS keyring and reference it as ${keyring:name}"
48-
@click="$emit('convert')"
49-
:disabled="busy"
50-
>
51-
<span aria-hidden="true">🔒</span>
52-
<span class="hidden md:inline ml-1">Convert to secret</span>
53-
</button>
42+
<code v-if="isBackendRedacted" class="bg-base-200 px-1.5 py-0.5 rounded text-xs text-base-content/50" title="Backend redacted this value. Edit to overwrite — the PATCH endpoint deep-merges, so the unchanged real value is preserved.">{{ rawValue }}</code>
43+
<template v-else>
44+
<code v-if="revealed" class="bg-base-200 px-1.5 py-0.5 rounded text-xs break-all">{{ rawValue }}</code>
45+
<code v-else class="bg-base-200 px-1.5 py-0.5 rounded text-xs text-base-content/50">{{ redactedPreview }}</code>
46+
<button
47+
class="btn btn-ghost btn-xs"
48+
:title="revealed ? 'Hide value' : 'Reveal value'"
49+
@click="revealed ? $emit('hide') : $emit('reveal')"
50+
>
51+
<span aria-hidden="true">{{ revealed ? '🙈' : '👁' }}</span>
52+
</button>
53+
<button
54+
class="btn btn-ghost btn-xs"
55+
title="Move value into the OS keyring and reference it as ${keyring:name}"
56+
@click="$emit('convert')"
57+
:disabled="busy"
58+
>
59+
<span aria-hidden="true">🔒</span>
60+
<span class="hidden md:inline ml-1">Convert to secret</span>
61+
</button>
62+
</template>
5463
</template>
5564
<button class="btn btn-ghost btn-xs" title="Edit" @click="$emit('start-edit')" :disabled="busy">✎</button>
5665
<button class="btn btn-ghost btn-xs text-error" title="Delete" @click="$emit('delete')" :disabled="busy">✕</button>
@@ -114,6 +123,15 @@ const envRefName = computed(() => {
114123
const m = (props.rawValue ?? '').match(/^\$\{env:([^}]+)\}$/)
115124
return m ? m[1] : ''
116125
})
126+
// The Go backend redacts secret header values via
127+
// `redactServerHeaders` so an MCP agent calling `upstream_servers list`
128+
// cannot exfiltrate Bearer tokens (PR #425). The REST API and SSE
129+
// channel inherit the same redaction. When that sentinel surfaces, the
130+
// real string is not in our hands — reveal/convert are disabled, but
131+
// editing still works because the PATCH endpoint deep-merges (the
132+
// untouched redacted key simply stays out of the patch body).
133+
const isBackendRedacted = computed(() => props.rawValue === '***REDACTED***')
134+
117135
const redactedPreview = computed(() => {
118136
const v = props.rawValue ?? ''
119137
if (!v) return '(empty)'

frontend/src/views/ServerDetail.vue

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2366,21 +2366,16 @@ async function startAddingEnv() {
23662366
newEnvKeyInput.value?.focus()
23672367
}
23682368
2369-
// Build the next headers/env map for a PATCH. We send the full map (not a
2370-
// diff) so the backend stores exactly what's on the client — there is no
2371-
// "merge" semantics on the server side, the partial-update is at the
2372-
// field level (headers / env / args / ...), not at the per-key level.
2373-
function buildNextMap(scope: 'header' | 'env', mutate: (next: Record<string, string>) => void): Record<string, string> {
2374-
const next = { ...(scope === 'header' ? serverHeaders.value : serverEnv.value) }
2375-
mutate(next)
2376-
return next
2377-
}
2378-
2379-
async function patchKVMap(scope: 'header' | 'env', nextMap: Record<string, string>, action: string): Promise<boolean> {
2369+
// patchServer with deep-merge semantics: the backend treats keys present
2370+
// in `headers` / `env` as upserts, keys absent as preserved, and keys
2371+
// listed in `headers_remove` / `env_remove` as deletes. This lets us send
2372+
// the minimal diff for each user action — and crucially never round-trips
2373+
// `***REDACTED***` values: any header whose redacted form was unchanged
2374+
// simply stays out of the patch, so the backend keeps the real string.
2375+
async function patchServerDiff(patch: Record<string, unknown>, action: string): Promise<boolean> {
23802376
if (!server.value) return false
23812377
kvPatchInFlight.value = true
23822378
try {
2383-
const patch = scope === 'header' ? { headers: nextMap } : { env: nextMap }
23842379
const resp = await api.patchServer(server.value.name, patch)
23852380
if (!resp.success) {
23862381
systemStore.addToast({ type: 'error', title: `${action} failed`, message: resp.error || 'Unknown error' })
@@ -2397,37 +2392,38 @@ async function patchKVMap(scope: 'header' | 'env', nextMap: Record<string, strin
23972392
}
23982393
}
23992394
2395+
function scopeKey(scope: 'header' | 'env'): 'headers' | 'env' {
2396+
return scope === 'header' ? 'headers' : 'env'
2397+
}
2398+
function scopeRemoveKey(scope: 'header' | 'env'): 'headers_remove' | 'env_remove' {
2399+
return scope === 'header' ? 'headers_remove' : 'env_remove'
2400+
}
2401+
24002402
async function saveEdit(scope: 'header' | 'env', k: string, val: string) {
2401-
const next = buildNextMap(scope, (m) => {
2402-
m[k] = val
2403-
})
2404-
const ok = await patchKVMap(scope, next, `Updated ${k}`)
2403+
const ok = await patchServerDiff({ [scopeKey(scope)]: { [k]: val } }, `Updated ${k}`)
24052404
if (ok) editingKey.value = null
24062405
}
24072406
24082407
async function deleteKv(scope: 'header' | 'env', k: string) {
24092408
if (!confirm(`Delete ${scope === 'header' ? 'header' : 'env variable'} "${k}"?`)) return
2410-
const next = buildNextMap(scope, (m) => {
2411-
delete m[k]
2412-
})
2413-
await patchKVMap(scope, next, `Deleted ${k}`)
2409+
await patchServerDiff({ [scopeRemoveKey(scope)]: [k] }, `Deleted ${k}`)
24142410
}
24152411
24162412
async function commitNewHeader() {
24172413
if (!newHeaderKey.value || !newHeaderValue.value) return
2418-
const next = buildNextMap('header', (m) => {
2419-
m[newHeaderKey.value] = newHeaderValue.value
2420-
})
2421-
const ok = await patchKVMap('header', next, `Added ${newHeaderKey.value}`)
2414+
const ok = await patchServerDiff(
2415+
{ headers: { [newHeaderKey.value]: newHeaderValue.value } },
2416+
`Added ${newHeaderKey.value}`
2417+
)
24222418
if (ok) addingHeader.value = false
24232419
}
24242420
24252421
async function commitNewEnv() {
24262422
if (!newEnvKey.value || !newEnvValue.value) return
2427-
const next = buildNextMap('env', (m) => {
2428-
m[newEnvKey.value] = newEnvValue.value
2429-
})
2430-
const ok = await patchKVMap('env', next, `Added ${newEnvKey.value}`)
2423+
const ok = await patchServerDiff(
2424+
{ env: { [newEnvKey.value]: newEnvValue.value } },
2425+
`Added ${newEnvKey.value}`
2426+
)
24312427
if (ok) addingEnv.value = false
24322428
}
24332429
@@ -2470,10 +2466,10 @@ async function commitConvert() {
24702466
return
24712467
}
24722468
const ref = `\${keyring:${m.secretName}}`
2473-
const next = buildNextMap(m.scope, (map) => {
2474-
map[m.key] = ref
2475-
})
2476-
await patchKVMap(m.scope, next, `Converted ${m.key} to secret`)
2469+
await patchServerDiff(
2470+
{ [scopeKey(m.scope)]: { [m.key]: ref } },
2471+
`Converted ${m.key} to secret`
2472+
)
24772473
closeConvertModal()
24782474
} catch (e: any) {
24792475
systemStore.addToast({ type: 'error', title: 'Convert failed', message: e?.message || String(e) })

internal/config/config.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -163,20 +163,22 @@ type Config struct {
163163
// Security scanner settings (Spec 039)
164164
Security *SecurityConfig `json:"security,omitempty" mapstructure:"security"`
165165

166-
// RevealSecretHeaders controls whether the `upstream_servers` MCP
167-
// tool returns sensitive header values (Authorization, X-API-Key,
168-
// Cookie, …) in plaintext or redacted to `***REDACTED***`.
166+
// RevealSecretHeaders, when true, disables the redaction of sensitive
167+
// header values (Authorization, X-API-Key, Cookie, …) in responses
168+
// from the `upstream_servers` MCP tool, the `/api/v1/servers` REST
169+
// API, and the SSE event stream.
169170
//
170-
// Default false — an MCP agent calling `upstream_servers list` sees
171-
// `***REDACTED***` so it cannot read another upstream's Bearer token
172-
// out of the config and exfiltrate it back to the LLM context.
171+
// Default false — sensitive header values are surfaced as
172+
// `***REDACTED***` so an MCP agent cannot read Bearer tokens / API
173+
// keys out of another upstream's config (PR #425).
173174
//
174-
// This flag DOES NOT affect the REST API (`/api/v1/servers`) or the
175-
// SSE event stream. Those paths are gated by the local API key —
176-
// the same trust boundary as access to `~/.mcpproxy/mcp_config.json`
177-
// on disk — so redacting there bought no real security while
178-
// breaking the Web UI / macOS tray edit-and-save round-trip. They
179-
// always send plaintext.
175+
// The Web UI / macOS tray edit forms work without seeing the real
176+
// values: PATCH /api/v1/servers/{id} deep-merges (omitted keys are
177+
// preserved, see `headers_remove` / `env_remove` for explicit
178+
// deletes), so clients compute a diff and only send the keys that
179+
// actually changed. Redacted-but-unchanged values never round-trip
180+
// — the backend keeps the real string. Set this to true if a
181+
// downstream tool genuinely needs raw values in the response.
180182
RevealSecretHeaders bool `json:"reveal_secret_headers,omitempty" mapstructure:"reveal-secret-headers"`
181183

182184
// Server edition multi-user configuration (only meaningful with -tags server)

internal/httpapi/patch_server_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,177 @@ func TestHandlePatchServer_ExplicitBoolsTakePrecedence(t *testing.T) {
129129
assert.True(t, mockCtrl.capturedUpdates.ReconnectOnUse,
130130
"ReconnectOnUse must be preserved from existing server (was true)")
131131
}
132+
133+
// TestHandlePatchServer_HeadersDeepMerge verifies that PATCH /api/v1/servers
134+
// preserves existing header keys not mentioned in the request body. This is
135+
// the foundation of the Web UI / macOS tray edit flow: clients send a diff
136+
// against the redacted view of headers, so any key whose value is
137+
// `***REDACTED***` and unchanged stays out of the patch — and the backend
138+
// must NOT wipe it.
139+
func TestHandlePatchServer_HeadersDeepMerge(t *testing.T) {
140+
logger := zap.NewNop().Sugar()
141+
mockCtrl := &mockPatchServerController{
142+
apiKey: "test-key",
143+
existingServer: &config.ServerConfig{
144+
Name: "synapbus",
145+
URL: "https://example.com/mcp",
146+
Protocol: "streamable-http",
147+
Enabled: true,
148+
Headers: map[string]string{
149+
"Authorization": "Bearer real-secret-token",
150+
"X-Trace": "on",
151+
},
152+
},
153+
}
154+
srv := NewServer(mockCtrl, logger, nil)
155+
156+
// Client sends just X-New-Header. Authorization is omitted because
157+
// its redacted view (`***REDACTED***`) matched the original, and
158+
// X-Trace is omitted because the user didn't touch it.
159+
body, _ := json.Marshal(map[string]any{
160+
"headers": map[string]string{"X-New-Header": "new-value"},
161+
})
162+
req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/synapbus", bytes.NewReader(body))
163+
req.Header.Set("Content-Type", "application/json")
164+
req.Header.Set("X-API-Key", "test-key")
165+
w := httptest.NewRecorder()
166+
srv.ServeHTTP(w, req)
167+
168+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
169+
require.NotNil(t, mockCtrl.capturedUpdates)
170+
171+
got := mockCtrl.capturedUpdates.Headers
172+
assert.Equal(t, "Bearer real-secret-token", got["Authorization"],
173+
"Authorization must be preserved verbatim — it was not in the PATCH body and the real secret must not be wiped")
174+
assert.Equal(t, "on", got["X-Trace"], "X-Trace must be preserved")
175+
assert.Equal(t, "new-value", got["X-New-Header"], "X-New-Header must be added")
176+
assert.Len(t, got, 3, "exactly 3 headers expected (Authorization, X-Trace, X-New-Header)")
177+
}
178+
179+
// TestHandlePatchServer_HeadersRemove verifies that the `headers_remove`
180+
// field deletes the listed keys from the stored map. Combined with the
181+
// merge behaviour above, this is how clients delete a header through PATCH.
182+
func TestHandlePatchServer_HeadersRemove(t *testing.T) {
183+
logger := zap.NewNop().Sugar()
184+
mockCtrl := &mockPatchServerController{
185+
apiKey: "test-key",
186+
existingServer: &config.ServerConfig{
187+
Name: "synapbus",
188+
Protocol: "http",
189+
URL: "https://example.com/mcp",
190+
Enabled: true,
191+
Headers: map[string]string{
192+
"Authorization": "Bearer token",
193+
"X-Trace": "on",
194+
"X-Old": "stale",
195+
},
196+
},
197+
}
198+
srv := NewServer(mockCtrl, logger, nil)
199+
200+
body, _ := json.Marshal(map[string]any{
201+
"headers_remove": []string{"X-Old", "X-Trace"},
202+
})
203+
req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/synapbus", bytes.NewReader(body))
204+
req.Header.Set("Content-Type", "application/json")
205+
req.Header.Set("X-API-Key", "test-key")
206+
w := httptest.NewRecorder()
207+
srv.ServeHTTP(w, req)
208+
209+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
210+
require.NotNil(t, mockCtrl.capturedUpdates)
211+
212+
got := mockCtrl.capturedUpdates.Headers
213+
assert.Equal(t, "Bearer token", got["Authorization"], "Authorization untouched")
214+
assert.Len(t, got, 1, "only Authorization should remain (X-Old and X-Trace removed)")
215+
_, hasOld := got["X-Old"]
216+
_, hasTrace := got["X-Trace"]
217+
assert.False(t, hasOld, "X-Old must be removed")
218+
assert.False(t, hasTrace, "X-Trace must be removed")
219+
}
220+
221+
// TestHandlePatchServer_HeadersSetAndRemove combines the merge + remove
222+
// operations in a single PATCH. The same body shape the Web UI and macOS
223+
// tray send when the user simultaneously edits one header and deletes
224+
// another.
225+
func TestHandlePatchServer_HeadersSetAndRemove(t *testing.T) {
226+
logger := zap.NewNop().Sugar()
227+
mockCtrl := &mockPatchServerController{
228+
apiKey: "test-key",
229+
existingServer: &config.ServerConfig{
230+
Name: "synapbus",
231+
Protocol: "http",
232+
URL: "https://example.com/mcp",
233+
Enabled: true,
234+
Headers: map[string]string{
235+
"Authorization": "Bearer old-token",
236+
"X-Trace": "on",
237+
},
238+
},
239+
}
240+
srv := NewServer(mockCtrl, logger, nil)
241+
242+
body, _ := json.Marshal(map[string]any{
243+
"headers": map[string]string{
244+
"Authorization": "Bearer new-token",
245+
"X-New": "new-value",
246+
},
247+
"headers_remove": []string{"X-Trace"},
248+
})
249+
req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/synapbus", bytes.NewReader(body))
250+
req.Header.Set("Content-Type", "application/json")
251+
req.Header.Set("X-API-Key", "test-key")
252+
w := httptest.NewRecorder()
253+
srv.ServeHTTP(w, req)
254+
255+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
256+
require.NotNil(t, mockCtrl.capturedUpdates)
257+
258+
got := mockCtrl.capturedUpdates.Headers
259+
assert.Equal(t, "Bearer new-token", got["Authorization"], "Authorization updated to new value")
260+
assert.Equal(t, "new-value", got["X-New"], "X-New added")
261+
_, hasTrace := got["X-Trace"]
262+
assert.False(t, hasTrace, "X-Trace deleted")
263+
assert.Len(t, got, 2)
264+
}
265+
266+
// TestHandlePatchServer_EnvDeepMerge mirrors HeadersDeepMerge for env vars.
267+
// The redaction risk is the same — backend doesn't redact env values today,
268+
// but the merge semantics are needed for symmetric UI editing.
269+
func TestHandlePatchServer_EnvDeepMerge(t *testing.T) {
270+
logger := zap.NewNop().Sugar()
271+
mockCtrl := &mockPatchServerController{
272+
apiKey: "test-key",
273+
existingServer: &config.ServerConfig{
274+
Name: "demo",
275+
Protocol: "stdio",
276+
Command: "uvx",
277+
Enabled: true,
278+
Env: map[string]string{
279+
"API_KEY": "live-secret",
280+
"LOG_LEVEL": "debug",
281+
},
282+
},
283+
}
284+
srv := NewServer(mockCtrl, logger, nil)
285+
286+
body, _ := json.Marshal(map[string]any{
287+
"env": map[string]string{"NEW_VAR": "value"},
288+
"env_remove": []string{"LOG_LEVEL"},
289+
})
290+
req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/demo", bytes.NewReader(body))
291+
req.Header.Set("Content-Type", "application/json")
292+
req.Header.Set("X-API-Key", "test-key")
293+
w := httptest.NewRecorder()
294+
srv.ServeHTTP(w, req)
295+
296+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
297+
require.NotNil(t, mockCtrl.capturedUpdates)
298+
299+
got := mockCtrl.capturedUpdates.Env
300+
assert.Equal(t, "live-secret", got["API_KEY"], "API_KEY preserved (not in patch body)")
301+
assert.Equal(t, "value", got["NEW_VAR"], "NEW_VAR added")
302+
_, hasLog := got["LOG_LEVEL"]
303+
assert.False(t, hasLog, "LOG_LEVEL deleted via env_remove")
304+
assert.Len(t, got, 2)
305+
}

0 commit comments

Comments
 (0)