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") }