290 lines
10 KiB
Go
290 lines
10 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
)
|
|
|
|
func TestRegisterRoutesWithDispatcherDoesNotExposeLegacyGitLabActions(t *testing.T) {
|
|
dispatcher := ws.NewDispatcher()
|
|
router := gin.New()
|
|
RegisterRoutesWithDispatcher(
|
|
router,
|
|
dispatcher,
|
|
NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)),
|
|
newTestLogger(t),
|
|
)
|
|
|
|
legacyActions := []string{
|
|
ws.ActionGitLabStatus,
|
|
ws.ActionGitLabTaskMRsList,
|
|
ws.ActionGitLabTaskMRGet,
|
|
ws.ActionGitLabMRFeedbackGet,
|
|
ws.ActionGitLabReviewWatchesList,
|
|
ws.ActionGitLabReviewWatchCreate,
|
|
ws.ActionGitLabReviewWatchUpdate,
|
|
ws.ActionGitLabReviewWatchDelete,
|
|
ws.ActionGitLabReviewTrigger,
|
|
ws.ActionGitLabReviewTriggerAll,
|
|
ws.ActionGitLabMRWatchesList,
|
|
ws.ActionGitLabMRWatchDelete,
|
|
ws.ActionGitLabMRFilesGet,
|
|
ws.ActionGitLabMRCommitsGet,
|
|
ws.ActionGitLabTaskMRSync,
|
|
ws.ActionGitLabStats,
|
|
ws.ActionGitLabMRMerge,
|
|
ws.ActionGitLabMRApprove,
|
|
ws.ActionGitLabMRUnapprove,
|
|
ws.ActionGitLabMRSetLabels,
|
|
ws.ActionGitLabMRSetAssignees,
|
|
ws.ActionGitLabMRDiscussionNew,
|
|
ws.ActionGitLabMRDiscussionResolve,
|
|
ws.ActionGitLabProjectMergeMethodsGet,
|
|
ws.ActionGitLabIssueWatchesList,
|
|
ws.ActionGitLabIssueWatchCreate,
|
|
ws.ActionGitLabIssueWatchUpdate,
|
|
ws.ActionGitLabIssueWatchDelete,
|
|
ws.ActionGitLabIssueTrigger,
|
|
ws.ActionGitLabIssueTriggerAll,
|
|
ws.ActionGitLabActionPresetsList,
|
|
ws.ActionGitLabActionPresetsUpdate,
|
|
ws.ActionGitLabActionPresetsReset,
|
|
ws.ActionGitLabListUserProjects,
|
|
ws.ActionGitLabSearchProjects,
|
|
ws.ActionGitLabProjectBranches,
|
|
ws.ActionGitLabCleanupReviewTasks,
|
|
ws.ActionGitLabCleanupIssueTasks,
|
|
}
|
|
for _, action := range legacyActions {
|
|
if dispatcher.HasHandler(action) {
|
|
t.Errorf("legacy GitLab WebSocket action %q is registered", 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)
|
|
}
|
|
}
|
|
}
|