201 lines
7.9 KiB
Go
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")
|
|
}
|