Skip to content

Commit 18b022e

Browse files
Merge branch 'master' into fix/resource-delete-atomic-deprovision-guard-2026-06-04
2 parents c985119 + 43bef6b commit 18b022e

7 files changed

Lines changed: 399 additions & 54 deletions

File tree

internal/handlers/deploy_stack_promote_approval_coverage_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,7 @@ func TestStackUpdateEnv_TooLarge_Returns413(t *testing.T) {
628628
VALUES ($1, $2, $3, 'healthy', 'pro', 'production') RETURNING id
629629
`, teamID, slug, "instant-stack-"+slug).Scan(&stackID))
630630

631-
// A single value > 64KiB blows the cap inside UpdateStackEnvVars.
631+
// A single value > 64KiB blows the cap inside MergeStackEnvVars.
632632
big := make([]byte, 70*1024)
633633
for i := range big {
634634
big[i] = 'A'

internal/handlers/faultdb_deployasync_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ var errFaultInjected = errors.New("faultdb: injected failure")
3131
// successful Query/Exec calls allowed before injection begins; -1 disables
3232
// injection (pass-through).
3333
type faultConfig struct {
34-
calls atomic.Int64
34+
calls atomic.Int64
3535
failAfter int64
3636
}
3737

@@ -87,6 +87,14 @@ func (c *faultConn) ExecContext(ctx context.Context, query string, args []driver
8787
return nil, driver.ErrSkip
8888
}
8989

90+
// BeginTx returns the inner (unwrapped) driver.Tx and does NOT itself consume
91+
// the failAfter budget. Note this does NOT exempt in-transaction queries from
92+
// fault injection: lib/pq's driver.Tx implements neither driver.QueryerContext
93+
// nor driver.ExecerContext, so database/sql routes sql.Tx.QueryContext /
94+
// ExecContext back through this conn's QueryContext / ExecContext — which DO
95+
// honor shouldFail(). That's why models.MergeStackEnvVars' tx-internal
96+
// SELECT ... FOR UPDATE and UPDATE are still fault-injectable (see
97+
// stack_final2_test.go).
9098
func (c *faultConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
9199
if bt, ok := c.inner.(driver.ConnBeginTx); ok {
92100
return bt.BeginTx(ctx, opts)

internal/handlers/stack.go

Lines changed: 20 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,18 +1150,19 @@ type updateStackEnvBody struct {
11501150
// NEVER persisted — the silent-data-loss failure mode. Now backed by
11511151
// migration 062's stacks.env_vars JSONB column. The handler:
11521152
//
1153-
// 1. Loads existing env_vars from the row.
1154-
// 2. Merges the incoming body's `env` map into the existing set (PATCH
1155-
// semantics — each call is incremental, not replace-all). Setting a
1156-
// key to the empty string deletes it (matches the dashboard contract
1157-
// and the env-var convention for "absent" elsewhere on the platform).
1158-
// 3. Validates every key against isValidEnvKey (POSIX [A-Z_][A-Z0-9_]*),
1153+
// 1. Validates every key against isValidEnvKey (POSIX [A-Z_][A-Z0-9_]*),
11591154
// mirroring deploy.go and /stacks/new so PATCH cannot smuggle in a
11601155
// key shape the create/redeploy paths would reject async.
1161-
// 4. Persists via UpdateStackEnvVars.
1162-
// 5. Emits a best-effort audit_log row (kind=stack.env.updated) for the
1156+
// 2. Atomically merges the incoming body's `env` map into the existing set
1157+
// (PATCH semantics — each call is incremental, not replace-all) via
1158+
// models.MergeStackEnvVars, a single row-locked transaction. Setting a
1159+
// key to the empty string deletes it (matches the dashboard contract
1160+
// and the env-var convention for "absent" elsewhere on the platform).
1161+
// The row lock serializes concurrent PATCHes so no key is lost to a
1162+
// read-modify-write race (bug-bash #10).
1163+
// 3. Emits a best-effort audit_log row (kind=stack.env.updated) for the
11631164
// dashboard activity feed and the support panel.
1164-
// 6. Returns the FULL merged env in the response so the caller doesn't
1165+
// 4. Returns the FULL merged env in the response so the caller doesn't
11651166
// have to re-GET to see the new state.
11661167
//
11671168
// Auth required — anonymous stacks cannot be mutated after creation.
@@ -1213,44 +1214,23 @@ func (h *StackHandler) UpdateEnv(c *fiber.Ctx) error {
12131214
"Env-var key "+quoteForError(badKey)+" must match POSIX shape [A-Z_][A-Z0-9_]*")
12141215
}
12151216

1216-
// Load existing env, merge, save. Empty-string value deletes the key —
1217-
// matches the dashboard's PATCH-with-delete affordance.
1218-
existing, err := models.GetStackEnvVars(c.Context(), h.db, stack.ID)
1217+
// Load-merge-save ATOMICALLY in one row-locked transaction. Empty-string
1218+
// value deletes the key — matches the dashboard's PATCH-with-delete
1219+
// affordance. The single MergeStackEnvVars call replaces the previous
1220+
// GetStackEnvVars → merge-in-Go → UpdateStackEnvVars sequence, which had a
1221+
// lost-update race: two concurrent PATCHes both read the same snapshot and
1222+
// the second blind-overwrote the first, silently dropping a key (bug-bash
1223+
// #10). MergeStackEnvVars serializes concurrent PATCHes via SELECT ... FOR
1224+
// UPDATE, so the second reads the first's committed result.
1225+
merged, deletes, err := models.MergeStackEnvVars(c.Context(), h.db, stack.ID, body.Env)
12191226
if err != nil {
1220-
var notFound *models.ErrStackNotFound
1221-
if errors.As(err, &notFound) {
1222-
// Row vanished between GetStackBySlug and here. Treat as 404.
1223-
return respondError(c, fiber.StatusNotFound, "not_found", "Stack not found")
1224-
}
1225-
slog.Error("stack.env.fetch_failed",
1226-
"slug", slug, "team_id", team.ID, "stack_id", stack.ID, "error", err)
1227-
return respondError(c, fiber.StatusServiceUnavailable, "fetch_failed",
1228-
"Failed to fetch existing env vars")
1229-
}
1230-
if existing == nil {
1231-
existing = map[string]string{}
1232-
}
1233-
merged := make(map[string]string, len(existing)+len(body.Env))
1234-
for k, v := range existing {
1235-
merged[k] = v
1236-
}
1237-
deletes := 0
1238-
for k, v := range body.Env {
1239-
if v == "" {
1240-
delete(merged, k)
1241-
deletes++
1242-
continue
1243-
}
1244-
merged[k] = v
1245-
}
1246-
1247-
if err := models.UpdateStackEnvVars(c.Context(), h.db, stack.ID, merged); err != nil {
12481227
if errors.Is(err, models.ErrStackEnvVarsTooLarge) {
12491228
return respondError(c, fiber.StatusRequestEntityTooLarge, "env_too_large",
12501229
"Total env_vars payload exceeds 64KiB. Trim values or split across services.")
12511230
}
12521231
var notFound *models.ErrStackNotFound
12531232
if errors.As(err, &notFound) {
1233+
// Row vanished between GetStackBySlug and the merge tx. Treat as 404.
12541234
return respondError(c, fiber.StatusNotFound, "not_found", "Stack not found")
12551235
}
12561236
slog.Error("stack.env.persist_failed",

internal/handlers/stack_final2_test.go

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,38 @@ package handlers_test
77
// and a LATER one errors, so we seed a team-owned stack on the pooled DB and
88
// run the handler over a fault DB sharing the same postgres DSN.
99
//
10-
// * UpdateEnv GetStackEnvVars error → fetch_failed (stack.go L1185-1188, failAfter=2)
11-
// * UpdateEnv UpdateStackEnvVars error → persist_failed (L1216-1219, failAfter=3)
12-
// * Family GetStackBySlug error → fetch_failed (L1885, failAfter=1)
10+
// * UpdateEnv MergeStackEnvVars error → persist_failed (bug-bash #10)
11+
// * Family GetStackBySlug error → fetch_failed (failAfter=1)
12+
//
13+
// bug-bash #10 (2026-06-04): UpdateEnv's old GetStackEnvVars → merge-in-Go →
14+
// UpdateStackEnvVars sequence was a non-atomic read-modify-write that lost keys
15+
// under concurrency. It was replaced by a single atomic models.MergeStackEnvVars
16+
// call (one row-locked transaction). That collapsed the two distinct DB-error
17+
// arms (fetch_failed for the read, persist_failed for the write) into ONE
18+
// surface: any error out of MergeStackEnvVars — including the tx's internal
19+
// SELECT ... FOR UPDATE and the UPDATE — maps to persist_failed 503. (Note the
20+
// merge's tx-internal queries DO fault through the shared faultConn: lib/pq's
21+
// driver.Tx has no QueryerContext, so sql.Tx.QueryContext/ExecContext fall back
22+
// to the conn-level faultConn.QueryContext/ExecContext, which honor the
23+
// failAfter budget.) Both former tests now assert the single persist_failed
24+
// surface at the two query depths (the in-tx SELECT and the in-tx UPDATE).
1325

1426
import (
27+
"context"
1528
"database/sql"
29+
"database/sql/driver"
1630
"errors"
31+
"io"
1732
"net/http"
1833
"net/http/httptest"
1934
"os"
2035
"strings"
36+
"sync"
2137
"testing"
2238

2339
"github.com/gofiber/fiber/v2"
2440
"github.com/google/uuid"
41+
"github.com/lib/pq"
2542
"github.com/stretchr/testify/assert"
2643
"github.com/stretchr/testify/require"
2744

@@ -82,7 +99,12 @@ func patchStackEnvF2(t *testing.T, app *fiber.App, slug, jwt, body string) (int,
8299
return resp.StatusCode, string(raw[:n])
83100
}
84101

85-
func TestStackFinal2_UpdateEnv_FetchEnvFailed(t *testing.T) {
102+
// TestStackFinal2_UpdateEnv_MergeSelectFailed faults the merge tx's internal
103+
// SELECT ... FOR UPDATE. team(1)+GetStackBySlug(2) ok, then MergeStackEnvVars
104+
// begins a tx and its SELECT (3rd conn-level query) errors → persist_failed.
105+
// (Pre-bug-bash-#10 this depth was the GetStackEnvVars "fetch_failed" arm; the
106+
// atomic merge collapsed it into the single persist_failed surface.)
107+
func TestStackFinal2_UpdateEnv_MergeSelectFailed(t *testing.T) {
86108
stackNeedDB(t)
87109
seedDB, clean := testhelpers.SetupTestDB(t)
88110
defer clean()
@@ -91,13 +113,16 @@ func TestStackFinal2_UpdateEnv_FetchEnvFailed(t *testing.T) {
91113
_, slug := seedStack(t, seedDB, &teamID, "healthy")
92114
jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamIDStr, "stkf2@example.com")
93115

94-
// team(1)+GetStackBySlug(2) ok, GetStackEnvVars(3) errors.
116+
// team(1)+GetStackBySlug(2) ok, merge tx SELECT ... FOR UPDATE(3) errors.
95117
app := stackFaultApp(t, openFaultDB(t, 2))
96118
status, body := patchStackEnvF2(t, app, slug, jwt, `{"env":{"FOO":"bar"}}`)
97119
assert.Equal(t, http.StatusServiceUnavailable, status)
98-
assert.Contains(t, body, "fetch_failed")
120+
assert.Contains(t, body, "persist_failed")
99121
}
100122

123+
// TestStackFinal2_UpdateEnv_PersistFailed faults the merge tx's internal UPDATE.
124+
// team(1)+slug(2)+merge SELECT(3) ok, the merge tx UPDATE(4) errors →
125+
// persist_failed.
101126
func TestStackFinal2_UpdateEnv_PersistFailed(t *testing.T) {
102127
stackNeedDB(t)
103128
seedDB, clean := testhelpers.SetupTestDB(t)
@@ -107,7 +132,7 @@ func TestStackFinal2_UpdateEnv_PersistFailed(t *testing.T) {
107132
_, slug := seedStack(t, seedDB, &teamID, "healthy")
108133
jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamIDStr, "stkf2@example.com")
109134

110-
// team(1)+slug(2)+GetStackEnvVars(3) ok, UpdateStackEnvVars(4) errors.
135+
// team(1)+slug(2)+merge SELECT FOR UPDATE(3) ok, merge UPDATE(4) errors.
111136
app := stackFaultApp(t, openFaultDB(t, 3))
112137
status, body := patchStackEnvF2(t, app, slug, jwt, `{"env":{"FOO":"bar"}}`)
113138
assert.Equal(t, http.StatusServiceUnavailable, status)
@@ -131,3 +156,103 @@ func TestStackFinal2_Family_FetchFailed(t *testing.T) {
131156
defer resp.Body.Close()
132157
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
133158
}
159+
160+
// ── merge-NotFound (TOCTOU) arm coverage ──────────────────────────────────────
161+
//
162+
// The UpdateEnv handler maps a models.ErrStackNotFound out of MergeStackEnvVars
163+
// to a 404 — the row vanished between GetStackBySlug and the merge tx's
164+
// SELECT ... FOR UPDATE (a genuine TOCTOU that can't be timed deterministically
165+
// against a live row). To exercise that handler arm deterministically we proxy
166+
// the real pq driver and return ZERO rows ONLY for the merge's
167+
// `SELECT ... FOR UPDATE` query: GetStackBySlug (no FOR UPDATE) still finds the
168+
// row, so the handler reaches the merge, whose SELECT then sees no rows →
169+
// ErrStackNotFound → 404 not_found.
170+
171+
type forUpdateVanishConn struct{ inner driver.Conn }
172+
173+
func (c *forUpdateVanishConn) Prepare(q string) (driver.Stmt, error) { return c.inner.Prepare(q) }
174+
func (c *forUpdateVanishConn) Close() error { return c.inner.Close() }
175+
func (c *forUpdateVanishConn) Begin() (driver.Tx, error) { return c.inner.Begin() } //nolint:staticcheck
176+
177+
func (c *forUpdateVanishConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
178+
if bt, ok := c.inner.(driver.ConnBeginTx); ok {
179+
return bt.BeginTx(ctx, opts)
180+
}
181+
return c.inner.Begin() //nolint:staticcheck
182+
}
183+
184+
func (c *forUpdateVanishConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
185+
if strings.Contains(query, "FOR UPDATE") {
186+
// Simulate the row vanishing: return an empty result set so
187+
// MergeStackEnvVars' Scan yields sql.ErrNoRows → ErrStackNotFound.
188+
return &emptyRows{}, nil
189+
}
190+
if qc, ok := c.inner.(driver.QueryerContext); ok {
191+
return qc.QueryContext(ctx, query, args)
192+
}
193+
return nil, driver.ErrSkip
194+
}
195+
196+
func (c *forUpdateVanishConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
197+
if ec, ok := c.inner.(driver.ExecerContext); ok {
198+
return ec.ExecContext(ctx, query, args)
199+
}
200+
return nil, driver.ErrSkip
201+
}
202+
203+
// emptyRows is a zero-row driver.Rows for the single env_vars column.
204+
type emptyRows struct{}
205+
206+
func (*emptyRows) Columns() []string { return []string{"coalesce"} }
207+
func (*emptyRows) Close() error { return nil }
208+
func (*emptyRows) Next(_ []driver.Value) error { return io.EOF }
209+
210+
type forUpdateVanishDriver struct{ dsn string }
211+
212+
func (d *forUpdateVanishDriver) Open(_ string) (driver.Conn, error) {
213+
inner, err := pq.Open(d.dsn)
214+
if err != nil {
215+
return nil, err
216+
}
217+
return &forUpdateVanishConn{inner: inner}, nil
218+
}
219+
220+
var fuvRegMu sync.Mutex
221+
var fuvRegN int
222+
223+
func openForUpdateVanishDB(t *testing.T) *sql.DB {
224+
t.Helper()
225+
dsn := os.Getenv("TEST_DATABASE_URL")
226+
if dsn == "" {
227+
t.Skip("TEST_DATABASE_URL not set")
228+
}
229+
fuvRegMu.Lock()
230+
fuvRegN++
231+
name := "fuvpq_" + itoaFault(fuvRegN)
232+
sql.Register(name, &forUpdateVanishDriver{dsn: dsn})
233+
fuvRegMu.Unlock()
234+
db, err := sql.Open(name, dsn)
235+
require.NoError(t, err)
236+
db.SetMaxOpenConns(1)
237+
db.SetMaxIdleConns(1)
238+
t.Cleanup(func() { _ = db.Close() })
239+
return db
240+
}
241+
242+
// TestStackFinal2_UpdateEnv_MergeRowVanished_404 covers the handler's
243+
// ErrStackNotFound→404 mapping on the atomic merge: GetStackBySlug finds the
244+
// row, but the merge tx's SELECT ... FOR UPDATE sees none (simulated vanish).
245+
func TestStackFinal2_UpdateEnv_MergeRowVanished_404(t *testing.T) {
246+
stackNeedDB(t)
247+
seedDB, clean := testhelpers.SetupTestDB(t)
248+
defer clean()
249+
teamIDStr := testhelpers.MustCreateTeamDB(t, seedDB, "pro")
250+
teamID := uuid.MustParse(teamIDStr)
251+
_, slug := seedStack(t, seedDB, &teamID, "healthy")
252+
jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamIDStr, "vanish@example.com")
253+
254+
app := stackFaultApp(t, openForUpdateVanishDB(t))
255+
status, body := patchStackEnvF2(t, app, slug, jwt, `{"env":{"FOO":"bar"}}`)
256+
assert.Equal(t, http.StatusNotFound, status)
257+
assert.Contains(t, body, "not_found")
258+
}

internal/models/coverage_stack_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,90 @@ func TestUpdateStackEnvVars_Branches(t *testing.T) {
327327
mock3.ExpectExec(`UPDATE stacks SET env_vars`).WillReturnError(errors.New("boom"))
328328
require.ErrorContains(t, UpdateStackEnvVars(ctx, db3, uuid.New(), map[string]string{"a": "b"}), "boom")
329329
}
330+
331+
// TestMergeStackEnvVars_Branches exercises every error/return arm of the atomic
332+
// PATCH merge with sqlmock: BeginTx error, select→NotFound, select error,
333+
// unmarshal error, over-cap rollback, update error, commit error, and the happy
334+
// path (upsert + present-key delete counting). The real-DB serialization proof
335+
// lives in handlers' TestMergeStackEnvVars_ConcurrentPatchesNoLostUpdate.
336+
func TestMergeStackEnvVars_Branches(t *testing.T) {
337+
ctx := context.Background()
338+
id := uuid.New()
339+
patch := map[string]string{"A": "1"}
340+
341+
// 1) BeginTx error.
342+
dbB, mockB := newMock(t)
343+
mockB.ExpectBegin().WillReturnError(errors.New("begin-boom"))
344+
_, _, err := MergeStackEnvVars(ctx, dbB, id, patch)
345+
require.ErrorContains(t, err, "begin-boom")
346+
347+
// 2) SELECT ... FOR UPDATE → no rows → ErrStackNotFound (rolls back).
348+
dbNF, mockNF := newMock(t)
349+
mockNF.ExpectBegin()
350+
mockNF.ExpectQuery(`SELECT COALESCE\(env_vars.*FOR UPDATE`).WillReturnError(errNoRows())
351+
mockNF.ExpectRollback()
352+
var nf *ErrStackNotFound
353+
_, _, err = MergeStackEnvVars(ctx, dbNF, id, patch)
354+
require.ErrorAs(t, err, &nf)
355+
356+
// 3) SELECT error (non-NoRows).
357+
dbSE, mockSE := newMock(t)
358+
mockSE.ExpectBegin()
359+
mockSE.ExpectQuery(`SELECT COALESCE\(env_vars.*FOR UPDATE`).WillReturnError(errors.New("sel-boom"))
360+
mockSE.ExpectRollback()
361+
_, _, err = MergeStackEnvVars(ctx, dbSE, id, patch)
362+
require.ErrorContains(t, err, "sel-boom")
363+
364+
// 4) unmarshal error (malformed jsonb).
365+
dbUM, mockUM := newMock(t)
366+
mockUM.ExpectBegin()
367+
mockUM.ExpectQuery(`SELECT COALESCE\(env_vars.*FOR UPDATE`).
368+
WillReturnRows(sqlmock.NewRows([]string{"env_vars"}).AddRow([]byte(`not json`)))
369+
mockUM.ExpectRollback()
370+
_, _, err = MergeStackEnvVars(ctx, dbUM, id, patch)
371+
require.ErrorContains(t, err, "unmarshal")
372+
373+
// 5) over-cap → ErrStackEnvVarsTooLarge, checked BEFORE the UPDATE (rolls back).
374+
dbTL, mockTL := newMock(t)
375+
mockTL.ExpectBegin()
376+
mockTL.ExpectQuery(`SELECT COALESCE\(env_vars.*FOR UPDATE`).
377+
WillReturnRows(sqlmock.NewRows([]string{"env_vars"}).AddRow([]byte(`{}`)))
378+
mockTL.ExpectRollback()
379+
big := map[string]string{"K": strings.Repeat("x", maxStackEnvVarsBytes+1)}
380+
_, _, err = MergeStackEnvVars(ctx, dbTL, id, big)
381+
require.ErrorIs(t, err, ErrStackEnvVarsTooLarge)
382+
383+
// 6) UPDATE error.
384+
dbUE, mockUE := newMock(t)
385+
mockUE.ExpectBegin()
386+
mockUE.ExpectQuery(`SELECT COALESCE\(env_vars.*FOR UPDATE`).
387+
WillReturnRows(sqlmock.NewRows([]string{"env_vars"}).AddRow([]byte(`{}`)))
388+
mockUE.ExpectExec(`UPDATE stacks SET env_vars`).WillReturnError(errors.New("upd-boom"))
389+
mockUE.ExpectRollback()
390+
_, _, err = MergeStackEnvVars(ctx, dbUE, id, patch)
391+
require.ErrorContains(t, err, "upd-boom")
392+
393+
// 7) Commit error.
394+
dbCE, mockCE := newMock(t)
395+
mockCE.ExpectBegin()
396+
mockCE.ExpectQuery(`SELECT COALESCE\(env_vars.*FOR UPDATE`).
397+
WillReturnRows(sqlmock.NewRows([]string{"env_vars"}).AddRow([]byte(`{}`)))
398+
mockCE.ExpectExec(`UPDATE stacks SET env_vars`).WillReturnResult(sqlmock.NewResult(0, 1))
399+
mockCE.ExpectCommit().WillReturnError(errors.New("commit-boom"))
400+
_, _, err = MergeStackEnvVars(ctx, dbCE, id, patch)
401+
require.ErrorContains(t, err, "commit-boom")
402+
403+
// 8) Happy path: existing {A:old, B:keep}; patch upserts A, adds C, deletes B
404+
// (present → counted) and deletes MISSING (absent → NOT counted).
405+
dbOK, mockOK := newMock(t)
406+
mockOK.ExpectBegin()
407+
mockOK.ExpectQuery(`SELECT COALESCE\(env_vars.*FOR UPDATE`).
408+
WillReturnRows(sqlmock.NewRows([]string{"env_vars"}).AddRow([]byte(`{"A":"old","B":"keep"}`)))
409+
mockOK.ExpectExec(`UPDATE stacks SET env_vars`).WillReturnResult(sqlmock.NewResult(0, 1))
410+
mockOK.ExpectCommit()
411+
merged, deletes, err := MergeStackEnvVars(ctx, dbOK, id,
412+
map[string]string{"A": "new", "C": "3", "B": "", "MISSING": ""})
413+
require.NoError(t, err)
414+
require.Equal(t, 1, deletes, "only the present key B counts as a delete")
415+
require.Equal(t, map[string]string{"A": "new", "C": "3"}, merged)
416+
}

0 commit comments

Comments
 (0)