Files
coder/enterprise/coderd/license/usercount_test.go
T
Steven Masley 52423eb87b feat: promote MinimumImplicitMember experiment to GA (#27472)
Promotes the `minimum-implicit-member` experiment to GA and removes it.

## What changes

- The `minimum-implicit-member` experiment constant, its
`RoleOptions.MinimumImplicitMember` toggle, and the global
`rbac.MinimumImplicitMember()` accessor are deleted. The minimal-member
behavior is now the only behavior: `organization-member` and
`organization-service-account` carry only the floor (read-self records,
notifications, and similar) and grant **no workspace permissions**.
Workspace access lives exclusively on the
`organization-workspace-access` role.
- The experiment gate on customizing `default_org_member_roles` (`PATCH
/organizations/{org}`) is removed; the built-in-roles-only validation
remains.
- The dashboard's Default Roles section and the implied-roles display on
the members page are no longer experiment-gated.
- Admin docs: new "Default member roles" section in
`docs/admin/users/organizations.md`, cross-linked from
`groups-roles.md`.

## Why this is safe for existing deployments

Migration `000516` (shipped earlier) backfilled
`default_org_member_roles` with `['organization-workspace-access']` on
every organization. Members therefore keep exactly the effective
permissions they had with the experiment off; the workspace elevation
flows through the default role instead of being baked into
`organization-member`.

**Rollback caveat:** rolling back past this release restores the bundled
elevation, silently re-granting workspace access to members of
organizations that cleared their default roles.

## Review

Deep-review R1 findings are addressed in `chore: address deep-review
findings` (copy fixes, read-only Default Roles for viewers, removable
overlapping explicit grants, RBAC prose restoration, test
de-tautologizing, docs). Point-by-point disposition is in the PR
comments.

---

Generated by Coder Agents on behalf of @Emyrk.
2026-08-03 15:56:56 -05:00

670 lines
29 KiB
Go

package license_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
// TestCountWorkspaceCapableUsers verifies workspace-capable license seat
// counting: only users the RBAC engine authorizes to create workspaces
// consume seats, so members without workspace-create ("gateway accounts")
// are excluded.
func TestCountWorkspaceCapableUsers(t *testing.T) {
t.Parallel()
ctx := context.Background()
authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry())
activeUser := func(t *testing.T, db database.Store, seed database.User) database.User {
seed.Status = database.UserStatusActive
return dbgen.User(t, db, seed)
}
member := func(t *testing.T, db database.Store, orgID uuid.UUID, user database.User, roles ...string) {
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: orgID,
UserID: user.ID,
Roles: roles,
})
}
emptyDefaultRoles := func(t *testing.T, db database.Store, org database.Organization) {
_, err := db.UpdateOrganization(ctx, database.UpdateOrganizationParams{
ID: org.ID,
UpdatedAt: dbtime.Now(),
Name: org.Name,
DisplayName: org.DisplayName,
Description: org.Description,
Icon: org.Icon,
DefaultOrgMemberRoles: []string{},
})
require.NoError(t, err)
}
t.Run("DefaultRolesParity", func(t *testing.T) {
t.Parallel()
// Orgs keep the default default_org_member_roles, which include
// organization-workspace-access, so every active org member counts
// and the workspace-capable count matches the legacy count except
// for zero-org plain members.
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
plainMember := activeUser(t, db, database.User{})
member(t, db, org.ID, plainMember)
orgAdmin := activeUser(t, db, database.User{})
member(t, db, org.ID, orgAdmin, rbac.RoleOrgAdmin())
owner := activeUser(t, db, database.User{RBACRoles: []string{rbac.RoleOwner().Name}})
member(t, db, org.ID, owner)
// Counts under legacy, not under workspace-capable counting: no org, no
// workspace-create anywhere.
activeUser(t, db, database.User{})
// Counts under both: the owner site role grants workspace-create
// in any organization, independent of membership.
activeUser(t, db, database.User{RBACRoles: []string{rbac.RoleOwner().Name}})
// Never counted: not active.
suspended := dbgen.User(t, db, database.User{Status: database.UserStatusSuspended})
member(t, db, org.ID, suspended)
dormant := dbgen.User(t, db, database.User{Status: database.UserStatusDormant})
member(t, db, org.ID, dormant)
// Never counted: service accounts are excluded from seat counts.
sa := activeUser(t, db, database.User{IsServiceAccount: true})
member(t, db, org.ID, sa)
legacy, err := db.GetActiveUserCount(ctx, false)
require.NoError(t, err)
require.Equal(t, int64(5), legacy)
count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer)
require.NoError(t, err)
require.Equal(t, int64(4), count, "zero-org plain member must not count")
})
t.Run("EmptyDefaultRoles", func(t *testing.T) {
t.Parallel()
// organization-member carries no workspace permissions on its
// own. With default_org_member_roles cleared, workspace-create
// flows exclusively through an explicit
// organization-workspace-access grant.
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
emptyDefaultRoles(t, db, org)
// Gateway account: floor only, no workspace-create. Not counted.
gateway := activeUser(t, db, database.User{})
member(t, db, org.ID, gateway)
// Explicit organization-workspace-access grant. Counted.
wsUser := activeUser(t, db, database.User{})
member(t, db, org.ID, wsUser, rbac.RoleOrgWorkspaceAccess())
// The creation ban negates workspace-create even when the
// workspace-access role is present. Not counted.
banned := activeUser(t, db, database.User{})
member(t, db, org.ID, banned, rbac.RoleOrgWorkspaceAccess(), rbac.RoleOrgWorkspaceCreationBan())
// Org admins retain workspace-create. Counted.
orgAdmin := activeUser(t, db, database.User{})
member(t, db, org.ID, orgAdmin, rbac.RoleOrgAdmin())
// Owners retain workspace-create. Counted.
owner := activeUser(t, db, database.User{RBACRoles: []string{rbac.RoleOwner().Name}})
member(t, db, org.ID, owner)
// Members of an org that keeps organization-workspace-access in
// default_org_member_roles inherit workspace-create. Counted.
defaultOrg := dbgen.Organization(t, db, database.Organization{})
defaultMember := activeUser(t, db, database.User{})
member(t, db, defaultOrg.ID, defaultMember)
count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer)
require.NoError(t, err)
require.Equal(t, int64(4), count)
})
t.Run("MultiOrgSplitCapability", func(t *testing.T) {
t.Parallel()
// Users whose capability differs between their organizations:
// workspace-create in any one org is sufficient to be counted.
db, _ := dbtestutil.NewDB(t)
orgA := dbgen.Organization(t, db, database.Organization{})
orgB := dbgen.Organization(t, db, database.Organization{})
emptyDefaultRoles(t, db, orgA)
emptyDefaultRoles(t, db, orgB)
// Gateway in org A, workspace-create in org B. Counted.
split := activeUser(t, db, database.User{})
member(t, db, orgA.ID, split)
member(t, db, orgB.ID, split, rbac.RoleOrgWorkspaceAccess())
// The creation ban is scoped to org A and must not negate the
// org B grant. Counted.
bannedSplit := activeUser(t, db, database.User{})
member(t, db, orgA.ID, bannedSplit, rbac.RoleOrgWorkspaceAccess(), rbac.RoleOrgWorkspaceCreationBan())
member(t, db, orgB.ID, bannedSplit, rbac.RoleOrgWorkspaceAccess())
// Gateway in both orgs. Not counted.
gateway := activeUser(t, db, database.User{})
member(t, db, orgA.ID, gateway)
member(t, db, orgB.ID, gateway)
count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer)
require.NoError(t, err)
require.Equal(t, int64(2), count)
})
t.Run("CustomOrgRole", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
emptyDefaultRoles(t, db, org)
creatorRole, err := db.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: "workspace-creator",
DisplayName: "Workspace Creator",
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
OrgPermissions: []database.CustomRolePermission{{
ResourceType: rbac.ResourceWorkspace.Type,
Action: policy.ActionCreate,
}},
})
require.NoError(t, err)
auditRole, err := db.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: "org-reader",
DisplayName: "Org Reader",
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
OrgPermissions: []database.CustomRolePermission{{
ResourceType: rbac.ResourceOrganization.Type,
Action: policy.ActionRead,
}},
})
require.NoError(t, err)
// Custom org role with workspace-create. Counted.
creator := activeUser(t, db, database.User{})
member(t, db, org.ID, creator, creatorRole.Name)
// Custom org role without workspace-create. Not counted.
reader := activeUser(t, db, database.User{})
member(t, db, org.ID, reader, auditRole.Name)
count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer)
require.NoError(t, err)
require.Equal(t, int64(1), count)
})
t.Run("MalformedRoleNotCounted", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
// Authorization fails closed on an unparsable stored role, so
// this user is not workspace-capable even though their org
// membership would otherwise qualify them.
corrupt := activeUser(t, db, database.User{RBACRoles: []string{"bad:role:extra"}})
member(t, db, org.ID, corrupt)
// The bad row must not fail the count for everyone else.
capable := activeUser(t, db, database.User{})
member(t, db, org.ID, capable)
count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer)
require.NoError(t, err)
require.Equal(t, int64(1), count)
})
t.Run("EntitlementsAddonGate", func(t *testing.T) {
t.Parallel()
// Permission-based counting is gated on both the experiment and a
// valid license carrying the AI Governance addon. Without either,
// the legacy active user count applies.
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
emptyDefaultRoles(t, db, org)
gateway := activeUser(t, db, database.User{})
member(t, db, org.ID, gateway)
wsUser := activeUser(t, db, database.User{})
member(t, db, org.ID, wsUser, rbac.RoleOrgWorkspaceAccess())
enablements := map[codersdk.FeatureName]bool{}
experimentOn := codersdk.Experiments{codersdk.ExperimentWorkspaceCapableLicensing}
// No license: legacy count, even with the experiment on.
entitlements, err := license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn)
require.NoError(t, err)
require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
// License without the AI Governance addon: still the legacy count.
_, err = db.InsertLicense(ctx, database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}),
Exp: dbtime.Now().Add(time.Hour),
})
require.NoError(t, err)
entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn)
require.NoError(t, err)
require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
// License with the AI Governance addon: only the workspace-capable
// user counts.
_, err = db.InsertLicense(ctx, database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, *(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).AIGovernanceAddon(10)),
Exp: dbtime.Now().Add(time.Hour),
})
require.NoError(t, err)
entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
require.Equal(t, int64(1), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
// Addon present but experiment off: legacy count.
entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, nil)
require.NoError(t, err)
require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
// Addon present, experiment on, but no authorizer: fall back to the
// legacy count instead of failing.
entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, experimentOn)
require.NoError(t, err)
require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
})
t.Run("LicensesEntitlementsCountFn", func(t *testing.T) {
t.Parallel()
// Exercises LicensesEntitlements directly: the count function is
// only invoked when a valid license carries the addon, grace
// period licenses still gate the count, and count errors fall
// back to the legacy count with a recorded error.
now := time.Now()
enablements := map[codersdk.FeatureName]bool{}
dbLicense := func(opts coderdenttest.LicenseOptions) database.License {
return database.License{
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, opts),
Exp: now.Add(time.Hour * 24 * 60),
}
}
addonLicense := func() database.License {
return dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).Valid(now).AIGovernanceAddon(10))
}
t.Run("NoAddonFnNotCalled", func(t *testing.T) {
licenses := []database.License{dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).Valid(now))}
entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
t.Fatal("count fn must not be called without the addon")
return 0, nil
},
})
require.NoError(t, err)
require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
})
t.Run("AddonMissingDependenciesIgnored", func(t *testing.T) {
// A license carrying the addon without its required features
// records a validation error and the addon is skipped, so
// workspace-capable counting must not activate.
opts := (&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).Valid(now)
opts.Addons = append(opts.Addons, codersdk.AddonAIGovernance)
entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{dbLicense(*opts)}, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
t.Fatal("count fn must not be called when addon dependencies are unmet")
return 0, nil
},
})
require.NoError(t, err)
require.NotEmpty(t, entitlements.Errors)
require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
})
t.Run("ActiveModeIgnoresFn", func(t *testing.T) {
// UserCountingMode is authoritative: with the mode left at its
// active-users zero value, the counting function must not be
// called even though it is set and the addon is present.
entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
t.Fatal("count fn must not be called in active counting mode")
return 0, nil
},
})
require.NoError(t, err)
require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit)
})
t.Run("AddonUsesFn", func(t *testing.T) {
entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
ActiveAISeatCount: 5,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 3, nil
},
})
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
// Permission-based counting applies to workspace seats only:
// AI Governance seats keep their own count and limit.
aiSeats := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.Equal(t, int64(5), *aiSeats.Actual)
require.Equal(t, int64(10), *aiSeats.Limit)
// Under the limit: no user-limit warning, even though the
// legacy active user count would also have been under it.
for _, warning := range entitlements.Warnings {
require.NotContains(t, warning, "users but")
}
// A fully valid addon license must not warn about the
// counting-mode revert.
for _, warning := range entitlements.Warnings {
require.NotContains(t, warning, "fully expires")
}
})
t.Run("BestPairSelection", func(t *testing.T) {
// A deployment holding both an addon license and a non-addon
// license has two user_limit candidates, each evaluated with
// its own counting mode. Limits and modes never mix.
licenses := []database.License{
dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 200},
}).Valid(now)),
addonLicense(), // user_limit 100, AI Governance addon.
}
run := func(t *testing.T, activeUsers, capableUsers int64) codersdk.Entitlements {
entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: activeUsers,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return capableUsers, nil
},
})
require.NoError(t, err)
return entitlements
}
t.Run("LegacyPairCompliant", func(t *testing.T) {
// 180 active <= 200 wins over 150 capable > 100: the
// non-addon license keeps the deployment compliant.
entitlements := run(t, 180, 150)
require.Equal(t, int64(180), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Equal(t, int64(200), *entitlements.Features[codersdk.FeatureUserLimit].Limit)
for _, warning := range entitlements.Warnings {
require.NotContains(t, warning, "users but")
}
})
t.Run("AddonPairCompliant", func(t *testing.T) {
// 90 capable <= 100 wins over 250 active > 200: the addon
// license keeps the deployment compliant.
entitlements := run(t, 250, 90)
require.Equal(t, int64(90), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit)
for _, warning := range entitlements.Warnings {
require.NotContains(t, warning, "users but")
}
})
t.Run("NeitherPairCompliant", func(t *testing.T) {
// Both pairs over: the higher limit is reported, with the
// counting mode of its own license.
entitlements := run(t, 250, 150)
require.Equal(t, int64(250), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Equal(t, int64(200), *entitlements.Features[codersdk.FeatureUserLimit].Limit)
require.Contains(t, entitlements.Warnings,
"Your deployment has 250 active users but is only licensed for 200.")
})
t.Run("GraceAddonCompliantBeatsEntitledOver", func(t *testing.T) {
// A grace-period addon pair that fits its count wins over an
// entitled non-addon pair that does not, carrying its grace
// entitlement and the revert warning with it.
licenses := []database.License{
dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 200},
}).Valid(now)),
dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).GracePeriod(now).AIGovernanceAddon(10)),
}
entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 250,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 90, nil
},
})
require.NoError(t, err)
require.Equal(t, int64(90), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit)
require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
require.Contains(t, entitlements.Warnings,
"Your deployment has 90 workspace-capable users but the license with the limit 100 is expired.")
require.Contains(t, entitlements.Warnings,
"Your license with the AI Governance addon is expired. When it fully expires, all 250 active users will count toward the user limit instead of the 90 workspace-capable users.")
})
t.Run("EqualLimitsPreferAddon", func(t *testing.T) {
// Identical limit and entitlement on an addon and a
// non-addon license: the addon pair wins the tie, so the
// workspace-capable count is displayed.
licenses := []database.License{
dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).Valid(now)),
addonLicense(),
}
entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 80,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 30, nil
},
})
require.NoError(t, err)
require.Equal(t, int64(30), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit)
})
t.Run("TwoAddonCandidates", func(t *testing.T) {
// Two addon licenses: the entitled higher-limit pair fits
// the capable count and wins over the grace pair, and its
// presence suppresses the revert warning.
licenses := []database.License{
dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).GracePeriod(now).AIGovernanceAddon(10)),
dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 300},
}).Valid(now).AIGovernanceAddon(10)),
}
entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 500,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 150, nil
},
})
require.NoError(t, err)
require.Equal(t, int64(150), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Equal(t, int64(300), *entitlements.Features[codersdk.FeatureUserLimit].Limit)
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
for _, warning := range entitlements.Warnings {
require.NotContains(t, warning, "fully expires")
require.NotContains(t, warning, "users but")
}
})
})
t.Run("ModeWithoutFnIsDevError", func(t *testing.T) {
_, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
})
require.ErrorContains(t, err, "dev error")
})
t.Run("OverLimitWarnsWithCapableCount", func(t *testing.T) {
// The over-limit warning must report the workspace-capable
// count it was compared against, and say so, rather than
// claiming that many "active users" exist.
entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 150, nil
},
})
require.NoError(t, err)
require.Equal(t, int64(150), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Contains(t, entitlements.Warnings,
"Your deployment has 150 workspace-capable users but is only licensed for 100.")
})
t.Run("GracePeriodAddonUsesFn", func(t *testing.T) {
// A license in its grace period still includes the addon, so
// counting must not revert until the license hard-expires.
licenses := []database.License{dbLicense(*(&coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserLimit: 100},
}).GracePeriod(now).AIGovernanceAddon(10))}
entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 3, nil
},
})
require.NoError(t, err)
require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual)
require.Contains(t, entitlements.Warnings,
"Your deployment has 3 workspace-capable users but the license with the limit 100 is expired.")
// The revert warning gives admins the legacy count they will be
// measured by once the grace period ends.
require.Contains(t, entitlements.Warnings,
"Your license with the AI Governance addon is expired. When it fully expires, all 7 active users will count toward the user limit instead of the 3 workspace-capable users.")
})
t.Run("FnErrorPropagates", func(t *testing.T) {
// A failed capable count aborts the computation, matching the
// legacy active-user-count error semantics; the caller keeps
// the previous entitlements rather than seeing a silently
// different count.
_, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 0, xerrors.New("boom")
},
})
require.ErrorContains(t, err, "count workspace capable users")
require.ErrorContains(t, err, "boom")
})
t.Run("ContextCanceledBails", func(t *testing.T) {
_, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{
ActiveUserCount: 7,
UserCountingMode: license.UserCountingModeWorkspaceCapable,
WorkspaceCapableUserCountFn: func(context.Context) (int64, error) {
return 0, context.Canceled
},
})
require.ErrorIs(t, err, context.Canceled)
})
})
}
// TestCountWorkspaceCapableUsersErrors covers the count's database
// failure paths, which abort the count rather than skewing it.
func TestCountWorkspaceCapableUsersErrors(t *testing.T) {
t.Parallel()
ctx := context.Background()
authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry())
prefetchParams := database.CustomRolesParams{IncludeSystemRoles: true}
t.Run("NilAuthorizer", func(t *testing.T) {
t.Parallel()
mDB := dbmock.NewMockStore(gomock.NewController(t))
_, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, nil)
require.ErrorContains(t, err, "dev error")
})
t.Run("PrefetchError", func(t *testing.T) {
t.Parallel()
mDB := dbmock.NewMockStore(gomock.NewController(t))
mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return(nil, xerrors.New("boom"))
_, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, authorizer)
require.ErrorContains(t, err, "prefetch custom roles")
})
t.Run("RolesQueryError", func(t *testing.T) {
t.Parallel()
mDB := dbmock.NewMockStore(gomock.NewController(t))
mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return([]database.CustomRole{}, nil)
mDB.EXPECT().GetActiveUsersAuthorizationRoles(gomock.Any()).Return(nil, xerrors.New("boom"))
_, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, authorizer)
require.ErrorContains(t, err, "get active users authorization roles")
})
t.Run("ExpandLookupError", func(t *testing.T) {
t.Parallel()
// A custom role that was not prefetched (deleted, or created
// mid-count) is looked up individually; a database failure there
// aborts the count.
mDB := dbmock.NewMockStore(gomock.NewController(t))
userID := uuid.New()
orgID := uuid.New()
mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return([]database.CustomRole{}, nil)
mDB.EXPECT().GetActiveUsersAuthorizationRoles(gomock.Any()).Return([]database.GetActiveUsersAuthorizationRolesRow{{
ID: userID,
Roles: []string{"member", "dangling-role:" + orgID.String()},
}}, nil)
mDB.EXPECT().CustomRoles(gomock.Any(), gomock.Not(prefetchParams)).Return(nil, xerrors.New("boom"))
_, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, authorizer)
require.ErrorContains(t, err, "evaluate workspace-create for user "+userID.String())
require.ErrorContains(t, err, "expand roles")
})
}