Skip to content

Commit 4089202

Browse files
committed
fix(pools): atomic create-with-target and schema-valid set-auth-reset response
1 parent d26a3f0 commit 4089202

10 files changed

Lines changed: 114 additions & 80 deletions

File tree

cmd/sluice/cred_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2573,7 +2573,7 @@ func TestFinding3Round9_StoreGatedVaultDeleteOnLivePoolMember(t *testing.T) {
25732573
if err := db.AddCredentialMeta("pool_mem", "oauth", "https://auth.example.com/token"); err != nil {
25742574
t.Fatalf("AddCredentialMeta: %v", err)
25752575
}
2576-
if err := db.CreatePoolWithMembers("codex_pool", "failover", []string{"pool_mem"}); err != nil {
2576+
if err := db.CreatePoolWithMembers("codex_pool", "failover", []string{"pool_mem"}, ""); err != nil {
25772577
t.Fatalf("CreatePoolWithMembers: %v", err)
25782578
}
25792579
_ = db.Close()
@@ -2692,7 +2692,7 @@ func TestFinding3Round9_TOCTOUInterleaveStoreGatesVaultDelete(t *testing.T) {
26922692
if e != nil {
26932693
return
26942694
}
2695-
_ = pdb.CreatePoolWithMembers("codex_pool", "failover", []string{"racer"})
2695+
_ = pdb.CreatePoolWithMembers("codex_pool", "failover", []string{"racer"}, "")
26962696
_ = pdb.Close()
26972697
}()
26982698

cmd/sluice/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1778,7 +1778,7 @@ func seedRecoveryPool(t *testing.T, db *store.Store, members []string) {
17781778
t.Fatalf("add credential meta %q: %v", m, err)
17791779
}
17801780
}
1781-
if err := db.CreatePoolWithMembers(pool, "", members); err != nil {
1781+
if err := db.CreatePoolWithMembers(pool, "", members, ""); err != nil {
17821782
t.Fatalf("create pool %q: %v", pool, err)
17831783
}
17841784
}

cmd/sluice/pool_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ func TestPoolRotateGuardedAgainstConcurrentRemoval(t *testing.T) {
502502
_ = db.Close()
503503
t.Fatalf("RemovePool: %v", rerr)
504504
}
505-
if cerr := db.CreatePoolWithMembers("codex", "failover", []string{"acct_a"}); cerr != nil {
505+
if cerr := db.CreatePoolWithMembers("codex", "failover", []string{"acct_a"}, ""); cerr != nil {
506506
_ = db.Close()
507507
t.Fatalf("recreate pool: %v", cerr)
508508
}
@@ -534,7 +534,7 @@ func TestPoolRotateGuardedAgainstConcurrentRemoval(t *testing.T) {
534534
if _, rerr := db.RemovePool("codex"); rerr != nil {
535535
t.Fatalf("final RemovePool: %v", rerr)
536536
}
537-
if cerr := db.CreatePoolWithMembers("codex", "failover", []string{"acct_a", "acct_b"}); cerr != nil {
537+
if cerr := db.CreatePoolWithMembers("codex", "failover", []string{"acct_a", "acct_b"}, ""); cerr != nil {
538538
t.Fatalf("final recreate pool: %v", cerr)
539539
}
540540
rows, lerr := db.ListCredentialHealth()
@@ -607,7 +607,7 @@ func TestPoolRotateEpochScopedRejectsCrossPoolReAdd(t *testing.T) {
607607
_ = db.Close()
608608
t.Fatalf("RemovePool(P): %v", rerr)
609609
}
610-
if cerr := db.CreatePoolWithMembers("Q", "failover", []string{"c", "d"}); cerr != nil {
610+
if cerr := db.CreatePoolWithMembers("Q", "failover", []string{"c", "d"}, ""); cerr != nil {
611611
_ = db.Close()
612612
t.Fatalf("recreate c,d into Q: %v", cerr)
613613
}

internal/api/server.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,9 +1856,10 @@ func (s *Server) PostApiPools(w http.ResponseWriter, r *http.Request) { //nolint
18561856
effectiveStrategy = store.PoolStrategyFailover
18571857
}
18581858
out := storePoolToAPI(store.Pool{
1859-
Name: req.Name,
1860-
Strategy: effectiveStrategy,
1861-
Members: membersToStorePoolMembers(req.Members),
1859+
Name: req.Name,
1860+
Strategy: effectiveStrategy,
1861+
Members: membersToStorePoolMembers(req.Members),
1862+
AuthResetTarget: authResetTarget,
18621863
})
18631864
if p, err := s.store.GetPool(req.Name); err == nil && p != nil {
18641865
out = storePoolToAPI(*p)
@@ -1922,7 +1923,11 @@ func (s *Server) PostApiPoolsNameRotate(w http.ResponseWriter, _ *http.Request,
19221923
// routing through poolops.SetAuthResetTarget so the three surfaces cannot
19231924
// drift (channel feature-parity principle). A NUL/newline in the target is a
19241925
// 400 (poolops.ErrInvalidAuthResetTarget); an unknown pool is 404. On success
1925-
// the updated pool is returned so the caller sees the persisted value.
1926+
// the updated pool is returned (200). If the post-write read-back fails the
1927+
// set still succeeded, so 204 No Content is returned rather than a partial
1928+
// Pool object: the OpenAPI Pool schema requires name+strategy+members, and the
1929+
// request body alone cannot reconstruct strategy/members, so echoing it would
1930+
// emit a schema-invalid response.
19261931
func (s *Server) PostApiPoolsNameAuthResetTarget(w http.ResponseWriter, r *http.Request, name string) { //nolint:revive // generated interface name
19271932
var req SetPoolAuthResetTargetRequest
19281933
if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil {
@@ -1937,14 +1942,15 @@ func (s *Server) PostApiPoolsNameAuthResetTarget(w http.ResponseWriter, r *http.
19371942
writeError(w, status, err.Error(), "")
19381943
return
19391944
}
1940-
w.Header().Set("Content-Type", "application/json")
19411945
if p, err := s.store.GetPool(name); err == nil && p != nil {
1946+
w.Header().Set("Content-Type", "application/json")
19421947
_ = json.NewEncoder(w).Encode(storePoolToAPI(*p))
19431948
return
19441949
}
1945-
// The set succeeded; a read-back failure must not report failure. Echo
1946-
// the persisted value from the request instead.
1947-
_ = json.NewEncoder(w).Encode(Pool{Name: name, AuthResetTarget: &req.AuthResetTarget})
1950+
// The set succeeded; only the post-write read-back failed. Returning a
1951+
// partial Pool (name + target, missing the required strategy/members)
1952+
// would violate the OpenAPI schema, so report success with no body.
1953+
w.WriteHeader(http.StatusNoContent)
19481954
}
19491955

19501956
// DeleteApiPoolsName removes a pool. It refuses (409) while any binding still

internal/api/server_test.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,7 +1561,7 @@ func TestDeleteApiCredentials_PoolGuardVsStoreFault(t *testing.T) {
15611561
t.Fatalf("seed oauth cred %q: %v", n, err)
15621562
}
15631563
}
1564-
if err := st.CreatePoolWithMembers("p", "failover", []string{"m", "n"}); err != nil {
1564+
if err := st.CreatePoolWithMembers("p", "failover", []string{"m", "n"}, ""); err != nil {
15651565
t.Fatalf("create pool: %v", err)
15661566
}
15671567

@@ -2801,7 +2801,7 @@ func TestPostApiPools_DuplicateName(t *testing.T) {
28012801
st := newTestStore(t)
28022802
enableHTTPChannel(t, st)
28032803
seedOAuthCred(t, st, "credA", "credB")
2804-
if err := st.CreatePoolWithMembers("dup_pool", store.PoolStrategyFailover, []string{"credA"}); err != nil {
2804+
if err := st.CreatePoolWithMembers("dup_pool", store.PoolStrategyFailover, []string{"credA"}, ""); err != nil {
28052805
t.Fatalf("seed pool: %v", err)
28062806
}
28072807
srv := api.NewServer(st, nil, nil, "")
@@ -2851,7 +2851,7 @@ func TestPostApiPools_MemberAlreadyPooled(t *testing.T) {
28512851
st := newTestStore(t)
28522852
enableHTTPChannel(t, st)
28532853
seedOAuthCred(t, st, "credA", "credB")
2854-
if err := st.CreatePoolWithMembers("pool_one", store.PoolStrategyFailover, []string{"credA"}); err != nil {
2854+
if err := st.CreatePoolWithMembers("pool_one", store.PoolStrategyFailover, []string{"credA"}, ""); err != nil {
28552855
t.Fatalf("seed pool: %v", err)
28562856
}
28572857
srv := api.NewServer(st, nil, nil, "")
@@ -2955,7 +2955,7 @@ func TestGetApiPoolsName_Status(t *testing.T) {
29552955
st := newTestStore(t)
29562956
enableHTTPChannel(t, st)
29572957
seedOAuthCred(t, st, "credA", "credB")
2958-
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}); err != nil {
2958+
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}, ""); err != nil {
29592959
t.Fatalf("create pool: %v", err)
29602960
}
29612961
srv := api.NewServer(st, nil, nil, "")
@@ -3011,7 +3011,7 @@ func TestPostApiPoolsNameRotate_Success(t *testing.T) {
30113011
st := newTestStore(t)
30123012
enableHTTPChannel(t, st)
30133013
seedOAuthCred(t, st, "credA", "credB")
3014-
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}); err != nil {
3014+
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}, ""); err != nil {
30153015
t.Fatalf("create pool: %v", err)
30163016
}
30173017
srv := api.NewServer(st, nil, nil, "")
@@ -3051,7 +3051,7 @@ func TestPostApiPoolsNameAuthResetTarget(t *testing.T) {
30513051
st := newTestStore(t)
30523052
enableHTTPChannel(t, st)
30533053
seedOAuthCred(t, st, "credA", "credB")
3054-
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}); err != nil {
3054+
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}, ""); err != nil {
30553055
t.Fatalf("create pool: %v", err)
30563056
}
30573057
srv := api.NewServer(st, nil, nil, "")
@@ -3067,7 +3067,12 @@ func TestPostApiPoolsNameAuthResetTarget(t *testing.T) {
30673067
return rec
30683068
}
30693069

3070-
// Set a target.
3070+
// Set a target. The 200 path returns the full, schema-complete pool via a
3071+
// read-back: name+strategy+members are all required by the OpenAPI Pool
3072+
// schema. (The read-back-failure fallback returns 204 No Content rather
3073+
// than a partial Pool that would violate that schema — Copilot #2 — but
3074+
// that path is not reachable here without failing the store mid-handler,
3075+
// which the concrete *store.Store gives no seam for.)
30713076
rec := post(`{"auth_reset_target": "openai-codex"}`)
30723077
if rec.Code != http.StatusOK {
30733078
t.Fatalf("set: expected 200, got %d: %s", rec.Code, rec.Body.String())
@@ -3079,6 +3084,16 @@ func TestPostApiPoolsNameAuthResetTarget(t *testing.T) {
30793084
if p.AuthResetTarget == nil || *p.AuthResetTarget != "openai-codex" {
30803085
t.Fatalf("response AuthResetTarget = %v, want openai-codex", p.AuthResetTarget)
30813086
}
3087+
// Schema completeness: the OpenAPI Pool schema requires name+strategy+members.
3088+
if p.Name != "pool1" {
3089+
t.Errorf("200 body name = %q, want pool1", p.Name)
3090+
}
3091+
if p.Strategy != store.PoolStrategyFailover {
3092+
t.Errorf("200 body strategy = %q, want %q", p.Strategy, store.PoolStrategyFailover)
3093+
}
3094+
if len(p.Members) != 2 {
3095+
t.Fatalf("200 body members = %+v, want 2 (credA, credB)", p.Members)
3096+
}
30823097
// Reached the store (no inline logic; routed through poolops).
30833098
got, err := st.GetPool("pool1")
30843099
if err != nil {
@@ -3147,6 +3162,16 @@ func TestPostApiPools_WithAuthResetTarget(t *testing.T) {
31473162
if rec.Code != http.StatusCreated {
31483163
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
31493164
}
3165+
// The 201 response body must reflect the configured target. The synthetic
3166+
// store.Pool the handler builds when the read-back is skipped/fails used to
3167+
// omit it (Copilot #1), so assert it round-trips through the JSON body.
3168+
var p api.Pool
3169+
if err := json.NewDecoder(rec.Body).Decode(&p); err != nil {
3170+
t.Fatalf("decode 201 body: %v", err)
3171+
}
3172+
if p.AuthResetTarget == nil || *p.AuthResetTarget != "openai-codex" {
3173+
t.Fatalf("201 body AuthResetTarget = %v, want openai-codex", p.AuthResetTarget)
3174+
}
31503175
got, err := st.GetPool("codex")
31513176
if err != nil {
31523177
t.Fatalf("GetPool: %v", err)
@@ -3178,7 +3203,7 @@ func TestDeleteApiPoolsName_Success(t *testing.T) {
31783203
st := newTestStore(t)
31793204
enableHTTPChannel(t, st)
31803205
seedOAuthCred(t, st, "credA", "credB")
3181-
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}); err != nil {
3206+
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}, ""); err != nil {
31823207
t.Fatalf("create pool: %v", err)
31833208
}
31843209
srv := api.NewServer(st, nil, nil, "")
@@ -3225,7 +3250,7 @@ func TestDeleteApiPoolsName_ReferencedByBinding(t *testing.T) {
32253250
st := newTestStore(t)
32263251
enableHTTPChannel(t, st)
32273252
seedOAuthCred(t, st, "credA", "credB")
3228-
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}); err != nil {
3253+
if err := st.CreatePoolWithMembers("pool1", store.PoolStrategyFailover, []string{"credA", "credB"}, ""); err != nil {
32293254
t.Fatalf("create pool: %v", err)
32303255
}
32313256
// A binding referencing the pool by name keeps it from being removed.

internal/poolops/poolops.go

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424
// interface lets each channel pass its own already-open store and lets the
2525
// tests substitute a fake.
2626
type Store interface {
27-
CreatePoolWithMembers(name, strategy string, members []string) error
27+
CreatePoolWithMembers(name, strategy string, members []string, authResetTarget string) error
2828
GetPool(name string) (*store.Pool, error)
2929
ListPools() ([]store.Pool, error)
3030
RemovePoolIfUnreferenced(name string) (bool, error)
@@ -144,10 +144,13 @@ func Create(s Store, name, strategy string, members []string) error {
144144
}
145145

146146
// CreateWithAuthResetTarget is Create plus an optional per-pool
147-
// auth_reset_target (empty = no reset). The target is set in a follow-up
148-
// SetPoolAuthResetTarget call after the pool exists; channels that don't
149-
// accept a target call Create. Used by every channel's create adapter so the
150-
// create-with-target path has a single source of truth.
147+
// auth_reset_target (empty = no reset). The target is bound in the same store
148+
// transaction that creates the pool and its members, so create-with-target is
149+
// atomic: a partial state where the pool exists without its target can never
150+
// be observed, and there is no second write whose failure would leave a
151+
// created pool plus an error (which a retry would then 409 on). Channels that
152+
// don't accept a target call Create, which threads "". Used by every channel's
153+
// create adapter so the create-with-target path has a single source of truth.
151154
func CreateWithAuthResetTarget(s Store, name, strategy string, members []string, authResetTarget string) error {
152155
if strategy == "" {
153156
strategy = store.PoolStrategyFailover
@@ -158,15 +161,7 @@ func CreateWithAuthResetTarget(s Store, name, strategy string, members []string,
158161
if err := validateAuthResetTarget(authResetTarget); err != nil {
159162
return err
160163
}
161-
if err := s.CreatePoolWithMembers(name, strategy, members); err != nil {
162-
return err
163-
}
164-
if authResetTarget != "" {
165-
if err := s.SetPoolAuthResetTarget(name, authResetTarget); err != nil {
166-
return err
167-
}
168-
}
169-
return nil
164+
return s.CreatePoolWithMembers(name, strategy, members, authResetTarget)
170165
}
171166

172167
// SetAuthResetTarget sets (target != "") or clears (target == "") the

internal/poolops/poolops_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ func TestCreateListStatusRotateRemove(t *testing.T) {
109109
}
110110
}
111111

112+
// TestCreateWithAuthResetTarget asserts create-with-target persists the target
113+
// in the single CreatePoolWithMembers call (the target is bound in the same
114+
// store transaction that creates the pool, not a separate follow-up
115+
// SetPoolAuthResetTarget write — Copilot #5). Both the GetPool read-back and
116+
// the derived Status reflect the configured target.
112117
func TestCreateWithAuthResetTarget(t *testing.T) {
113118
db := newTestStore(t, "acct_a", "acct_b")
114119

internal/store/pools.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,10 @@ func assertCredentialNotInAnotherPoolTx(tx *sql.Tx, credential, newPool string)
196196
// existing oauth credential with a token_url. At least two members are
197197
// required for failover to be meaningful, but a single-member pool is
198198
// permitted (it degrades to a plain indirection with no failover target).
199-
func (s *Store) CreatePoolWithMembers(name, strategy string, members []string) error {
199+
// authResetTarget is stored verbatim in the same transaction (empty = none),
200+
// so create-with-target is atomic: there is no window where the pool exists
201+
// without its configured target.
202+
func (s *Store) CreatePoolWithMembers(name, strategy string, members []string, authResetTarget string) error {
200203
if name == "" {
201204
return fmt.Errorf("%w: pool name is required", ErrPoolNoMembers)
202205
}
@@ -253,7 +256,7 @@ func (s *Store) CreatePoolWithMembers(name, strategy string, members []string) e
253256
}
254257

255258
if _, err := tx.Exec(
256-
"INSERT INTO credential_pools (name, strategy, auth_reset_target) VALUES (?, ?, '')", name, strategy,
259+
"INSERT INTO credential_pools (name, strategy, auth_reset_target) VALUES (?, ?, ?)", name, strategy, authResetTarget,
257260
); err != nil {
258261
return fmt.Errorf("insert pool %q: %w", name, err)
259262
}

0 commit comments

Comments
 (0)