Ctrl K
ComunidadGuías…

coderd/idpsync/group_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package idpsync_test

import (
	"context"
	"database/sql"
	"regexp"
	"slices"
	"testing"

	"github.com/golang-jwt/jwt/v4"
	"github.com/google/uuid"
	"github.com/stretchr/testify/require"
	"golang.org/x/xerrors"

	"cdr.dev/slog/v3"
	"cdr.dev/slog/v3/sloggers/slogtest"
	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbgen"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/idpsync"
	"github.com/coder/coder/v2/coderd/runtimeconfig"
	"github.com/coder/coder/v2/coderd/util/slice"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
)

func TestParseGroupClaims(t *testing.T) {
	t.Parallel()

	t.Run("EmptyConfig", func(t *testing.T) {
		t.Parallel()

		s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}),
			runtimeconfig.NewManager(),
			idpsync.DeploymentSyncSettings{})

		ctx := testutil.Context(t, testutil.WaitMedium)

		params, err := s.ParseGroupClaims(ctx, jwt.MapClaims{})
		require.Nil(t, err)

		require.False(t, params.SyncEntitled)
	})

	t.Run("NotInAllowList", func(t *testing.T) {
		t.Parallel()

		s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}),
			runtimeconfig.NewManager(),
			idpsync.DeploymentSyncSettings{
				GroupField: "groups",
				GroupAllowList: map[string]struct{}{
					"foo": {},
				},
			})

		ctx := testutil.Context(t, testutil.WaitMedium)

		// Invalid group
		_, err := s.ParseGroupClaims(ctx, jwt.MapClaims{
			"groups": []string{"bar"},
		})
		require.NotNil(t, err)
		require.Equal(t, 403, err.Code)

		// No groups
		_, err = s.ParseGroupClaims(ctx, jwt.MapClaims{})
		require.NotNil(t, err)
		require.Equal(t, 403, err.Code)
	})

	t.Run("InAllowList", func(t *testing.T) {
		t.Parallel()

		s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}),
			runtimeconfig.NewManager(),
			idpsync.DeploymentSyncSettings{
				GroupField: "groups",
				GroupAllowList: map[string]struct{}{
					"foo": {},
				},
			})

		ctx := testutil.Context(t, testutil.WaitMedium)

		claims := jwt.MapClaims{
			"groups": []string{"foo", "bar"},
		}
		params, err := s.ParseGroupClaims(ctx, claims)
		require.Nil(t, err)
		require.Equal(t, claims, params.MergedClaims)
	})
}

