465 lines
17 KiB
Go
465 lines
17 KiB
Go
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}
|
|
}
|