diff --git a/.env.example b/.env.example index 4066e5891..9e86082b4 100644 --- a/.env.example +++ b/.env.example @@ -148,6 +148,7 @@ # Google Analytics (GA4) OAuth (distinct from Google Workspace). # PROBOD_CONNECTOR_GOOGLE_ANALYTICS_CLIENT_ID= # PROBOD_CONNECTOR_GOOGLE_ANALYTICS_CLIENT_SECRET= +# Square OAuth (EMPLOYEES_READ); customers may also use a personal access token. # PROBOD_CONNECTOR_SQUARE_CLIENT_ID= # PROBOD_CONNECTOR_SQUARE_CLIENT_SECRET= # PostHog Cloud (US + EU) OAuth needs NO config: it uses the CIMD public-client diff --git a/pkg/accessreview/drivers/connector_helpers_test.go b/pkg/accessreview/drivers/connector_helpers_test.go new file mode 100644 index 000000000..ad90de244 --- /dev/null +++ b/pkg/accessreview/drivers/connector_helpers_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package drivers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Merging by lowercased email is the whole contract of the Google Analytics +// driver: one user holding an account-level and a property-level binding must +// collapse to a single reviewed account. Cassette fixtures are all already +// lowercase, so the normalization only shows up here. +func TestAddGoogleAnalyticsBinding(t *testing.T) { + t.Parallel() + + t.Run("merges casing and whitespace variants of one email", func(t *testing.T) { + t.Parallel() + + members := map[string]*googleAnalyticsMember{} + + addGoogleAnalyticsBinding(members, "Ada@Example.com", []string{"predefinedRoles/viewer"}) + addGoogleAnalyticsBinding(members, " ada@example.com ", []string{"predefinedRoles/analyst"}) + + require.Len(t, members, 1) + + records := googleAnalyticsRecords(members) + require.Len(t, records, 1) + assert.Equal(t, "ada@example.com", records[0].Email) + assert.Equal(t, []string{"analyst", "viewer"}, records[0].Roles) + assert.False(t, records[0].IsAdmin) + }) + + t.Run("an admin role anywhere wins", func(t *testing.T) { + t.Parallel() + + members := map[string]*googleAnalyticsMember{} + + addGoogleAnalyticsBinding(members, "ada@example.com", []string{"predefinedRoles/viewer"}) + addGoogleAnalyticsBinding(members, "ada@example.com", []string{googleAnalyticsAdminRole}) + + records := googleAnalyticsRecords(members) + require.Len(t, records, 1) + assert.True(t, records[0].IsAdmin) + }) + + t.Run("skips empty emails and blank roles", func(t *testing.T) { + t.Parallel() + + members := map[string]*googleAnalyticsMember{} + + addGoogleAnalyticsBinding(members, " ", []string{"predefinedRoles/viewer"}) + addGoogleAnalyticsBinding(members, "ada@example.com", []string{"", " "}) + + require.Len(t, members, 1) + + records := googleAnalyticsRecords(members) + require.Len(t, records, 1) + assert.Empty(t, records[0].Roles) + }) +} + +// The probe must target the account's accessBindings, not the accounts list, +// so a connection that can read accounts but not bindings reports disconnected. +func TestGoogleAnalyticsAccountBindingsProbeURL(t *testing.T) { + t.Parallel() + + t.Run("targets the account bindings with a single-item page", func(t *testing.T) { + t.Parallel() + + got, err := GoogleAnalyticsAccountBindingsProbeURL("123456") + require.NoError(t, err) + assert.Equal( + t, + "https://analyticsadmin.googleapis.com/v1alpha/accounts/123456/accessBindings?pageSize=1", + got, + ) + }) + + t.Run("escapes the account ID", func(t *testing.T) { + t.Parallel() + + got, err := GoogleAnalyticsAccountBindingsProbeURL("12/34") + require.NoError(t, err) + assert.Contains(t, got, "/accounts/12%2F34/accessBindings") + }) +} + +func TestDotfileIsAdmin(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + role string + want bool + }{ + {role: "owner", want: true}, + {role: "admin", want: true}, + {role: "OWNER", want: true}, + {role: " Admin ", want: true}, + {role: "member", want: false}, + {role: "", want: false}, + } { + t.Run(tc.role, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, dotfileIsAdmin(tc.role)) + }) + } +} + +func TestSegmentRolesAndAdmin(t *testing.T) { + t.Parallel() + + t.Run("deduplicates and sorts role names", func(t *testing.T) { + t.Parallel() + + roles, isAdmin := segmentRolesAndAdmin([]segmentPermission{ + {RoleName: "Source Admin"}, + {RoleName: "Source Admin"}, + {RoleName: "Read-only"}, + {RoleName: " "}, + }) + + assert.Equal(t, []string{"Read-only", "Source Admin"}, roles) + assert.False(t, isAdmin) + }) + + t.Run("matches the workspace owner role case-insensitively", func(t *testing.T) { + t.Parallel() + + roles, isAdmin := segmentRolesAndAdmin([]segmentPermission{ + {RoleName: "workspace owner"}, + }) + + assert.Equal(t, []string{"workspace owner"}, roles) + assert.True(t, isAdmin) + }) + + t.Run("no permissions means no roles and no admin", func(t *testing.T) { + t.Parallel() + + roles, isAdmin := segmentRolesAndAdmin(nil) + + assert.Empty(t, roles) + assert.False(t, isAdmin) + }) +} diff --git a/pkg/accessreview/drivers/dotfile_test.go b/pkg/accessreview/drivers/dotfile_test.go index 118281554..b7b29c80a 100644 --- a/pkg/accessreview/drivers/dotfile_test.go +++ b/pkg/accessreview/drivers/dotfile_test.go @@ -24,6 +24,7 @@ import ( "context" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -50,6 +51,10 @@ func TestDotfileDriver(t *testing.T) { require.NotNil(t, owner.Active) assert.True(t, *owner.Active) assert.Equal(t, []string{"owner"}, owner.Roles) + // parseRFC3339Ptr nils on any format mismatch, so pin the parsed instant: + // a silently dropped created_at would otherwise look like a clean pass. + require.NotNil(t, owner.CreatedAt) + assert.Equal(t, "2023-01-31T13:30:00Z", owner.CreatedAt.UTC().Format(time.RFC3339)) // Admin: also administrative. admin := records[1] diff --git a/pkg/accessreview/drivers/square_test.go b/pkg/accessreview/drivers/square_test.go index 9f4fac6bf..02c26634c 100644 --- a/pkg/accessreview/drivers/square_test.go +++ b/pkg/accessreview/drivers/square_test.go @@ -24,6 +24,7 @@ import ( "context" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -49,6 +50,10 @@ func TestSquareDriver(t *testing.T) { assert.Equal(t, []string{"Member"}, member.Roles) require.NotNil(t, member.Active) assert.True(t, *member.Active) + // parseRFC3339Ptr nils on any format mismatch, so pin the parsed instant: + // a silently dropped created_at would otherwise look like a clean pass. + require.NotNil(t, member.CreatedAt) + assert.Equal(t, "2020-03-24T18:14:26Z", member.CreatedAt.UTC().Format(time.RFC3339)) // Owner → is_owner drives IsAdmin directly. owner := records[1]