//nolint:paralleltest, tparallel
func TestGroupSyncTable(t *testing.T) {
	t.Parallel()

	userClaims := jwt.MapClaims{
		"groups": []string{
			"foo", "bar", "baz",
			"create-bar", "create-baz",
			"legacy-bar",
		},
	}

	ids := coderdtest.NewDeterministicUUIDGenerator()
	testCases := []orgSetupDefinition{
		{
			Name: "SwitchGroups",
			GroupSettings: &codersdk.GroupSyncSettings{
				Field: "groups",
				Mapping: map[string][]uuid.UUID{
					"foo": {ids.ID("sg-foo"), ids.ID("sg-foo-2")},
					"bar": {ids.ID("sg-bar")},
					"baz": {ids.ID("sg-baz")},
				},
			},
			Groups: map[uuid.UUID]bool{
				uuid.New(): true,
				uuid.New(): true,
				// Extra groups
				ids.ID("sg-foo"):   false,
				ids.ID("sg-foo-2"): false,
				ids.ID("sg-bar"):   false,
				ids.ID("sg-baz"):   false,
			},
			assertGroups: &orgGroupAssert{
				ExpectedGroups: []uuid.UUID{
					ids.ID("sg-foo"),
					ids.ID("sg-foo-2"),
					ids.ID("sg-bar"),
					ids.ID("sg-baz"),
				},
			},
		},
		{
			Name: "StayInGroup",
			GroupSettings: &codersdk.GroupSyncSettings{
				Field: "groups",
				// Only match foo, so bar does not map
				RegexFilter: regexp.MustCompile("^foo$"),
				Mapping: map[string][]uuid.UUID{
					"foo": {ids.ID("gg-foo"), uuid.New()},
					"bar": {ids.ID("gg-bar")},
					"baz": {ids.ID("gg-baz")},
				},
			},
			Groups: map[uuid.UUID]bool{
				ids.ID("gg-foo"): true,
				ids.ID("gg-bar"): false,
			},
			assertGroups: &orgGroupAssert{
				ExpectedGroups: []uuid.UUID{
					ids.ID("gg-foo"),
				},
			},
		},
		{
			Name: "UserJoinsGroups",
			GroupSettings: &codersdk.GroupSyncSettings{
				Field: "groups",
				Mapping: map[string][]uuid.UUID{
					"foo": {ids.ID("ng-foo"), uuid.New()},
					"bar": {ids.ID("ng-bar"), ids.ID("ng-bar-2")},
					"baz": {ids.ID("ng-baz")},
				},
			},
			Groups: map[uuid.UUID]bool{
				ids.ID("ng-foo"):   false,
				ids.ID("ng-bar"):   false,
				ids.ID("ng-bar-2"): false,
				ids.ID("ng-baz"):   false,
			},
			assertGroups: &orgGroupAssert{
				ExpectedGroups: []uuid.UUID{
					ids.ID("ng-foo"),
					ids.ID("ng-bar"),
					ids.ID("ng-bar-2"),
					ids.ID("ng-baz"),
				},
			},
		},
		{
			Name: "CreateGroups",
			GroupSettings: &codersdk.GroupSyncSettings{
				Field:             "groups",
				RegexFilter:       regexp.MustCompile("^create"),
				AutoCreateMissing: true,
			},
			Groups: map[uuid.UUID]bool{},
			assertGroups: &orgGroupAssert{
				ExpectedGroupNames: []string{
					"create-bar",
					"create-baz",
				},
			},
		},
		{
			Name: "GroupNamesNoMapping",
			GroupSettings: &codersdk.GroupSyncSettings{
				Field:             "groups",
				RegexFilter:       regexp.MustCompile(".*"),
				AutoCreateMissing: false,
			},
			GroupNames: map[string]bool{
				"foo":  false,
				"bar":  false,
				"goob": true,
			},
			assertGroups: &orgGroupAssert{
				ExpectedGroupNames: []string{
					"foo",
					"bar",
				},
			},
		},
		{
			Name: "NoUser",
			GroupSettings: &codersdk.GroupSyncSettings{
				Field: "groups",
				Mapping: map[string][]uuid.UUID{
					// Extra ID that does not map to a group
					"foo": {ids.ID("ow-foo"), uuid.New()},
				},
				RegexFilter:       nil,
				AutoCreateMissing: false,
			},
			NotMember: true,
			Groups: map[uuid.UUID]bool{
				ids.ID("ow-foo"): false,
				ids.ID("ow-bar"): false,
			},
		},
		{
			Name:          "NoSettings",
			GroupSettings: nil,
			Groups:        map[uuid.UUID]bool{},
			assertGroups: &orgGroupAssert{
				ExpectedGroups: []uuid.UUID{},
			},
		},
		{
			Name: "LegacyMapping",
			GroupSettings: &codersdk.GroupSyncSettings{
				Field:       "groups",
				RegexFilter: regexp.MustCompile("^legacy"),
				LegacyNameMapping: map[string]string{
					"create-bar": "legacy-bar",
					"foo":        "legacy-foo",
					"bop":        "legacy-bop",
				},
				AutoCreateMissing: true,
			},
			Groups: map[uuid.UUID]bool{
				ids.ID("lg-foo"): true,
			},
			GroupNames: map[string]bool{
				"legacy-foo": false,
				"extra":      true,
				"legacy-bop": true,
			},
			assertGroups: &orgGroupAssert{
				ExpectedGroupNames: []string{
					"legacy-bar",
					"legacy-foo",
				},
			},
		},
	}

	for _, tc := range testCases {
		// The final test, "AllTogether", cannot run in parallel.
		// These tests are nearly instant using the memory db, so
		// this is still fast without being in parallel.
		//nolint:paralleltest, tparallel
		t.Run(tc.Name, func(t *testing.T) {
			db, _ := dbtestutil.NewDB(t)
			manager := runtimeconfig.NewManager()
			s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}),
				manager,
				idpsync.DeploymentSyncSettings{
					GroupField: "groups",
					Legacy: idpsync.DefaultOrgLegacySettings{
						GroupField: "groups",
						GroupMapping: map[string]string{
							"foo": "legacy-foo",
							"baz": "legacy-baz",
						},
						GroupFilter:         regexp.MustCompile("^legacy"),
						CreateMissingGroups: true,
					},
				},
			)

			ctx := testutil.Context(t, testutil.WaitSuperLong)
			user := dbgen.User(t, db, database.User{})
			orgID := uuid.New()
			SetupOrganization(t, s, db, user, orgID, tc)

			// Do the group sync!
			err := s.SyncGroups(ctx, db, user, idpsync.GroupParams{
				SyncEntitled: true,
				MergedClaims: userClaims,
			})
			require.NoError(t, err)

			tc.Assert(t, orgID, db, user)
		})
	}

	// AllTogether runs the entire tabled test as a singular user and
	// deployment. This tests all organizations being synced together.
	// The reason we do them individually, is that it is much easier to
	// debug a single test case.
	//nolint:paralleltest, tparallel // This should run after all the individual tests
	t.Run("AllTogether", func(t *testing.T) {
		db, _ := dbtestutil.NewDB(t)
		manager := runtimeconfig.NewManager()
		s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}),
			manager,
			// Also sync the default org!
			idpsync.DeploymentSyncSettings{
				GroupField: "groups",
				// This legacy field will fail any tests if the legacy override code
				// has any bugs.
				Legacy: idpsync.DefaultOrgLegacySettings{
					GroupField: "groups",
					GroupMapping: map[string]string{
						"foo": "legacy-foo",
						"baz": "legacy-baz",
					},
					GroupFilter:         regexp.MustCompile("^legacy"),
					CreateMissingGroups: true,
				},
			},
		)

		ctx := testutil.Context(t, testutil.WaitSuperLong)
		user := dbgen.User(t, db, database.User{})

		var asserts []func(t *testing.T)
		// The default org is also going to do something
		def := orgSetupDefinition{
			Name: "DefaultOrg",
			GroupNames: map[string]bool{
				"legacy-foo": false,
				"legacy-baz": true,
				"random":     true,
			},
			// No settings, because they come from the deployment values
			GroupSettings: nil,
			assertGroups: &orgGroupAssert{
				ExpectedGroupNames: []string{"legacy-foo", "legacy-baz", "legacy-bar"},
			},
		}

		defOrg, err := db.GetDefaultOrganization(ctx)
		require.NoError(t, err)
		SetupOrganization(t, s, db, user, defOrg.ID, def)
		asserts = append(asserts, func(t *testing.T) {
			t.Run(def.Name, func(t *testing.T) {
				t.Parallel()
				def.Assert(t, defOrg.ID, db, user)
			})
		})

		for _, tc := range testCases {
			orgID := uuid.New()
			SetupOrganization(t, s, db, user, orgID, tc)
			asserts = append(asserts, func(t *testing.T) {
				t.Run(tc.Name, func(t *testing.T) {
					t.Parallel()
					tc.Assert(t, orgID, db, user)
				})
			})
		}

		asserts = append(asserts, func(t *testing.T) {
			t.Helper()
			def.Assert(t, defOrg.ID, db, user)
		})

		// Do the group sync!
		err = s.SyncGroups(ctx, db, user, idpsync.GroupParams{
			SyncEntitled: true,
			MergedClaims: userClaims,
		})
		require.NoError(t, err)

		for _, assert := range asserts {
			assert(t)
		}
	})
}

