Files
gochat/backend/internal/handler/api/v1/copilot_config_handler_test.go
T

201 lines
7.9 KiB
Go

package v1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/middleware"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type copilotConfigHandlerFixture struct {
db *gorm.DB
router *gin.Engine
account *model.Account
}
func newCopilotConfigHandlerFixture(t *testing.T) *copilotConfigHandlerFixture {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{}, &model.InstallationConfig{}, &model.Audit{}))
account := &model.Account{Name: "Copilot Config", Active: true}
require.NoError(t, db.Create(account).Error)
manager := llm.NewProviderManager()
platformService := service.NewCopilotConfigService(repository.NewInstallationConfigRepo(db), manager)
preferenceService := service.NewCaptainPreferenceService(repository.NewCaptainPreferenceRepo(db), repository.NewAccountRepo(db))
preferenceService.SetCopilotConfigService(platformService)
handler := NewCopilotConfigHandler(platformService, preferenceService).
WithAuditService(service.NewAuditService(repository.NewAuditRepo(db)))
router := gin.New()
superAdmin := router.Group("/platform/api/v1/copilot", func(c *gin.Context) {
c.Set("user_type", "super_admin")
c.Set("account_id", account.ID)
c.Next()
}, middleware.SuperAdmin())
superAdmin.GET("/config", handler.PlatformGet)
superAdmin.PUT("/config", handler.PlatformUpdate)
superAdmin.POST("/config/test", handler.PlatformTest)
accountAdmin := router.Group("/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
c.Set("role", "administrator")
c.Next()
})
accountAdmin.GET("", handler.AccountGet)
accountAdmin.PUT("", handler.AccountUpdate)
router.PUT("/forbidden/platform/api/v1/copilot/config", func(c *gin.Context) {
c.Set("user_type", "user")
c.Next()
}, middleware.SuperAdmin(), handler.PlatformUpdate)
router.GET("/forbidden/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
c.Set("role", "agent")
c.Next()
}, handler.AccountGet)
t.Cleanup(func() {
sqlDB, dbErr := db.DB()
require.NoError(t, dbErr)
require.NoError(t, sqlDB.Close())
})
return &copilotConfigHandlerFixture{db: db, router: router, account: account}
}
func (f *copilotConfigHandlerFixture) request(method, path string, body any) *httptest.ResponseRecorder {
var raw []byte
if body != nil {
raw, _ = json.Marshal(body)
}
recorder := httptest.NewRecorder()
req, _ := http.NewRequest(method, path, bytes.NewReader(raw))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
f.router.ServeHTTP(recorder, req)
return recorder
}
func copilotConfigRequest(baseURL, apiKey string) map[string]any {
return map[string]any{
"chat": map[string]any{
"provider": "openai_compatible",
"base_url": baseURL,
"model": "chat-model",
"api_key": apiKey,
},
"embedding": map[string]any{
"mode": "reuse_chat_credentials",
"provider": "openai_compatible",
"base_url": baseURL,
"model": "embedding-model",
"dimensions": 3,
},
"generation": map[string]any{"temperature": 0.2, "max_tokens": 512},
"request": map[string]any{"timeout_seconds": 10, "max_retries": 0},
}
}
func TestCopilotConfigHandlerPlatformPermissionsAndSecretPresentation(t *testing.T) {
f := newCopilotConfigHandlerFixture(t)
input := copilotConfigRequest("https://llm.example.com/v1", "plain-secret-key")
forbidden := f.request(http.MethodPut, "/forbidden/platform/api/v1/copilot/config", input)
require.Equal(t, http.StatusForbidden, forbidden.Code, forbidden.Body.String())
updated := f.request(http.MethodPut, "/platform/api/v1/copilot/config", input)
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
require.NotContains(t, updated.Body.String(), "plain-secret-key")
require.Contains(t, updated.Body.String(), "pla****-key")
require.Contains(t, updated.Body.String(), `"masked_value":"pla****-key"`)
require.NotContains(t, updated.Body.String(), `"masked":`)
var stored model.InstallationConfig
require.NoError(t, f.db.Where("name = ?", "COPILOT_CHAT_API_KEY").First(&stored).Error)
require.Equal(t, "plain-secret-key", stored.Value)
var audit model.Audit
require.NoError(t, f.db.Where("auditable_type = ?", "InstallationConfig").First(&audit).Error)
require.NotContains(t, string(audit.AuditedChanges), "plain-secret-key")
require.NotContains(t, string(audit.AuditedChanges), "pla****-key")
require.Contains(t, string(audit.AuditedChanges), `"api_key_changed":true`)
require.NotNil(t, audit.AccountID)
require.Equal(t, f.account.ID, *audit.AccountID)
require.Equal(t, "Account", audit.AssociatedType)
accountPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(f.account.ID), 10) + "/copilot/config"
accountPayload := f.request(http.MethodGet, accountPath, nil)
require.Equal(t, http.StatusOK, accountPayload.Code, accountPayload.Body.String())
require.NotContains(t, accountPayload.Body.String(), "plain-secret-key")
require.NotContains(t, accountPayload.Body.String(), "pla****-key")
require.NotContains(t, accountPayload.Body.String(), "masked_value")
require.Contains(t, accountPayload.Body.String(), `"configured":true`)
agentPayload := f.request(http.MethodGet, "/forbidden"+accountPath, nil)
require.Equal(t, http.StatusForbidden, agentPayload.Code, agentPayload.Body.String())
}
func TestCopilotConfigHandlerTestEndpointDoesNotPersistCandidate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/chat/completions":
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`))
case "/embeddings":
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embedding-model"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
f := newCopilotConfigHandlerFixture(t)
tested := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", copilotConfigRequest(server.URL, "candidate-key"))
require.Equal(t, http.StatusOK, tested.Code, tested.Body.String())
require.Contains(t, tested.Body.String(), `"ok":true`)
require.NotContains(t, tested.Body.String(), "candidate-key")
current := f.request(http.MethodGet, "/platform/api/v1/copilot/config", nil)
require.Equal(t, http.StatusOK, current.Code, current.Body.String())
require.Contains(t, current.Body.String(), `"configured":false`)
}
func TestCopilotConfigHandlerUsesStandardNotConfiguredError(t *testing.T) {
f := newCopilotConfigHandlerFixture(t)
response := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", map[string]any{})
require.Equal(t, http.StatusConflict, response.Code, response.Body.String())
require.Contains(t, response.Body.String(), "COPILOT_NOT_CONFIGURED")
}
func TestCopilotProviderErrorsAreSanitized(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/provider-error", func(c *gin.Context) {
handleServiceError(c, fmt.Errorf("provider failed: %w", &llm.APIError{
StatusCode: http.StatusUnauthorized,
Message: "invalid key sk-secret-value",
}))
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/provider-error", nil)
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusBadGateway, recorder.Code)
require.Contains(t, recorder.Body.String(), "COPILOT_PROVIDER_AUTHENTICATION_FAILED")
require.NotContains(t, recorder.Body.String(), "sk-secret-value")
}