package gitlab import ( "context" "errors" "fmt" "go/ast" "go/parser" "go/token" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strconv" "strings" "sync/atomic" "testing" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" "github.com/kandev/kandev/internal/common/logger" ) func TestGitLabRequestSurfaceCannotImportWebSocketTransport(t *testing.T) { _, sourceFile, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate GitLab package") } packageDir := filepath.Dir(sourceFile) entries, err := os.ReadDir(packageDir) if err != nil { t.Fatalf("read GitLab package: %v", err) } for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { continue } path := filepath.Join(packageDir, entry.Name()) file, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) if parseErr != nil { t.Fatalf("parse %s: %v", entry.Name(), parseErr) } for _, importSpec := range file.Imports { importPath, unquoteErr := strconv.Unquote(importSpec.Path.Value) if unquoteErr != nil { t.Fatalf("read import in %s: %v", entry.Name(), unquoteErr) } if importPath == "github.com/kandev/kandev/pkg/websocket" { t.Errorf("%s imports the WebSocket request transport; GitLab requests must remain HTTP-only", entry.Name()) } } } } func TestWebSocketCatalogContainsOnlyAuthorizedGitLabActions(t *testing.T) { authorized := map[string]bool{ "ActionGitLabCheckSessionMR": true, "ActionGitLabTaskMRUpdated": true, "ActionGitLabTaskMRAutomationUpdated": true, } _, sourceFile, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate GitLab package") } actionsPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "pkg", "websocket", "actions.go") file, err := parser.ParseFile(token.NewFileSet(), actionsPath, nil, 0) if err != nil { t.Fatalf("parse WebSocket action catalog: %v", err) } found := map[string]bool{} ast.Inspect(file, func(node ast.Node) bool { spec, isValueSpec := node.(*ast.ValueSpec) if !isValueSpec { return true } for _, name := range spec.Names { if !strings.HasPrefix(name.Name, "ActionGitLab") { continue } found[name.Name] = true if !authorized[name.Name] { t.Errorf("legacy GitLab WebSocket action constant %s is present", name.Name) } } return true }) for action := range authorized { if !found[action] { t.Errorf("authorized GitLab WebSocket action constant %s is missing", action) } } } func TestRegisterHTTPRoutesDoesNotExposeUnscopedTaskMRSync(t *testing.T) { router := gin.New() NewController( NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)), newTestLogger(t), ).RegisterHTTPRoutes(router) request := httptest.NewRequest( http.MethodPost, "/api/v1/gitlab/tasks/fabricated/mrs/sync", strings.NewReader(`{"project_path":"group/project","iid":7}`), ) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("sync route status = %d, want 404; body=%s", response.Code, response.Body.String()) } } func TestEnvironmentTokenRejectsUntrustedWorkspaceHostBeforeClientConstruction(t *testing.T) { t.Setenv(secretNameToken, "glpat-environment-secret") store := newTestStore(t) seedWorkspace(t, store, "workspace-a") service := newWorkspaceConfigService(t, store, &configTestSecrets{values: make(map[string]string)}) var constructions atomic.Int32 service.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { constructions.Add(1) return NewMockClient(cfg.Host), nil } result := service.TestConfigForWorkspace(t.Context(), "workspace-a", &SetConfigRequest{ Host: "https://attacker.invalid", AuthMethod: AuthMethodEnvironment, }) if result.OK || !strings.Contains(result.Error, "environment credential host") { t.Fatalf("test result = %#v, want trusted-origin rejection", result) } if got := constructions.Load(); got != 0 { t.Fatalf("client constructions = %d, want zero", got) } } func TestEnvironmentTokenAllowsImmutableStartupHost(t *testing.T) { t.Setenv(secretNameToken, "glpat-environment-secret") store := newTestStore(t) seedWorkspace(t, store, "workspace-a") service := NewService("https://gitlab.internal", NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) service.SetStore(store) service.SetWorkspaceSecretStore(&configTestSecrets{values: make(map[string]string)}) var constructions atomic.Int32 service.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, token string) (Client, error) { constructions.Add(1) if cfg.Host != "https://gitlab.internal" || token != "glpat-environment-secret" { t.Fatalf("constructed client for host=%q token=%q", cfg.Host, token) } return NewMockClient(cfg.Host), nil } result := service.TestConfigForWorkspace(t.Context(), "workspace-a", &SetConfigRequest{ Host: "https://gitlab.internal/", AuthMethod: AuthMethodEnvironment, }) if !result.OK || result.Error != "" { t.Fatalf("test result = %#v, want success", result) } if got := constructions.Load(); got != 1 { t.Fatalf("client constructions = %d, want one", got) } } func TestResolveExecutionCredentialsRejectsPersistedEnvironmentHostMismatch(t *testing.T) { t.Setenv(secretNameToken, "glpat-environment-secret") store := newTestStore(t) seedWorkspace(t, store, "workspace-a") if err := store.SaveConfigForWorkspace(t.Context(), "workspace-a", &GitLabConfig{ Host: "https://attacker.invalid", AuthMethod: AuthMethodEnvironment, }); err != nil { t.Fatalf("seed mismatched config: %v", err) } service := NewService("https://gitlab.internal", NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) service.SetStore(store) host, token, err := service.ResolveGitLabExecutionCredentials(t.Context(), "workspace-a") if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("error = %v, want ErrInvalidConfig", err) } if host != "" || token != "" { t.Fatalf("credentials = (%q, %q), want empty values", host, token) } } func TestAssociateExistingMRRejectsFabricatedProviderIdentityWithoutPersistence(t *testing.T) { const host = "https://gitlab.internal" tests := []struct { name string mr *MR }{ { name: "wrong returned iid", mr: &MR{IID: 8, WebURL: host + "/group/project/-/merge_requests/8"}, }, { name: "wrong returned host", mr: &MR{IID: 7, WebURL: "https://attacker.invalid/group/project/-/merge_requests/7"}, }, { name: "wrong returned project path", mr: &MR{IID: 7, WebURL: host + "/group/other/-/merge_requests/7"}, }, { name: "malformed returned url", mr: &MR{IID: 7, WebURL: "not-a-url"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { service, store, client := newTaskMRLinkService(t, host) seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") setTaskMRRepositoryIdentity(t, store, "repo-1", host, "group/project") test.mr.Title = "untrusted provider response" test.mr.State = "opened" test.mr.CreatedAt = time.Now().UTC() service.workspaceClients["ws-1"] = &fixedMRStatusClient{ Client: client, status: &MRStatus{MR: test.mr}, } _, err := service.AssociateExistingMRByURL( t.Context(), "ws-1", "task-1", "repo-1", host+"/group/project/-/merge_requests/7", ) if !errors.Is(err, ErrTaskMRNotFound) { t.Fatalf("error = %v, want ErrTaskMRNotFound", err) } rows, listErr := store.ListTaskMRsByTask(t.Context(), "task-1") if listErr != nil || len(rows) != 0 { t.Fatalf("stored rows = %d, err = %v; want zero", len(rows), listErr) } }) } } type fixedMRStatusClient struct { Client status *MRStatus } func (c *fixedMRStatusClient) GetMRStatus(context.Context, string, int) (*MRStatus, error) { return c.status, nil } func TestAPIErrorNeverIncludesProviderResponseBody(t *testing.T) { const hostileBody = `{"message":"private@example.com glpat-secret"}` err := (&APIError{StatusCode: http.StatusBadGateway, Endpoint: "/projects", Body: hostileBody}).Error() if strings.Contains(err, hostileBody) || strings.Contains(err, "glpat-secret") { t.Fatalf("APIError leaked provider response body: %q", err) } } func TestHostileProviderBodyDoesNotReachHTTPStatusOrLogs(t *testing.T) { const secret = "glpat-secret-do-not-log" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message":"contact private@example.com with ` + secret + `"}`)) })) t.Cleanup(server.Close) core, observed := observer.New(zapcore.DebugLevel) log, err := logger.NewFromZap(zap.New(core)) if err != nil { t.Fatalf("logger: %v", err) } store := newTestStore(t) seedWorkspace(t, store, "workspace-test") if err := store.SaveConfigForWorkspace(t.Context(), "workspace-test", &GitLabConfig{ Host: server.URL, AuthMethod: AuthMethodPAT, }); err != nil { t.Fatalf("seed config: %v", err) } service := NewService(server.URL, NewPATClient(server.URL, secret), AuthMethodPAT, nil, log) service.SetStore(store) service.SetWorkspaceSecretStore(&configTestSecrets{values: map[string]string{ SecretKeyForWorkspace("workspace-test"): secret, }}) router := gin.New() NewController(service, log).RegisterHTTPRoutes(router) response := hit(router, "/api/v1/gitlab/user/mrs") if response.Code != http.StatusBadGateway { t.Fatalf("HTTP status = %d, want 502; body=%s", response.Code, response.Body.String()) } if strings.Contains(response.Body.String(), secret) || strings.Contains(response.Body.String(), "private@example.com") { t.Fatalf("HTTP response leaked provider body: %s", response.Body.String()) } statusResponse := hit(router, "/api/v1/gitlab/status") if statusResponse.Code != http.StatusOK { t.Fatalf("status endpoint = %d, want 200; body=%s", statusResponse.Code, statusResponse.Body.String()) } if strings.Contains(statusResponse.Body.String(), secret) || strings.Contains(statusResponse.Body.String(), "private@example.com") { t.Fatalf("status response leaked provider body: %s", statusResponse.Body.String()) } _, _ = service.GetStatsForWorkspace(t.Context(), "workspace-test") for _, entry := range observed.All() { serialized := entry.ContextMap() contextText := fmt.Sprint(serialized) if strings.Contains(entry.Message, secret) || strings.Contains(entry.Message, "private@example.com") || strings.Contains(contextText, secret) || strings.Contains(contextText, "private@example.com") { t.Fatalf("log leaked provider body: message=%q context=%v", entry.Message, serialized) } } }