func TestSyncDisabled(t *testing.T) {
	t.Parallel()

	db, _ := dbtestutil.NewDB(t)
	manager := runtimeconfig.NewManager()
	s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}),
		manager,
		idpsync.DeploymentSyncSettings{},
	)

	ids := coderdtest.NewDeterministicUUIDGenerator()
	ctx := testutil.Context(t, testutil.WaitSuperLong)
	user := dbgen.User(t, db, database.User{})
	orgID := uuid.New()

	def := orgSetupDefinition{
		Name: "SyncDisabled",
		Groups: map[uuid.UUID]bool{
			ids.ID("foo"): true,
			ids.ID("bar"): true,
			ids.ID("baz"): false,
			ids.ID("bop"): false,
		},
		GroupSettings: &codersdk.GroupSyncSettings{
			Field: "groups",
			Mapping: map[string][]uuid.UUID{
				"foo": {ids.ID("foo")},
				"baz": {ids.ID("baz")},
			},
		},
		assertGroups: &orgGroupAssert{
			ExpectedGroups: []uuid.UUID{
				ids.ID("foo"),
				ids.ID("bar"),
			},
		},
	}

	SetupOrganization(t, s, db, user, orgID, def)

	// Do the group sync!
	err := s.SyncGroups(ctx, db, user, idpsync.GroupParams{
		SyncEntitled: false,
		MergedClaims: jwt.MapClaims{
			"groups": []string{"baz", "bop"},
		},
	})
	require.NoError(t, err)

	def.Assert(t, orgID, db, user)
}

