package github import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" ) func setupWorkspaceAuthMockController(t *testing.T) (*gin.Engine, *Service, *Store) { t.Helper() gin.SetMode(gin.TestMode) store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1", "workspace-2") mock := NewMockClient() svc := NewService(mock, AuthMethodPAT, nil, store, nil, newControllerTestLogger()) svc.SetConnectionSecretStore(&fakeConnectionSecrets{values: make(map[string]string)}) router := gin.New() NewController(svc, newControllerTestLogger()).RegisterHTTPRoutes(router) RegisterMockRoutes(router, svc, newControllerTestLogger()) return router, svc, store } func serveMockJSON(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() router.ServeHTTP(response, req) return response } func TestMockControllerSequencesPRCommitFailureThenSuccess(t *testing.T) { router, svc, _ := setupWorkspaceAuthMockController(t) response := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/pr-commits-failures", `{"owner":"owner","repo":"repo","number":7,"failures":1}`) if response.Code != http.StatusOK { t.Fatalf("configure PR commit failure: %d %s", response.Code, response.Body.String()) } mock, ok := svc.client.(*MockClient) if !ok { t.Fatalf("service client has type %T, want *MockClient", svc.client) } if _, err := mock.ListPRCommits(context.Background(), "owner", "repo", 7); err == nil { t.Fatal("expected the configured request to fail") } if _, err := mock.ListPRCommits(context.Background(), "owner", "repo", 7); err != nil { t.Fatalf("expected the next request to succeed: %v", err) } } func TestMockControllerWorkspaceConnectionsResolveIsolatedPrincipals(t *testing.T) { router, svc, _ := setupWorkspaceAuthMockController(t) registration := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/app-registrations/registration-work", `{"display_name":"Work App","app_id":101}`) if registration.Code != http.StatusOK { t.Fatalf("seed App registration: %d %s", registration.Code, registration.Body.String()) } first := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/workspace-connections/workspace-1", `{"source":"github_app_installation","status":"active","app_registration_id":"registration-work","installation_id":42,"installation_account_login":"acme","installation_account_type":"Organization","capabilities":{"pull_request_read":true}}`) if first.Code != http.StatusOK { t.Fatalf("seed app connection: %d %s", first.Code, first.Body.String()) } second := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/workspace-connections/workspace-2", `{"source":"gh_cli","status":"active","login":"alice","capabilities":{"pull_request_write":true}}`) if second.Code != http.StatusOK { t.Fatalf("seed CLI connection: %d %s", second.Code, second.Body.String()) } app, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) if err != nil || app.Automation == nil || app.Automation.Actor == nil { t.Fatalf("app status = %+v, err = %v", app, err) } if app.Automation.Actor.Kind != AuthPrincipalApp || app.Automation.Actor.Login != "acme" || !app.Automation.Capabilities[CapabilityPullRequestRead] || app.Automation.Capabilities[CapabilityPullRequestWrite] { t.Fatalf("app automation = %+v", app.Automation) } cli, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-2", DefaultUserID) if err != nil || cli.Automation == nil || cli.Automation.Actor == nil || cli.Automation.Actor.Kind != AuthPrincipalHuman || cli.Automation.Actor.Login != "alice" { t.Fatalf("CLI status = %+v, err = %v", cli, err) } } func TestMockControllerPersonalCLIAvailabilityTransitionsAndReset(t *testing.T) { router, svc, store := setupWorkspaceAuthMockController(t) registration := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/app-registrations/registration-work", `{"display_name":"Work App","app_id":101}`) if registration.Code != http.StatusOK { t.Fatalf("seed App registration: %d %s", registration.Code, registration.Body.String()) } seed := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/workspace-connections/workspace-1", `{"source":"github_app_installation","status":"active","app_registration_id":"registration-work","installation_id":42,"installation_account_login":"acme"}`) if seed.Code != http.StatusOK { t.Fatalf("seed workspace: %d %s", seed.Code, seed.Body.String()) } personal := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/personal-connections/workspace-1", `{"login":"bob","status":"active","github_user_id":7,"access_expires_at":"2030-01-01T00:00:00Z"}`) if personal.Code != http.StatusOK { t.Fatalf("seed personal connection: %d %s", personal.Code, personal.Body.String()) } accounts := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/cli-accounts", `{"accounts":[{"host":"github.com","login":"alice","active":true,"state":"success"}]}`) if accounts.Code != http.StatusOK { t.Fatalf("seed CLI accounts: %d %s", accounts.Code, accounts.Body.String()) } status, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) if err != nil || status.EffectivePersonalActor == nil || status.EffectivePersonalActor.Login != "bob" || !status.GitHubAppAvailable { t.Fatalf("workspace status = %+v, err = %v", status, err) } listed := serveMockJSON(t, router, http.MethodGet, "/api/v1/github/auth/gh-cli/accounts", "") if listed.Code != http.StatusOK || !bytes.Contains(listed.Body.Bytes(), []byte(`"login":"alice"`)) { t.Fatalf("list CLI accounts: %d %s", listed.Code, listed.Body.String()) } pat := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/workspace-connection?workspace_id=workspace-2", `{"source":"pat","token":"mock-pat:carol"}`) if pat.Code != http.StatusOK { t.Fatalf("configure mock PAT: %d %s", pat.Code, pat.Body.String()) } patStatus, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-2", DefaultUserID) if err != nil || patStatus.Automation == nil || patStatus.Automation.Actor == nil || patStatus.Automation.Actor.Login != "carol" { t.Fatalf("configured PAT status = %+v, err = %v", patStatus, err) } configured := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/workspace-connection?workspace_id=workspace-2", `{"source":"gh_cli","host":"github.com","login":"alice"}`) if configured.Code != http.StatusOK { t.Fatalf("configure injected CLI account: %d %s", configured.Code, configured.Body.String()) } cliStatus, err := svc.GetWorkspaceAuthStatus(context.Background(), "workspace-2", DefaultUserID) if err != nil || cliStatus.Automation == nil || cliStatus.Automation.Actor == nil || cliStatus.Automation.Actor.Login != "alice" { t.Fatalf("configured CLI status = %+v, err = %v", cliStatus, err) } transition := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/workspace-connections/workspace-1/status", `{"status":"suspended"}`) if transition.Code != http.StatusOK { t.Fatalf("transition connection: %d %s", transition.Code, transition.Body.String()) } status, err = svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) if err != nil || status.Authenticated || status.Automation.Status != ConnectionStatusSuspended { t.Fatalf("suspended status = %+v, err = %v", status, err) } reset := serveMockJSON(t, router, http.MethodDelete, "/api/v1/github/mock/reset", "") if reset.Code != http.StatusOK { t.Fatalf("reset: %d %s", reset.Code, reset.Body.String()) } connection, err := store.GetWorkspaceConnection(context.Background(), "workspace-1") if err != nil || connection != nil { t.Fatalf("connection after reset = %+v, err = %v", connection, err) } status, err = svc.GetWorkspaceAuthStatus(context.Background(), "workspace-1", DefaultUserID) if err != nil || status.GitHubAppAvailable || status.Personal != nil { t.Fatalf("status after reset = %+v, err = %v", status, err) } } func TestMockControllerDeletesWorkspaceAndPersonalConnections(t *testing.T) { router, _, store := setupWorkspaceAuthMockController(t) registration := serveMockJSON(t, router, http.MethodPut, "/api/v1/github/mock/app-registrations/registration-work", `{"display_name":"Work App","app_id":101}`) if registration.Code != http.StatusOK { t.Fatalf("seed App registration: %d %s", registration.Code, registration.Body.String()) } for _, seed := range []struct { path string body string }{ { path: "/api/v1/github/mock/workspace-connections/workspace-1", body: `{"source":"github_app_installation","status":"active","app_registration_id":"registration-work","installation_id":42,"installation_account_login":"acme"}`, }, { path: "/api/v1/github/mock/personal-connections/workspace-1", body: `{"login":"bob","status":"active","github_user_id":7,"access_expires_at":"2030-01-01T00:00:00Z"}`, }, } { response := serveMockJSON(t, router, http.MethodPut, seed.path, seed.body) if response.Code != http.StatusOK { t.Fatalf("seed %s: %d %s", seed.path, response.Code, response.Body.String()) } } personal := serveMockJSON(t, router, http.MethodDelete, "/api/v1/github/mock/personal-connections/workspace-1", "") if personal.Code != http.StatusOK { t.Fatalf("delete personal: %d %s", personal.Code, personal.Body.String()) } if got, err := store.GetUserConnection(context.Background(), "workspace-1", DefaultUserID); err != nil || got != nil { t.Fatalf("personal after delete = %+v, err = %v", got, err) } workspace := serveMockJSON(t, router, http.MethodDelete, "/api/v1/github/mock/workspace-connections/workspace-1", "") if workspace.Code != http.StatusOK { t.Fatalf("delete workspace: %d %s", workspace.Code, workspace.Body.String()) } if got, err := store.GetWorkspaceConnection(context.Background(), "workspace-1"); err != nil || got != nil { t.Fatalf("workspace after delete = %+v, err = %v", got, err) } } func setupMockControllerTestForAddIssues() (*gin.Engine, *MockClient) { gin.SetMode(gin.TestMode) mock := NewMockClient() ctrl := NewMockController(mock, nil, nil, nil, newControllerTestLogger()) router := gin.New() ctrl.RegisterRoutes(router) return router, mock } func TestMockControllerAddIssues(t *testing.T) { router, mock := setupMockControllerTestForAddIssues() body := bytes.NewBufferString(`{"issues":[{"number":1456,"title":"Fix picker","repo_owner":"owner","repo_name":"repo"}]}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/github/mock/issues", body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var got struct { Added int `json:"added"` } if err := json.NewDecoder(w.Body).Decode(&got); err != nil { t.Fatalf("decode response: %v", err) } if got.Added != 1 { t.Fatalf("expected added=1, got %d", got.Added) } issue, err := mock.GetIssue(context.Background(), "owner", "repo", 1456) if err != nil { t.Fatalf("get seeded issue: %v", err) } if issue.Title != "Fix picker" { t.Fatalf("expected seeded issue title, got %q", issue.Title) } } func TestMockControllerAddIssuesInvalidPayload(t *testing.T) { router, _ := setupMockControllerTestForAddIssues() req := httptest.NewRequest(http.MethodPost, "/api/v1/github/mock/issues", bytes.NewBufferString(`{`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) } } func TestMockControllerAddRepoFiles(t *testing.T) { router, mock := setupMockControllerTestForAddIssues() body := bytes.NewBufferString(`{ "owner":"o", "repo":"r", "ref":"main", "files":[{"path":"workflows/deploy.yaml","content":"name: deploy"}] }`) req := httptest.NewRequest(http.MethodPost, "/api/v1/github/mock/repo-files", body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var got struct { Added int `json:"added"` } if err := json.NewDecoder(w.Body).Decode(&got); err != nil { t.Fatalf("decode response: %v", err) } if got.Added != 1 { t.Fatalf("expected added=1, got %d", got.Added) } content, err := mock.GetRepoFileContent(context.Background(), "o", "r", "workflows/deploy.yaml", "main") if err != nil { t.Fatalf("get seeded repo file: %v", err) } if string(content) != "name: deploy" { t.Fatalf("expected seeded content, got %q", content) } } func TestMockControllerAddRepoFilesInvalidPayload(t *testing.T) { router, _ := setupMockControllerTestForAddIssues() req := httptest.NewRequest(http.MethodPost, "/api/v1/github/mock/repo-files", bytes.NewBufferString(`{`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) } } func TestMockControllerAddRepoFilesRequiresOwnerAndRepo(t *testing.T) { router, _ := setupMockControllerTestForAddIssues() body := bytes.NewBufferString(`{"files":[{"path":"a.yaml","content":"x"}]}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/github/mock/repo-files", body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) } } func TestBuildTaskPRFromRequestCopiesWorkspaceID(t *testing.T) { req := &associateTaskPRRequest{ TaskID: "task-1", WorkspaceID: "ws-1", RepositoryID: "repo-1", Owner: "testorg", Repo: "testrepo", PRNumber: 103, } tp := buildTaskPRFromRequest(req, time.Now().UTC()) if tp.WorkspaceID != "ws-1" { t.Fatalf("WorkspaceID = %q, want ws-1", tp.WorkspaceID) } if tp.RepositoryID != "repo-1" { t.Fatalf("RepositoryID = %q, want repo-1", tp.RepositoryID) } } func TestEnsureMockPRForRequestCopiesMergeableState(t *testing.T) { mock := NewMockClient() controller := &MockController{mock: mock} req := &associateTaskPRRequest{ Owner: "testorg", Repo: "testrepo", PRNumber: 102, PRURL: "https://github.com/testorg/testrepo/pull/102", PRTitle: "Ready to ship", HeadBranch: "feat/ready", BaseBranch: "main", AuthorLogin: "test-user", State: "open", MergeableState: "clean", } controller.ensureMockPRForRequest(context.Background(), req, time.Now().UTC()) pr, err := mock.GetPR(context.Background(), req.Owner, req.Repo, req.PRNumber) if err != nil { t.Fatalf("GetPR: %v", err) } if pr == nil { t.Fatal("expected synthetic PR") } if pr.MergeableState != "clean" { t.Fatalf("MergeableState = %q, want clean", pr.MergeableState) } }