diff --git a/internal/api/handlers/management/auth_files_delete_test.go b/internal/api/handlers/management/auth_files_delete_test.go index 7b7b888c4b..4a60a739c7 100644 --- a/internal/api/handlers/management/auth_files_delete_test.go +++ b/internal/api/handlers/management/auth_files_delete_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -127,3 +128,76 @@ func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) { t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat) } } + +func TestDeleteAuthFile_AllowsReaddingSameNameWithoutStaleState(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + authDir := t.TempDir() + fileName := "codex-readd.json" + filePath := filepath.Join(authDir, fileName) + fileData := []byte(`{"type":"codex","email":"readd@example.com"}`) + if errWrite := os.WriteFile(filePath, fileData, 0o600); errWrite != nil { + t.Fatalf("failed to write auth file: %v", errWrite) + } + + store := &memoryAuthStore{} + manager := coreauth.NewManager(store, nil, nil) + authID := fileName + if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{ + ID: authID, + FileName: fileName, + Provider: "codex", + Metadata: map[string]any{"type": "codex", "email": "old@example.com"}, + Attributes: map[string]string{ + "path": filePath, + }, + ModelStates: map[string]*coreauth.ModelState{ + "gpt-5.3-codex": { + Status: coreauth.StatusError, + Unavailable: true, + NextRetryAfter: time.Now().Add(30 * time.Minute), + }, + }, + }); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager) + h.tokenStore = store + + deleteRec := httptest.NewRecorder() + deleteCtx, _ := gin.CreateTestContext(deleteRec) + deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil) + deleteCtx.Request = deleteReq + h.DeleteAuthFile(deleteCtx) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String()) + } + disabled, ok := manager.GetByID(authID) + if !ok || disabled == nil { + t.Fatal("expected removed auth to stay in manager as disabled entry") + } + if !disabled.Disabled || disabled.Status != coreauth.StatusDisabled { + t.Fatalf("expected removed auth to be disabled, got disabled=%v status=%s", disabled.Disabled, disabled.Status) + } + + if errWrite := os.WriteFile(filePath, fileData, 0o600); errWrite != nil { + t.Fatalf("failed to recreate auth file: %v", errWrite) + } + if errRegister := h.registerAuthFromFile(context.Background(), filePath, fileData); errRegister != nil { + t.Fatalf("re-register auth from file: %v", errRegister) + } + + updated, ok := manager.GetByID(authID) + if !ok || updated == nil { + t.Fatal("expected re-added auth to exist") + } + if updated.Disabled || updated.Status == coreauth.StatusDisabled { + t.Fatalf("expected re-added auth to be active, got disabled=%v status=%s", updated.Disabled, updated.Status) + } + if len(updated.ModelStates) != 0 { + t.Fatalf("expected stale model state to be cleared, got %#v", updated.ModelStates) + } +} diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index ae5b745c98..a2406455f0 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -468,7 +468,7 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) { auth.Index = existing.Index auth.indexAssigned = existing.indexAssigned } - if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 { + if shouldPreserveModelStatesOnUpdate(existing) && len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 { auth.ModelStates = existing.ModelStates } } @@ -481,6 +481,16 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) { return auth.Clone(), nil } +func shouldPreserveModelStatesOnUpdate(existing *Auth) bool { + if existing == nil { + return false + } + if existing.Disabled || existing.Status == StatusDisabled { + return false + } + return true +} + // Load resets manager state from the backing store. func (m *Manager) Load(ctx context.Context) error { m.mu.Lock() diff --git a/sdk/cliproxy/auth/conductor_update_test.go b/sdk/cliproxy/auth/conductor_update_test.go index f058f51713..4a5a0cbe0f 100644 --- a/sdk/cliproxy/auth/conductor_update_test.go +++ b/sdk/cliproxy/auth/conductor_update_test.go @@ -3,6 +3,7 @@ package auth import ( "context" "testing" + "time" ) func TestManager_Update_PreservesModelStates(t *testing.T) { @@ -47,3 +48,50 @@ func TestManager_Update_PreservesModelStates(t *testing.T) { t.Fatalf("expected BackoffLevel to be %d, got %d", backoffLevel, state.Quota.BackoffLevel) } } + +func TestManager_Update_DoesNotPreserveModelStatesFromDisabledAuth(t *testing.T) { + m := NewManager(nil, nil, nil) + model := "test-model" + + if _, errRegister := m.Register(context.Background(), &Auth{ + ID: "auth-1", + Provider: "codex", + Metadata: map[string]any{"type": "codex"}, + ModelStates: map[string]*ModelState{ + model: { + Status: StatusError, + Unavailable: true, + NextRetryAfter: time.Now().Add(30 * time.Minute), + }, + }, + }); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + if _, errUpdate := m.Update(context.Background(), &Auth{ + ID: "auth-1", + Provider: "codex", + Metadata: map[string]any{"type": "codex"}, + Disabled: true, + Status: StatusDisabled, + StatusMessage: "removed via management API", + }); errUpdate != nil { + t.Fatalf("disable auth: %v", errUpdate) + } + + if _, errUpdate := m.Update(context.Background(), &Auth{ + ID: "auth-1", + Provider: "codex", + Metadata: map[string]any{"type": "codex"}, + }); errUpdate != nil { + t.Fatalf("re-enable auth: %v", errUpdate) + } + + updated, ok := m.GetByID("auth-1") + if !ok || updated == nil { + t.Fatalf("expected recreated auth to be present") + } + if len(updated.ModelStates) != 0 { + t.Fatalf("expected stale ModelStates to be cleared, got %#v", updated.ModelStates) + } +}