// TestApplyGroupDifference is mainly testing the database functions
// TestSyncGroupsOutsideOrganization checks that a sync for one organization
// only adds the user to that organization's groups, whatever its settings
// map to, and that it logs a warning for each kind of invalid group setting
// it rejects. Organization A is the one whose settings are under test, and its
// mapping sends the IdP group "x" to the IDs in each case. Organization B
// holds the outside group.
func TestSyncGroupsOutsideOrganization(t *testing.T) {
	t.Parallel()

	ids := coderdtest.NewDeterministicUUIDGenerator()
	insideA1 := ids.ID("inside-a1")
	insideA2 := ids.ID("inside-a2")
	outsideB := ids.ID("outside-b")
	deleted := ids.ID("deleted")
	never := ids.ID("never")
	// namedX is a group in organization A whose name matches the IdP group,
	// so it is synced by name when "x" has no mapping.
	namedX := ids.ID("named-x")

	testCases := []struct {
		Name string
		// Mapping is what organization A maps the IdP group "x" to. When
		// empty, "x" has no mapping entry at all.
		Mapping []uuid.UUID
		// OtherSyncOn enables group sync in organization B. Its settings
		// map nothing, so it never expects the user in outsideB.
		OtherSyncOn bool
		// NotMemberOfOther leaves the user out of organization B.
		NotMemberOfOther bool
		// InOutsideGroup starts the user as a member of outsideB.
		InOutsideGroup bool

		ExpectA []uuid.UUID
		ExpectB []uuid.UUID
		Outside []uuid.UUID
		Missing []uuid.UUID
	}{
		{
			Name:    "Valid",
			Mapping: []uuid.UUID{insideA1, insideA2},
			ExpectA: []uuid.UUID{insideA1, insideA2},
		},
		{
			Name:    "OutsideNotAdded/OtherSyncOff",
			Mapping: []uuid.UUID{outsideB},
			Outside: []uuid.UUID{outsideB},
		},
		{
			Name:        "OutsideNotAdded/OtherSyncOn",
			Mapping:     []uuid.UUID{outsideB},
			OtherSyncOn: true,
			Outside:     []uuid.UUID{outsideB},
		},
		{
			Name:             "OutsideNotAdded/NotMemberOfOther",
			Mapping:          []uuid.UUID{outsideB},
			NotMemberOfOther: true,
			Outside:          []uuid.UUID{outsideB},
		},
		{
			Name:    "InsideAndOutside",
			Mapping: []uuid.UUID{insideA1, outsideB},
			ExpectA: []uuid.UUID{insideA1},
			Outside: []uuid.UUID{outsideB},
		},
		{
			Name:    "Missing/NeverExisted",
			Mapping: []uuid.UUID{never},
			Missing: []uuid.UUID{never},
		},
		{
			Name:    "Missing/Deleted",
			Mapping: []uuid.UUID{deleted},
			Missing: []uuid.UUID{deleted},
		},
		{
			Name:    "OnlyMissing",
			Mapping: []uuid.UUID{deleted, never},
			Missing: []uuid.UUID{deleted, never},
		},
		{
			// With no IDs to look up, nothing is partitioned out, and the
			// user is added only to the group matched by name. If the
			// inside groups were taken from an unfiltered lookup, the user
			// would be added to every group in the organization.
			Name:    "NoMappedUUIDs",
			ExpectA: []uuid.UUID{namedX},
		},
		{
			Name:    "AllThree",
			Mapping: []uuid.UUID{insideA1, outsideB, never},
			ExpectA: []uuid.UUID{insideA1},
			Outside: []uuid.UUID{outsideB},
			Missing: []uuid.UUID{never},
		},
		{
			// Organization B's own sync removes the membership, and
			// organization A no longer adds it back.
			Name:           "ExistingOutsideMembership/OtherSyncOn",
			Mapping:        []uuid.UUID{outsideB},
			OtherSyncOn:    true,
			InOutsideGroup: true,
			Outside:        []uuid.UUID{outsideB},
		},
		{
			// Nothing removes the membership. Cleaning up memberships
			// created before this check is out of scope.
			Name:           "ExistingOutsideMembership/OtherSyncOff",
			Mapping:        []uuid.UUID{outsideB},
			InOutsideGroup: true,
			ExpectB:        []uuid.UUID{outsideB},
			Outside:        []uuid.UUID{outsideB},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.Name, func(t *testing.T) {
			t.Parallel()

			db, _ := dbtestutil.NewDB(t)
			ctx := testutil.Context(t, testutil.WaitMedium)
			sink := testutil.NewFakeSink(t)
			s := idpsync.NewAGPLSync(sink.Logger(), runtimeconfig.NewManager(), idpsync.DeploymentSyncSettings{})
			user := dbgen.User(t, db, database.User{})

			var mappingA map[string][]uuid.UUID
			if len(tc.Mapping) > 0 {
				mappingA = map[string][]uuid.UUID{"x": tc.Mapping}
			}
			orgA, orgB := uuid.New(), uuid.New()
			SetupOrganization(t, s, db, user, orgA, orgSetupDefinition{
				GroupSettings: &codersdk.GroupSyncSettings{
					Field:   "groups",
					Mapping: mappingA,
				},
				Groups: map[uuid.UUID]bool{insideA1: false, insideA2: false},
			})
			dbgen.Group(t, db, database.Group{ID: namedX, Name: "x", OrganizationID: orgA})
			var settingsB *codersdk.GroupSyncSettings
			if tc.OtherSyncOn {
				settingsB = &codersdk.GroupSyncSettings{Field: "groups"}
			}
			SetupOrganization(t, s, db, user, orgB, orgSetupDefinition{
				GroupSettings: settingsB,
				NotMember:     tc.NotMemberOfOther,
				Groups:        map[uuid.UUID]bool{outsideB: tc.InOutsideGroup},
			})
			dbgen.Group(t, db, database.Group{ID: deleted, OrganizationID: orgA})
			require.NoError(t, db.DeleteGroupByID(ctx, deleted))

			err := s.SyncGroups(ctx, db, user, idpsync.GroupParams{
				SyncEntitled: true,
				MergedClaims: jwt.MapClaims{"groups": []string{"x"}},
			})
			require.NoError(t, err)

			(&orgGroupAssert{ExpectedGroups: tc.ExpectA}).Assert(t, orgA, db, user)
			(&orgGroupAssert{ExpectedGroups: tc.ExpectB}).Assert(t, orgB, db, user)
			requireInvalidGroupSettingWarning(t, sink, "outside", orgA, user.ID, tc.Outside)
			requireInvalidGroupSettingWarning(t, sink, "missing", orgA, user.ID, tc.Missing)
		})
	}
}

