package github import ( "context" "errors" "strings" "testing" "time" ) type repositoryVisibilityClient struct { Client repos map[string]GitHubRepo prs []*PR issues []*Issue paginate bool } func (c repositoryVisibilityClient) SearchPRs(context.Context, string, string) ([]*PR, error) { return append([]*PR(nil), c.prs...), nil } func (c repositoryVisibilityClient) SearchPRsPaged( _ context.Context, _, _ string, page, perPage int, ) (*PRSearchPage, error) { prs := append([]*PR(nil), c.prs...) if c.paginate { prs = paginateSearchResults(prs, page, perPage) } return &PRSearchPage{PRs: prs, TotalCount: len(c.prs), Page: page, PerPage: perPage}, nil } func (c repositoryVisibilityClient) ListIssues(context.Context, string, string) ([]*Issue, error) { return append([]*Issue(nil), c.issues...), nil } func (c repositoryVisibilityClient) ListIssuesPaged( _ context.Context, _, _ string, page, perPage int, ) (*IssueSearchPage, error) { issues := append([]*Issue(nil), c.issues...) if c.paginate { issues = paginateSearchResults(issues, page, perPage) } return &IssueSearchPage{ Issues: issues, TotalCount: len(c.issues), Page: page, PerPage: perPage, }, nil } func (c repositoryVisibilityClient) GetPRStatus( _ context.Context, owner, repo string, number int, ) (*PRStatus, error) { return &PRStatus{PR: &PR{RepoOwner: owner, RepoName: repo, Number: number}}, nil } func (c repositoryVisibilityClient) HasRepositoryAccess( _ context.Context, owner, repo string, ) (bool, error) { if _, ok := c.repos[strings.ToLower(owner+"/"+repo)]; !ok { return false, &GitHubAPIError{StatusCode: 404, Endpoint: "/repos/" + owner + "/" + repo} } return true, nil } func (c repositoryVisibilityClient) ListAccessibleRepos( _ context.Context, query string, _ int, ) ([]GitHubRepo, error) { result := make([]GitHubRepo, 0, len(c.repos)) for _, repo := range c.repos { if query == "" || strings.Contains(strings.ToLower(repo.FullName), strings.ToLower(query)) { result = append(result, repo) } } return result, nil } type fixedUserCredentialProvider struct{ client Client } func (p fixedUserCredentialProvider) ResolveUser( _ context.Context, connection *UserConnection, _ ResolveCredentialRequest, ) (*ResolvedCredential, error) { return &ResolvedCredential{ Client: p.client, Principal: AuthPrincipal{ Kind: AuthPrincipalHuman, Source: ConnectionSourceGitHubAppUser, Login: connection.Login, }, }, nil } func TestResolveServiceClientRequiresWorkspace(t *testing.T) { svc := &Service{} _, err := svc.resolveAutomationClient(context.Background(), "", "octo", "repo") if !errors.Is(err, ErrGitHubWorkspaceRequired) { t.Fatalf("expected workspace required, got %v", err) } } func TestResolveServiceClientAuthorizesWorkspaceBeforeCredentialLookup(t *testing.T) { want := errors.New("workspace not found") svc := &Service{workspaceAuthorizer: func(_ context.Context, workspaceID string) error { if workspaceID != "victim-workspace" { t.Fatalf("workspaceID = %q", workspaceID) } return want }} _, err := svc.resolveServiceClient( context.Background(), "victim-workspace", "attacker", CredentialPurposePersonalRead, "", "", ) if !errors.Is(err, want) { t.Fatalf("resolveServiceClient() error = %v, want authorization denial", err) } } func TestCredentialCacheScopeSeparatesWorkspacePrincipalAndGeneration(t *testing.T) { base := &ResolvedCredential{ Principal: AuthPrincipal{ WorkspaceID: "workspace-a", UserID: "user-a", Source: ConnectionSourcePAT, Kind: AuthPrincipalHuman, Login: "octocat", }, CredentialGeneration: 1, AppRegistrationID: "registration-a", AppCredentialGeneration: 7, } baseScope := credentialCacheScope(base, CredentialPurposePersonalRead) variants := []*ResolvedCredential{ {Principal: AuthPrincipal{WorkspaceID: "workspace-b", UserID: "user-a", Source: ConnectionSourcePAT, Kind: AuthPrincipalHuman, Login: "octocat"}, CredentialGeneration: 1, AppRegistrationID: "registration-a", AppCredentialGeneration: 7}, {Principal: AuthPrincipal{WorkspaceID: "workspace-a", UserID: "user-b", Source: ConnectionSourcePAT, Kind: AuthPrincipalHuman, Login: "octocat"}, CredentialGeneration: 1, AppRegistrationID: "registration-a", AppCredentialGeneration: 7}, {Principal: base.Principal, CredentialGeneration: 2, AppRegistrationID: "registration-a", AppCredentialGeneration: 7}, {Principal: base.Principal, CredentialGeneration: 1, AppRegistrationID: "registration-b", AppCredentialGeneration: 7}, {Principal: base.Principal, CredentialGeneration: 1, AppRegistrationID: "registration-a", AppCredentialGeneration: 8}, } for _, variant := range variants { if got := credentialCacheScope(variant, CredentialPurposePersonalRead); got == baseScope { t.Fatalf("cache scope collision: %q", got) } } if got := credentialCacheScope(base, CredentialPurposePersonalWrite); got == baseScope { t.Fatalf("purpose must be part of cache scope: %q", got) } } func TestRepositoryInWorkspaceScope(t *testing.T) { tests := []struct { name string settings *WorkspaceSettings owner string repo string want bool }{ {name: "all", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeAll}, owner: "octo", repo: "one", want: true}, {name: "org match", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeOrgs, RepoScopeOrgs: []string{"Octo"}}, owner: "octo", repo: "one", want: true}, {name: "org miss", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeOrgs, RepoScopeOrgs: []string{"other"}}, owner: "octo", repo: "one"}, {name: "repo match", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeRepos, RepoScopeRepos: []RepoFilter{{Owner: "Octo", Name: "One"}}}, owner: "octo", repo: "one", want: true}, {name: "empty fails closed", settings: &WorkspaceSettings{RepoScopeMode: RepoScopeModeRepos}, owner: "octo", repo: "one"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := repositoryInWorkspaceScope(tt.settings, tt.owner, tt.repo); got != tt.want { t.Fatalf("repositoryInWorkspaceScope() = %v, want %v", got, tt.want) } }) } } func TestReviewWatchQueryUsesPersistedTargetLogin(t *testing.T) { tests := []struct { name string watch *ReviewWatch want string }{ { name: "user and teams", watch: &ReviewWatch{ReviewScope: ReviewScopeUserAndTeams, TargetLogin: "octocat"}, want: "type:pr state:open review-requested:octocat -is:draft", }, { name: "user only", watch: &ReviewWatch{ReviewScope: ReviewScopeUser, TargetLogin: "octocat"}, want: "type:pr state:open user-review-requested:octocat -is:draft", }, { name: "custom query", watch: &ReviewWatch{CustomQuery: "is:open label:urgent", TargetLogin: "octocat"}, want: "is:open label:urgent review-requested:octocat", }, { name: "human legacy remains current user", watch: &ReviewWatch{CustomQuery: "is:open"}, want: "is:open", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := reviewWatchQuery(tt.watch); got != tt.want { t.Fatalf("reviewWatchQuery() = %q, want %q", got, tt.want) } }) } } type rejectingInstallationProvider struct { t *testing.T } func (p rejectingInstallationProvider) ResolveInstallation( context.Context, *WorkspaceConnection, ResolveCredentialRequest, ) (*ResolvedCredential, error) { p.t.Fatal("installation provider called for a review watch without target_login") return nil, nil } func TestCheckReviewWatchDisablesAppWatchWithoutTargetBeforeProviderCall(t *testing.T) { ctx := context.Background() store := newTestStore(t) if _, err := store.db.Exec(`CREATE TABLE IF NOT EXISTS workspaces (id TEXT PRIMARY KEY)`); err != nil { t.Fatalf("create workspaces table: %v", err) } if _, err := store.db.Exec(`INSERT INTO workspaces (id) VALUES ('workspace-app')`); err != nil { t.Fatalf("seed workspace: %v", err) } installationID := int64(123) if err := store.UpsertDeploymentAppRegistration( ctx, newAppRegistration("registration-watch", 123, "Watch App", time.Now().UTC()), ); err != nil { t.Fatalf("seed App registration: %v", err) } if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{ WorkspaceID: "workspace-app", Source: ConnectionSourceGitHubAppInstallation, Status: ConnectionStatusActive, GitHubHost: defaultGitHubHost, InstallationID: &installationID, InstallationAccountLogin: "octo-org", InstallationAccountType: "Organization", AppRegistrationID: "registration-watch", }); err != nil { t.Fatalf("upsert workspace connection: %v", err) } watch := &ReviewWatch{ ID: "watch-app", WorkspaceID: "workspace-app", Enabled: true, PollIntervalSeconds: minWatchPollIntervalSec, } if err := store.CreateReviewWatch(ctx, watch); err != nil { t.Fatalf("create review watch: %v", err) } svc := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) svc.resolver.SetInstallationProvider(rejectingInstallationProvider{t: t}) _, err := svc.CheckReviewWatch(ctx, watch) if !errors.Is(err, ErrGitHubPersonalRequired) { t.Fatalf("CheckReviewWatch error = %v, want personal required", err) } stored, err := store.GetReviewWatch(ctx, watch.ID) if err != nil { t.Fatalf("get review watch: %v", err) } if stored.Enabled || stored.LastError == "" { t.Fatalf("watch was not disabled with actionable error: %+v", stored) } } func TestSyncWorkspaceWatchesBatchedRejectsMixedOwnershipBeforeResolve(t *testing.T) { svc := &Service{} _, err := svc.SyncWorkspaceWatchesBatched(context.Background(), "workspace-a", []*PRWatch{ {WorkspaceID: "workspace-a"}, {WorkspaceID: "workspace-b"}, }) if !errors.Is(err, ErrGitHubWorkspaceRequired) { t.Fatalf("error = %v, want workspace ownership error", err) } } func TestRequireGitHubCapabilityFailsClosedForAppOnly(t *testing.T) { app := &resolvedServiceClient{ Principal: AuthPrincipal{Kind: AuthPrincipalApp}, Capabilities: map[GitHubAppCapability]bool{CapabilityPullRequestRead: true}, } if err := requireGitHubCapability(app, CapabilityPullRequestWrite); !errors.Is(err, ErrGitHubCapabilityDenied) { t.Fatalf("app capability error = %v", err) } human := &resolvedServiceClient{Principal: AuthPrincipal{Kind: AuthPrincipalHuman}} if err := requireGitHubCapability(human, CapabilityPullRequestWrite); err != nil { t.Fatalf("human token should defer permissions to GitHub: %v", err) } } func TestPersonalRepositoryResolutionRequiresAutomationVisibility(t *testing.T) { service := personalBoundaryTestService(t, map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, map[string]GitHubRepo{ "acme/allowed": testGitHubRepo("acme", "allowed"), "personal-only/repo": testGitHubRepo("personal-only", "repo"), }, ) _, err := service.resolvePersonalReadClient( context.Background(), "workspace-1", "user-1", "personal-only", "repo", ) if !errors.Is(err, ErrRepoNotResolvable) { t.Fatalf("resolvePersonalReadClient() error = %v, want repository boundary denial", err) } if _, err := service.resolvePersonalReadClient( context.Background(), "workspace-1", "user-1", "acme", "allowed", ); err != nil { t.Fatalf("allowed repository resolution failed: %v", err) } } func TestPersonalAccessibleReposIntersectAutomationVisibility(t *testing.T) { service := personalBoundaryTestService(t, map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, map[string]GitHubRepo{ "acme/allowed": testGitHubRepo("acme", "allowed"), "personal-only/repo": testGitHubRepo("personal-only", "repo"), }, ) repos, err := service.ListAccessibleReposForWorkspace( context.Background(), "workspace-1", "user-1", "", 50, ) if err != nil { t.Fatalf("ListAccessibleReposForWorkspace() error = %v", err) } if len(repos) != 1 || repos[0].FullName != "acme/allowed" { t.Fatalf("accessible repositories = %+v, want only automation-visible repository", repos) } } func TestPersonalSearchAndStatusResultsIntersectAutomationVisibility(t *testing.T) { service := personalBoundaryTestService(t, map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, map[string]GitHubRepo{ "acme/allowed": testGitHubRepo("acme", "allowed"), "personal-only/repo": testGitHubRepo("personal-only", "repo"), }, ) ctx := context.Background() prs, err := service.SearchUserPRsForWorkspace(ctx, "workspace-1", "user-1", "", "") if err != nil || len(prs) != 1 || prs[0].RepoName != "allowed" { t.Fatalf("unpaged PR search = %+v, %v", prs, err) } issues, err := service.SearchUserIssuesPagedForWorkspaceUser( ctx, "workspace-1", "user-1", "", "", 1, 50, ) if err != nil || len(issues.Issues) != 1 || issues.TotalCount != 1 || issues.Issues[0].RepoName != "allowed" { t.Fatalf("paged issue search = %+v, %v", issues, err) } statuses, err := service.GetPRStatusesBatchForWorkspace(ctx, "workspace-1", "user-1", []PRRef{ {Owner: "acme", Repo: "allowed", Number: 1}, {Owner: "personal-only", Repo: "repo", Number: 2}, }) if err != nil { t.Fatalf("status batch error = %v", err) } if len(statuses) != 1 || statuses[prStatusCacheKey("acme", "allowed", 1)] == nil { t.Fatalf("status batch = %+v, want only automation-visible repository", statuses) } } func TestPersonalSearchPaginatesAfterAutomationVisibilityFiltering(t *testing.T) { personalPRs := make([]*PR, 100, 101) personalIssues := make([]*Issue, 100, 101) for i := range 100 { personalPRs[i] = &PR{RepoOwner: "personal-only", RepoName: "repo", Number: i + 1} personalIssues[i] = &Issue{RepoOwner: "personal-only", RepoName: "repo", Number: i + 1} } personalPRs = append(personalPRs, &PR{RepoOwner: "acme", RepoName: "allowed", Number: 101}) personalIssues = append(personalIssues, &Issue{RepoOwner: "acme", RepoName: "allowed", Number: 101}) service := personalBoundaryTestServiceWithResults(t, personalPRs, personalIssues, true) ctx := context.Background() prs, err := service.SearchUserPRsPagedForWorkspaceUser( ctx, "workspace-1", "user-1", "", "", 1, 1, ) if err != nil || len(prs.PRs) != 1 || prs.TotalCount != 1 || prs.PRs[0].Number != 101 { t.Fatalf("paged PR search = %+v, %v", prs, err) } issues, err := service.SearchUserIssuesPagedForWorkspaceUser( ctx, "workspace-1", "user-1", "", "", 1, 1, ) if err != nil || len(issues.Issues) != 1 || issues.TotalCount != 1 || issues.Issues[0].Number != 101 { t.Fatalf("paged issue search = %+v, %v", issues, err) } } func personalBoundaryTestService( t *testing.T, automationRepos, personalRepos map[string]GitHubRepo, ) *Service { return personalBoundaryTestServiceWithClients(t, automationRepos, personalRepos, []*PR{ {RepoOwner: "acme", RepoName: "allowed", Number: 1}, {RepoOwner: "personal-only", RepoName: "repo", Number: 2}, }, []*Issue{ {RepoOwner: "acme", RepoName: "allowed", Number: 1}, {RepoOwner: "personal-only", RepoName: "repo", Number: 2}, }, false) } func personalBoundaryTestServiceWithResults( t *testing.T, prs []*PR, issues []*Issue, paginate bool, ) *Service { return personalBoundaryTestServiceWithClients(t, map[string]GitHubRepo{"acme/allowed": testGitHubRepo("acme", "allowed")}, map[string]GitHubRepo{ "acme/allowed": testGitHubRepo("acme", "allowed"), "personal-only/repo": testGitHubRepo("personal-only", "repo"), }, prs, issues, paginate, ) } func personalBoundaryTestServiceWithClients( t *testing.T, automationRepos, personalRepos map[string]GitHubRepo, prs []*PR, issues []*Issue, paginate bool, ) *Service { t.Helper() store := newTestStore(t) seedConnectionWorkspaces(t, store, "workspace-1") if err := store.UpsertWorkspaceSettings(context.Background(), &WorkspaceSettings{ WorkspaceID: "workspace-1", RepoScopeMode: RepoScopeModeAll, }); err != nil { t.Fatal(err) } installationID := int64(42) connections := &fakeConnectionReader{ workspaces: map[string]*WorkspaceConnection{ "workspace-1": { WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation, InstallationID: &installationID, AppRegistrationID: "registration-test", Status: ConnectionStatusActive, CredentialGeneration: 1, }, }, users: map[string]*UserConnection{ "workspace-1:user-1": { WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test", Login: "octocat", Status: ConnectionStatusActive, CredentialGeneration: 1, }, }, } automationClient := repositoryVisibilityClient{Client: NewMockClient(), repos: automationRepos} personalClient := repositoryVisibilityClient{ Client: NewMockClient(), repos: personalRepos, prs: prs, issues: issues, paginate: paginate, } resolver := NewCredentialResolver(connections, nil) resolver.SetAutomationProvider(testAutomationCredentialProvider{client: automationClient}) resolver.SetUserProvider(fixedUserCredentialProvider{client: personalClient}) service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t)) service.resolver = resolver return service } func testGitHubRepo(owner, name string) GitHubRepo { return GitHubRepo{FullName: owner + "/" + name, Owner: owner, Name: name} }