fix(handlers): add base URL validation and improve API key deletion tests

This commit is contained in:
Luis Pater
2026-04-09 20:51:54 +08:00
parent 730809d8ea
commit 1dba2d0f81
3 changed files with 300 additions and 24 deletions

View File

@@ -214,19 +214,46 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
for _, v := range h.cfg.GeminiKey {
if v.APIKey != val {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
for _, v := range h.cfg.GeminiKey {
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
continue
}
out = append(out, v)
}
if len(out) != len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = out
h.cfg.SanitizeGeminiKeys()
h.persist(c)
} else {
c.JSON(404, gin.H{"error": "item not found"})
}
return
}
if len(out) != len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = out
h.cfg.SanitizeGeminiKeys()
h.persist(c)
} else {
matchIndex := -1
matchCount := 0
for i := range h.cfg.GeminiKey {
if strings.TrimSpace(h.cfg.GeminiKey[i].APIKey) == val {
matchCount++
if matchIndex == -1 {
matchIndex = i
}
}
}
if matchCount == 0 {
c.JSON(404, gin.H{"error": "item not found"})
return
}
if matchCount > 1 {
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
return
}
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...)
h.cfg.SanitizeGeminiKeys()
h.persist(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -335,14 +362,39 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
}
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
if val := c.Query("api-key"); val != "" {
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
for _, v := range h.cfg.ClaudeKey {
if v.APIKey != val {
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
for _, v := range h.cfg.ClaudeKey {
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
continue
}
out = append(out, v)
}
h.cfg.ClaudeKey = out
h.cfg.SanitizeClaudeKeys()
h.persist(c)
return
}
matchIndex := -1
matchCount := 0
for i := range h.cfg.ClaudeKey {
if strings.TrimSpace(h.cfg.ClaudeKey[i].APIKey) == val {
matchCount++
if matchIndex == -1 {
matchIndex = i
}
}
}
if matchCount > 1 {
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
return
}
if matchIndex != -1 {
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...)
}
h.cfg.ClaudeKey = out
h.cfg.SanitizeClaudeKeys()
h.persist(c)
return
@@ -601,13 +653,38 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
for _, v := range h.cfg.VertexCompatAPIKey {
if v.APIKey != val {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
for _, v := range h.cfg.VertexCompatAPIKey {
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
continue
}
out = append(out, v)
}
h.cfg.VertexCompatAPIKey = out
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
return
}
matchIndex := -1
matchCount := 0
for i := range h.cfg.VertexCompatAPIKey {
if strings.TrimSpace(h.cfg.VertexCompatAPIKey[i].APIKey) == val {
matchCount++
if matchIndex == -1 {
matchIndex = i
}
}
}
if matchCount > 1 {
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
return
}
if matchIndex != -1 {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...)
}
h.cfg.VertexCompatAPIKey = out
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
return
@@ -915,14 +992,39 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
}
func (h *Handler) DeleteCodexKey(c *gin.Context) {
if val := c.Query("api-key"); val != "" {
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
for _, v := range h.cfg.CodexKey {
if v.APIKey != val {
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
for _, v := range h.cfg.CodexKey {
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
continue
}
out = append(out, v)
}
h.cfg.CodexKey = out
h.cfg.SanitizeCodexKeys()
h.persist(c)
return
}
matchIndex := -1
matchCount := 0
for i := range h.cfg.CodexKey {
if strings.TrimSpace(h.cfg.CodexKey[i].APIKey) == val {
matchCount++
if matchIndex == -1 {
matchIndex = i
}
}
}
if matchCount > 1 {
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
return
}
if matchIndex != -1 {
h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...)
}
h.cfg.CodexKey = out
h.cfg.SanitizeCodexKeys()
h.persist(c)
return

View File

@@ -0,0 +1,172 @@
package management
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
func writeTestConfigFile(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if errWrite := os.WriteFile(path, []byte("{}\n"), 0o600); errWrite != nil {
t.Fatalf("failed to write test config: %v", errWrite)
}
return path
}
func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
GeminiKey: []config.GeminiKey{
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key", nil)
h.DeleteGeminiKey(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if got := len(h.cfg.GeminiKey); got != 2 {
t.Fatalf("gemini keys len = %d, want 2", got)
}
}
func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
GeminiKey: []config.GeminiKey{
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key&base-url=https://a.example.com", nil)
h.DeleteGeminiKey(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := len(h.cfg.GeminiKey); got != 1 {
t.Fatalf("gemini keys len = %d, want 1", got)
}
if got := h.cfg.GeminiKey[0].BaseURL; got != "https://b.example.com" {
t.Fatalf("remaining base-url = %q, want %q", got, "https://b.example.com")
}
}
func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
ClaudeKey: []config.ClaudeKey{
{APIKey: "shared-key", BaseURL: ""},
{APIKey: "shared-key", BaseURL: "https://claude.example.com"},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/claude-api-key?api-key=shared-key&base-url=", nil)
h.DeleteClaudeKey(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := len(h.cfg.ClaudeKey); got != 1 {
t.Fatalf("claude keys len = %d, want 1", got)
}
if got := h.cfg.ClaudeKey[0].BaseURL; got != "https://claude.example.com" {
t.Fatalf("remaining base-url = %q, want %q", got, "https://claude.example.com")
}
}
func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
VertexCompatAPIKey: []config.VertexCompatKey{
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/vertex-api-key?api-key=shared-key&base-url=https://b.example.com", nil)
h.DeleteVertexCompatKey(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := len(h.cfg.VertexCompatAPIKey); got != 1 {
t.Fatalf("vertex keys len = %d, want 1", got)
}
if got := h.cfg.VertexCompatAPIKey[0].BaseURL; got != "https://a.example.com" {
t.Fatalf("remaining base-url = %q, want %q", got, "https://a.example.com")
}
}
func TestDeleteCodexKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
CodexKey: []config.CodexKey{
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/codex-api-key?api-key=shared-key", nil)
h.DeleteCodexKey(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if got := len(h.cfg.CodexKey); got != 2 {
t.Fatalf("codex keys len = %d, want 2", got)
}
}

View File

@@ -865,6 +865,7 @@ func (cfg *Config) SanitizeClaudeKeys() {
}
// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
// It uses API key + base URL as the uniqueness key.
func (cfg *Config) SanitizeGeminiKeys() {
if cfg == nil {
return
@@ -883,10 +884,11 @@ func (cfg *Config) SanitizeGeminiKeys() {
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
if _, exists := seen[entry.APIKey]; exists {
uniqueKey := entry.APIKey + "|" + entry.BaseURL
if _, exists := seen[uniqueKey]; exists {
continue
}
seen[entry.APIKey] = struct{}{}
seen[uniqueKey] = struct{}{}
out = append(out, entry)
}
cfg.GeminiKey = out