// requireInvalidGroupSettingWarning asserts that exactly one warning of the
// given kind names groupIDs, or that none was logged when groupIDs is empty.
func requireInvalidGroupSettingWarning(t *testing.T, sink *testutil.FakeSink, kind string, orgID, userID uuid.UUID, groupIDs []uuid.UUID) {
	t.Helper()

	entries := sink.Entries(func(e slog.SinkEntry) bool {
		return e.Level == slog.LevelWarn && sinkField(e, "invalid_group_setting") == kind
	})
	if len(groupIDs) == 0 {
		require.Empty(t, entries, "%s warnings", kind)
		return
	}
	require.Len(t, entries, 1, "%s warnings", kind)
	require.Equal(t, orgID, sinkField(entries[0], "organization_id"), "organization_id")
	require.Equal(t, userID, sinkField(entries[0], "user_id"), "user_id")
	require.ElementsMatch(t, groupIDs, sinkField(entries[0], "group_ids"), "group_ids")
}

func sinkField(e slog.SinkEntry, name string) any {
	for _, f := range e.Fields {
		if f.Name == name {
			return f.Value
		}
	}
	return nil
}

func TestApplyGroupDifference(t *testing.T) {
	t.Parallel()

	ids := coderdtest.NewDeterministicUUIDGenerator()
	testCase := []struct {
		Name   string
		Before map[uuid.UUID]bool
		Add    []uuid.UUID
		Remove []uuid.UUID
		Expect []uuid.UUID
	}{
		{
			Name: "Empty",
		},
		{
			Name: "AddFromNone",
			Before: map[uuid.UUID]bool{
				ids.ID("g1"): false,
			},
			Add: []uuid.UUID{
				ids.ID("g1"),
			},
			Expect: []uuid.UUID{
				ids.ID("g1"),
			},
		},
		{
			Name: "AddSome",
			Before: map[uuid.UUID]bool{
				ids.ID("g1"): true,
				ids.ID("g2"): false,
				ids.ID("g3"): false,
				uuid.New():   false,
			},
			Add: []uuid.UUID{
				ids.ID("g2"),
				ids.ID("g3"),
			},
			Expect: []uuid.UUID{
				ids.ID("g1"),
				ids.ID("g2"),
				ids.ID("g3"),
			},
		},
		{
			Name: "RemoveAll",
			Before: map[uuid.UUID]bool{
				uuid.New():   false,
				ids.ID("g2"): true,
				ids.ID("g3"): true,
			},
			Remove: []uuid.UUID{
				ids.ID("g2"),
				ids.ID("g3"),
			},
			Expect: []uuid.UUID{},
		},
		{
			Name: "Mixed",
			Before: map[uuid.UUID]bool{
				// adds
				ids.ID("a1"): true,
				ids.ID("a2"): true,
				ids.ID("a3"): false,
				ids.ID("a4"): false,
				// removes
				ids.ID("r1"): true,
				ids.ID("r2"): true,
				ids.ID("r3"): false,
				ids.ID("r4"): false,
				// stable
				ids.ID("s1"): true,
				ids.ID("s2"): true,
				// noise
				uuid.New(): false,
				uuid.New(): false,
			},
			Add: []uuid.UUID{
				ids.ID("a1"), ids.ID("a2"),
				ids.ID("a3"), ids.ID("a4"),
				// Double up to try and confuse
				ids.ID("a1"),
				ids.ID("a4"),
			},
			Remove: []uuid.UUID{
				ids.ID("r1"), ids.ID("r2"),
				ids.ID("r3"), ids.ID("r4"),
				// Double up to try and confuse
				ids.ID("r1"),
				ids.ID("r4"),
			},
			Expect: []uuid.UUID{
				ids.ID("a1"), ids.ID("a2"), ids.ID("a3"), ids.ID("a4"),
				ids.ID("s1"), ids.ID("s2"),
			},
		},
	}

	for _, tc := range testCase {
		t.Run(tc.Name, func(t *testing.T) {
			t.Parallel()

			mgr := runtimeconfig.NewManager()
			db, _ := dbtestutil.NewDB(t)

			ctx := testutil.Context(t, testutil.WaitMedium)

			org := dbgen.Organization(t, db, database.Organization{})
			_, err := db.InsertAllUsersGroup(ctx, org.ID)
			require.NoError(t, err)

			user := dbgen.User(t, db, database.User{})
			_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
				UserID:         user.ID,
				OrganizationID: org.ID,
			})

			for gid, in := range tc.Before {
				group := dbgen.Group(t, db, database.Group{
					ID:             gid,
					OrganizationID: org.ID,
				})
				if in {
					_ = dbgen.GroupMember(t, db, database.GroupMemberTable{
						UserID:  user.ID,
						GroupID: group.ID,
					})
				}
			}

			s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), mgr, idpsync.FromDeploymentValues(coderdtest.DeploymentValues(t)))
			err = s.ApplyGroupDifference(context.Background(), db, user, tc.Add, tc.Remove)
			require.NoError(t, err)

			userGroups, err := db.GetGroups(ctx, database.GetGroupsParams{
				HasMemberID: user.ID,
			})
			require.NoError(t, err)

			// assert
			found := slice.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
				return g.Group.ID
			})

			// Add everyone group
			require.ElementsMatch(t, append(tc.Expect, org.ID), found)
		})
	}
}

