Files
kandev/apps/backend/internal/github/mock_controller_test.go
T

474 lines
19 KiB
Go

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 TestMockControllerSeedPRFeedbackClearsPRCaches(t *testing.T) {
router, svc, _ := setupWorkspaceAuthMockController(t)
svc.prFeedbackCache.set("feedback", "stale")
svc.prStatusCache.set("status", "stale")
response := serveMockJSON(t, router, http.MethodPost,
"/api/v1/github/mock/pr-feedback",
`{"owner":"owner","repo":"repo","pr_number":7,"workflow_runs":[{"id":7,"run_attempt":1,"workflow_id":9,"name":"CI","event":"pull_request","status":"completed","conclusion":"action_required","head_sha":"head"}],"workflow_jobs":[{"run_id":7,"jobs":[{"id":70,"name":"test","status":"completed","conclusion":"success"}]}]}`)
if response.Code != http.StatusOK {
t.Fatalf("seed PR feedback: %d %s", response.Code, response.Body.String())
}
if _, ok := svc.prFeedbackCache.get("feedback"); ok {
t.Fatal("feedback cache still contains the seeded snapshot")
}
if _, ok := svc.prStatusCache.get("status"); ok {
t.Fatal("status cache still contains the seeded snapshot")
}
mock, ok := svc.client.(*MockClient)
if !ok {
t.Fatalf("service client has type %T, want *MockClient", svc.client)
}
jobs, err := mock.ListWorkflowRunJobs(context.Background(), "owner", "repo", 7, 1)
if err != nil || len(jobs) != 1 {
t.Fatalf("workflow jobs with omitted attempt = %#v, err=%v; want attempt 1", jobs, err)
}
}
func TestMockControllerWorkflowMutationsClearCachesAndNormalizeEvidence(t *testing.T) {
router, svc, _ := setupWorkspaceAuthMockController(t)
mock, ok := svc.client.(*MockClient)
if !ok {
t.Fatalf("service client has type %T, want *MockClient", svc.client)
}
svc.prFeedbackCache.set("feedback", "stale")
runsResponse := serveMockJSON(t, router, http.MethodPost,
"/api/v1/github/mock/workflow-runs",
`{"owner":"owner","repo":"repo","head_sha":"head-sha","runs":[{"id":7,"workflow_id":9,"name":"CI","head_sha":""}]}`)
if runsResponse.Code != http.StatusOK {
t.Fatalf("seed workflow runs: %d %s", runsResponse.Code, runsResponse.Body.String())
}
if _, ok := svc.prFeedbackCache.get("feedback"); ok {
t.Fatal("feedback cache still contains workflow-run mutation snapshot")
}
runs, err := mock.ListWorkflowRuns(context.Background(), "owner", "repo", "head-sha")
if err != nil || len(runs) != 1 || runs[0].HeadSHA != "head-sha" {
t.Fatalf("workflow runs = %#v, err=%v; want requested head SHA copied into run", runs, err)
}
svc.prStatusCache.set("status", "stale")
jobsResponse := serveMockJSON(t, router, http.MethodPost,
"/api/v1/github/mock/workflow-jobs",
`{"owner":"owner","repo":"repo","run_id":7,"jobs":[{"id":70,"name":"test","status":"completed","conclusion":"success"}]}`)
if jobsResponse.Code != http.StatusOK {
t.Fatalf("seed workflow jobs: %d %s", jobsResponse.Code, jobsResponse.Body.String())
}
if _, ok := svc.prStatusCache.get("status"); ok {
t.Fatal("status cache still contains workflow-job mutation snapshot")
}
jobs, err := mock.ListWorkflowRunJobs(context.Background(), "owner", "repo", 7, 1)
if err != nil || len(jobs) != 1 {
t.Fatalf("workflow jobs = %#v, err=%v; want omitted attempt normalized to 1", jobs, 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 TestMockControllerAddPRsDefaultsSameRepositoryHeadIdentity(t *testing.T) {
router, mock := setupMockControllerTestForAddIssues()
response := serveMockJSON(t, router, http.MethodPost, "/api/v1/github/mock/prs", `{"prs":[
{"number":42,"title":"same repository","state":"open","head_branch":"feature","base_branch":"main","repo_owner":"owner","repo_name":"repo"},
{"number":43,"title":"fork source","state":"open","head_branch":"feature","base_branch":"main","repo_owner":"owner","repo_name":"repo","head_repo_owner":"contributor","head_repo_name":"repo-fork"}
]}`)
if response.Code != http.StatusOK {
t.Fatalf("add PRs: %d %s", response.Code, response.Body.String())
}
sameRepositoryPR, err := mock.GetPR(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("get same-repository PR: %v", err)
}
if sameRepositoryPR.HeadRepoOwner != "owner" || sameRepositoryPR.HeadRepoName != "repo" {
t.Fatalf("same-repository head = %s/%s, want owner/repo", sameRepositoryPR.HeadRepoOwner, sameRepositoryPR.HeadRepoName)
}
forkPR, err := mock.GetPR(context.Background(), "owner", "repo", 43)
if err != nil {
t.Fatalf("get fork PR: %v", err)
}
if forkPR.HeadRepoOwner != "contributor" || forkPR.HeadRepoName != "repo-fork" {
t.Fatalf("fork head = %s/%s, want contributor/repo-fork", forkPR.HeadRepoOwner, forkPR.HeadRepoName)
}
}
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)
}
}