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

357 lines
14 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 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",
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)
}
}
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)
}
}