func TestExpectedGroupEqual(t *testing.T) {
	t.Parallel()

	ids := coderdtest.NewDeterministicUUIDGenerator()
	testCases := []struct {
		Name  string
		A     idpsync.ExpectedGroup
		B     idpsync.ExpectedGroup
		Equal bool
	}{
		{
			Name:  "Empty",
			A:     idpsync.ExpectedGroup{},
			B:     idpsync.ExpectedGroup{},
			Equal: true,
		},
		{
			Name: "DifferentOrgs",
			A: idpsync.ExpectedGroup{
				OrganizationID: uuid.New(),
				GroupID:        new(ids.ID("g1")),
				GroupName:      nil,
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: uuid.New(),
				GroupID:        new(ids.ID("g1")),
				GroupName:      nil,
			},
			Equal: false,
		},
		{
			Name: "SameID",
			A: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(ids.ID("g1")),
				GroupName:      nil,
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(ids.ID("g1")),
				GroupName:      nil,
			},
			Equal: true,
		},
		{
			Name: "DifferentIDs",
			A: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(uuid.New()),
				GroupName:      nil,
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(uuid.New()),
				GroupName:      nil,
			},
			Equal: false,
		},
		{
			Name: "SameName",
			A: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        nil,
				GroupName:      new("foo"),
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        nil,
				GroupName:      new("foo"),
			},
			Equal: true,
		},
		{
			Name: "DifferentName",
			A: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        nil,
				GroupName:      new("foo"),
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        nil,
				GroupName:      new("bar"),
			},
			Equal: false,
		},
		// Edge cases
		{
			// A bit strange, but valid as ID takes priority.
			// We assume 2 groups with the same ID are equal, even if
			// their names are different. Names are mutable, IDs are not,
			// so there is 0% chance they are different groups.
			Name: "DifferentIDSameName",
			A: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(ids.ID("g1")),
				GroupName:      new("foo"),
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(ids.ID("g1")),
				GroupName:      new("bar"),
			},
			Equal: true,
		},
		{
			Name: "MixedNils",
			A: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(ids.ID("g1")),
				GroupName:      nil,
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        nil,
				GroupName:      new("bar"),
			},
			Equal: false,
		},
		{
			Name: "NoComparable",
			A: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        new(ids.ID("g1")),
				GroupName:      nil,
			},
			B: idpsync.ExpectedGroup{
				OrganizationID: ids.ID("org"),
				GroupID:        nil,
				GroupName:      nil,
			},
			Equal: false,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.Name, func(t *testing.T) {
			t.Parallel()

			require.Equal(t, tc.Equal, tc.A.Equal(tc.B))
		})
	}
}

func SetupOrganization(t *testing.T, s *idpsync.AGPLIDPSync, db database.Store, user database.User, orgID uuid.UUID, def orgSetupDefinition) {
	t.Helper()

	// Account that the org might be the default organization
	org, err := db.GetOrganizationByID(context.Background(), orgID)
	if xerrors.Is(err, sql.ErrNoRows) {
		org = dbgen.Organization(t, db, database.Organization{
			ID:                    orgID,
			DefaultOrgMemberRoles: def.DefaultOrgMemberRoles,
		})
	}

	_, err = db.InsertAllUsersGroup(context.Background(), org.ID)
	if !database.IsUniqueViolation(err) {
		require.NoError(t, err, "Everyone group for an org")
	}

	manager := runtimeconfig.NewManager()
	orgResolver := manager.OrganizationResolver(db, org.ID)
	if def.GroupSettings != nil {
		err = s.Group.SetRuntimeValue(context.Background(), orgResolver, (*idpsync.GroupSyncSettings)(def.GroupSettings))
		require.NoError(t, err)
	}

	if def.RoleSettings != nil {
		err = s.Role.SetRuntimeValue(context.Background(), orgResolver, def.RoleSettings)
		require.NoError(t, err)
	}

	if !def.NotMember {
		dbgen.OrganizationMember(t, db, database.OrganizationMember{
			UserID:         user.ID,
			OrganizationID: org.ID,
		})
	}

	if len(def.OrganizationRoles) > 0 {
		_, err := db.UpdateMemberRoles(context.Background(), database.UpdateMemberRolesParams{
			GrantedRoles: def.OrganizationRoles,
			UserID:       user.ID,
			OrgID:        org.ID,
		})
		require.NoError(t, err)
	}

	if len(def.CustomRoles) > 0 {
		for _, cr := range def.CustomRoles {
			_, err := db.InsertCustomRole(context.Background(), database.InsertCustomRoleParams{
				Name:        cr,
				DisplayName: cr,
				OrganizationID: uuid.NullUUID{
					UUID:  org.ID,
					Valid: true,
				},
				SitePermissions: nil,
				OrgPermissions:  nil,
				UserPermissions: nil,
			})
			require.NoError(t, err)
		}
	}

	for groupID, in := range def.Groups {
		dbgen.Group(t, db, database.Group{
			ID:             groupID,
			OrganizationID: org.ID,
		})
		if in {
			dbgen.GroupMember(t, db, database.GroupMemberTable{
				UserID:  user.ID,
				GroupID: groupID,
			})
		}
	}
	for groupName, in := range def.GroupNames {
		group := dbgen.Group(t, db, database.Group{
			Name:           groupName,
			OrganizationID: org.ID,
		})
		if in {
			dbgen.GroupMember(t, db, database.GroupMemberTable{
				UserID:  user.ID,
				GroupID: group.ID,
			})
		}
	}
}

type orgSetupDefinition struct {
	Name string
	// True if the user is a member of the group
	Groups            map[uuid.UUID]bool
	GroupNames        map[string]bool
	OrganizationRoles []string
	CustomRoles       []string
	// DefaultOrgMemberRoles overrides the organization's default member
	// roles when non-empty.
	DefaultOrgMemberRoles []string
	// NotMember if true will ensure the user is not a member of the organization.
	NotMember bool

	GroupSettings *codersdk.GroupSyncSettings
	RoleSettings  *idpsync.RoleSyncSettings

	assertGroups *orgGroupAssert
	assertRoles  *orgRoleAssert
}

type orgRoleAssert struct {
	ExpectedOrgRoles []string
}

type orgGroupAssert struct {
	ExpectedGroups     []uuid.UUID
	ExpectedGroupNames []string
}

func (o orgSetupDefinition) Assert(t *testing.T, orgID uuid.UUID, db database.Store, user database.User) {
	t.Helper()

	ctx := context.Background()

	members, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
		OrganizationID: orgID,
		UserID:         user.ID,
	})
	require.NoError(t, err)
	if o.NotMember {
		require.Len(t, members, 0, "should not be a member")
	} else {
		require.Len(t, members, 1, "should be a member")
	}

	if o.assertGroups != nil {
		o.assertGroups.Assert(t, orgID, db, user)
	}
	if o.assertRoles != nil {
		o.assertRoles.Assert(t, orgID, db, o.NotMember, user)
	}

	// If the user is not a member, there is nothing to really assert in the org
	if o.assertGroups == nil && o.assertRoles == nil && !o.NotMember {
		t.Errorf("no group or role asserts present, must have at least one")
		t.FailNow()
	}
}

func (o *orgGroupAssert) Assert(t *testing.T, orgID uuid.UUID, db database.Store, user database.User) {
	t.Helper()

	ctx := context.Background()

	userGroups, err := db.GetGroups(ctx, database.GetGroupsParams{
		OrganizationID: orgID,
		HasMemberID:    user.ID,
	})
	require.NoError(t, err)
	if o.ExpectedGroups == nil {
		o.ExpectedGroups = make([]uuid.UUID, 0)
	}
	if len(o.ExpectedGroupNames) > 0 && len(o.ExpectedGroups) > 0 {
		t.Fatal("ExpectedGroups and ExpectedGroupNames are mutually exclusive")
	}

	// Everyone groups mess up our asserts
	userGroups = slices.DeleteFunc(userGroups, func(row database.GetGroupsRow) bool {
		return row.Group.ID == row.Group.OrganizationID
	})

	if len(o.ExpectedGroupNames) > 0 {
		found := slice.List(userGroups, func(g database.GetGroupsRow) string {
			return g.Group.Name
		})
		require.ElementsMatch(t, o.ExpectedGroupNames, found, "user groups by name")
		require.Len(t, o.ExpectedGroups, 0, "ExpectedGroups should be empty")
	} else {
		// Check by ID, recommended
		found := slice.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
			return g.Group.ID
		})
		require.ElementsMatch(t, o.ExpectedGroups, found, "user groups")
		require.Len(t, o.ExpectedGroupNames, 0, "ExpectedGroupNames should be empty")
	}
}

//nolint:revive
func (o orgRoleAssert) Assert(t *testing.T, orgID uuid.UUID, db database.Store, notMember bool, user database.User) {
	t.Helper()

	ctx := context.Background()

	members, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
		OrganizationID: orgID,
		UserID:         user.ID,
	})
	if notMember {
		require.ErrorIs(t, err, sql.ErrNoRows)
		return
	}
	require.NoError(t, err)
	require.Len(t, members, 1)
	member := members[0]
	require.ElementsMatch(t, member.OrganizationMember.Roles, o.ExpectedOrgRoles)
}