mirror of
https://github.com/coder/coder.git
synced 2026-06-05 05:58:20 +00:00
4591212482
Rewrites the SCIM 2.0 user provisioning handler to be RFC 7644 compliant. Verified against an external IdP Okta. Behavior is OPT IN
761 lines
22 KiB
Go
761 lines
22 KiB
Go
package scim
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/elimity-com/scim"
|
|
scimErrors "github.com/elimity-com/scim/errors"
|
|
"github.com/google/uuid"
|
|
filter "github.com/scim2/filter-parser/v2"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/mock/gomock"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbmock"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
)
|
|
|
|
// setupSCIM creates a ResourceUser backed by a real database for testing.
|
|
// The returned mock auditor can be inspected for emitted audit logs.
|
|
func setupSCIM(t *testing.T) (*ResourceUser, database.Store, *audit.MockAuditor) {
|
|
t.Helper()
|
|
|
|
db, _ := dbtestutil.NewDB(t)
|
|
mockAudit := audit.NewMock()
|
|
auditorPtr := atomic.Pointer[audit.Auditor]{}
|
|
var a audit.Auditor = mockAudit
|
|
auditorPtr.Store(&a)
|
|
|
|
ru := &ResourceUser{
|
|
store: db,
|
|
opts: &Options{
|
|
DB: db,
|
|
Auditor: &auditorPtr,
|
|
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
|
|
},
|
|
}
|
|
return ru, db, mockAudit
|
|
}
|
|
|
|
// scimRequest builds an *http.Request with scim provisioner context,
|
|
// simulating the auth context that the SCIM middleware normally sets.
|
|
func scimRequest(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
ctx := dbauthz.AsSCIMProvisioner(context.Background())
|
|
return httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
}
|
|
|
|
// seedUser creates a user in the database for testing.
|
|
func seedUser(t *testing.T, db database.Store, opts database.User) database.User {
|
|
t.Helper()
|
|
return dbgen.User(t, db, opts)
|
|
}
|
|
|
|
// setupSCIMMock creates a ResourceUser backed by a gomock store for tests
|
|
// that only need to verify call patterns (e.g. audit emission) without
|
|
// real SQL.
|
|
func setupSCIMMock(t *testing.T) (*ResourceUser, *dbmock.MockStore, *audit.MockAuditor) {
|
|
t.Helper()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
mockStore := dbmock.NewMockStore(ctrl)
|
|
mockAudit := audit.NewMock()
|
|
auditorPtr := atomic.Pointer[audit.Auditor]{}
|
|
var a audit.Auditor = mockAudit
|
|
auditorPtr.Store(&a)
|
|
|
|
ru := &ResourceUser{
|
|
store: mockStore,
|
|
opts: &Options{
|
|
DB: mockStore,
|
|
Auditor: &auditorPtr,
|
|
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
|
|
},
|
|
}
|
|
return ru, mockStore, mockAudit
|
|
}
|
|
|
|
// --- Pure function tests (no DB) ---
|
|
|
|
func TestScimUserStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
boolPtr := func(b bool) *bool { return &b }
|
|
|
|
tests := []struct {
|
|
name string
|
|
status database.UserStatus
|
|
active *bool
|
|
expected database.UserStatus
|
|
}{
|
|
{"active+true=active", database.UserStatusActive, boolPtr(true), database.UserStatusActive},
|
|
{"active+false=suspended", database.UserStatusActive, boolPtr(false), database.UserStatusSuspended},
|
|
{"suspended+true=dormant", database.UserStatusSuspended, boolPtr(true), database.UserStatusDormant},
|
|
{"suspended+false=suspended", database.UserStatusSuspended, boolPtr(false), database.UserStatusSuspended},
|
|
{"dormant+true=dormant", database.UserStatusDormant, boolPtr(true), database.UserStatusDormant},
|
|
{"dormant+false=suspended", database.UserStatusDormant, boolPtr(false), database.UserStatusSuspended},
|
|
{"active+nil=active", database.UserStatusActive, nil, database.UserStatusActive},
|
|
{"suspended+nil=suspended", database.UserStatusSuspended, nil, database.UserStatusSuspended},
|
|
{"dormant+nil=dormant", database.UserStatusDormant, nil, database.UserStatusDormant},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
user := database.User{Status: tt.status}
|
|
got := scimUserStatus(user, tt.active)
|
|
assert.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrimaryEmail(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
attrs scim.ResourceAttributes
|
|
expected string
|
|
}{
|
|
{
|
|
name: "primary email",
|
|
attrs: scim.ResourceAttributes{
|
|
"emails": []interface{}{
|
|
map[string]interface{}{"value": "a@b.com", "primary": true},
|
|
},
|
|
},
|
|
expected: "a@b.com",
|
|
},
|
|
{
|
|
name: "fallback to first when no primary",
|
|
attrs: scim.ResourceAttributes{
|
|
"emails": []interface{}{
|
|
map[string]interface{}{"value": "first@b.com"},
|
|
},
|
|
},
|
|
expected: "first@b.com",
|
|
},
|
|
{
|
|
name: "picks primary over first",
|
|
attrs: scim.ResourceAttributes{
|
|
"emails": []interface{}{
|
|
map[string]interface{}{"value": "first@b.com"},
|
|
map[string]interface{}{"value": "primary@b.com", "primary": true},
|
|
},
|
|
},
|
|
expected: "primary@b.com",
|
|
},
|
|
{
|
|
name: "polluted",
|
|
attrs: scim.ResourceAttributes{
|
|
"emails": []interface{}{
|
|
// Try and cause a panic
|
|
"not-a-map",
|
|
true,
|
|
7,
|
|
map[int]interface{}{
|
|
1: "bad",
|
|
},
|
|
map[string]interface{}{
|
|
"value": 123, // value is not a string
|
|
},
|
|
map[string]interface{}{},
|
|
map[string]interface{}{"value": "first@b.com"},
|
|
map[string]interface{}{"value": "primary@b.com", "primary": true},
|
|
},
|
|
},
|
|
expected: "primary@b.com",
|
|
},
|
|
{
|
|
name: "no emails key",
|
|
attrs: scim.ResourceAttributes{},
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "empty emails",
|
|
attrs: scim.ResourceAttributes{"emails": []interface{}{}},
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "wrong type",
|
|
attrs: scim.ResourceAttributes{"emails": "not-a-list"},
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "case-insensitive top-level key",
|
|
attrs: scim.ResourceAttributes{
|
|
"Emails": []interface{}{
|
|
map[string]interface{}{"value": "a@b.com", "primary": true},
|
|
},
|
|
},
|
|
expected: "a@b.com",
|
|
},
|
|
{
|
|
name: "case-insensitive inner keys",
|
|
attrs: scim.ResourceAttributes{
|
|
"emails": []interface{}{
|
|
map[string]interface{}{"Value": "a@b.com", "Primary": true},
|
|
},
|
|
},
|
|
expected: "a@b.com",
|
|
},
|
|
{
|
|
name: "all caps keys",
|
|
attrs: scim.ResourceAttributes{
|
|
"EMAILS": []interface{}{
|
|
map[string]interface{}{"VALUE": "a@b.com", "PRIMARY": true},
|
|
},
|
|
},
|
|
expected: "a@b.com",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := primaryEmail(tt.attrs)
|
|
assert.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBooleanValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want bool
|
|
wantErr bool
|
|
}{
|
|
{"bool true", true, true, false},
|
|
{"bool false", false, false, false},
|
|
{"string true", "true", true, false},
|
|
{"string false", "false", false, false},
|
|
{"string True", "True", true, false},
|
|
{"int", 42, false, true},
|
|
{"nil", nil, false, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := booleanValue(tt.input)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAttribute(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
attrs scim.ResourceAttributes
|
|
key string
|
|
wantVal interface{}
|
|
wantOK bool
|
|
}{
|
|
{"exact match", scim.ResourceAttributes{"active": true}, "active", true, true},
|
|
{"capital first", scim.ResourceAttributes{"active": true}, "Active", true, true},
|
|
{"all caps", scim.ResourceAttributes{"active": true}, "ACTIVE", true, true},
|
|
{"camelCase key", scim.ResourceAttributes{"userName": "alice"}, "username", "alice", true},
|
|
{"camelCase swapped", scim.ResourceAttributes{"username": "alice"}, "userName", "alice", true},
|
|
{"missing key", scim.ResourceAttributes{"active": true}, "missing", nil, false},
|
|
{"empty map", scim.ResourceAttributes{}, "active", nil, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
val, ok := attribute(tt.attrs, tt.key)
|
|
assert.Equal(t, tt.wantOK, ok)
|
|
assert.Equal(t, tt.wantVal, val)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAttributeAsBool(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
attrs scim.ResourceAttributes
|
|
key string
|
|
want bool
|
|
wantOK bool
|
|
}{
|
|
{"exact key bool", scim.ResourceAttributes{"active": true}, "active", true, true},
|
|
{"mixed case bool", scim.ResourceAttributes{"active": false}, "Active", false, true},
|
|
{"all caps bool", scim.ResourceAttributes{"active": true}, "ACTIVE", true, true},
|
|
{"mixed case string true", scim.ResourceAttributes{"active": "true"}, "Active", true, true},
|
|
{"mixed case string false", scim.ResourceAttributes{"active": "false"}, "ACTIVE", false, true},
|
|
{"missing key", scim.ResourceAttributes{}, "active", false, false},
|
|
{"non-convertible", scim.ResourceAttributes{"active": 42}, "active", false, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, ok := attributeAsBool(tt.attrs, tt.key)
|
|
assert.Equal(t, tt.wantOK, ok)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAttributeAsString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
attrs scim.ResourceAttributes
|
|
key string
|
|
want string
|
|
wantOK bool
|
|
}{
|
|
{"exact key string", scim.ResourceAttributes{"userName": "alice"}, "userName", "alice", true},
|
|
{"mixed case string", scim.ResourceAttributes{"userName": "alice"}, "UserName", "alice", true},
|
|
{"lower case lookup", scim.ResourceAttributes{"userName": "alice"}, "username", "alice", true},
|
|
{"bool to string", scim.ResourceAttributes{"active": true}, "active", "true", true},
|
|
{"mixed case bool to string", scim.ResourceAttributes{"active": false}, "Active", "false", true},
|
|
{"missing key", scim.ResourceAttributes{}, "userName", "", false},
|
|
{"non-convertible", scim.ResourceAttributes{"count": 42}, "count", "", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, ok := attributeAsString(tt.attrs, tt.key)
|
|
assert.Equal(t, tt.wantOK, ok)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAttributeEqual(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("exact match same value", func(t *testing.T) {
|
|
t.Parallel()
|
|
attrs := scim.ResourceAttributes{"userName": "alice"}
|
|
assert.True(t, attributeEqual("alice", attrs, "userName"))
|
|
})
|
|
|
|
t.Run("mixed case same value", func(t *testing.T) {
|
|
t.Parallel()
|
|
attrs := scim.ResourceAttributes{"userName": "alice"}
|
|
assert.True(t, attributeEqual("alice", attrs, "UserName"))
|
|
})
|
|
|
|
t.Run("mixed case different value", func(t *testing.T) {
|
|
t.Parallel()
|
|
attrs := scim.ResourceAttributes{"userName": "bob"}
|
|
assert.False(t, attributeEqual("alice", attrs, "USERNAME"))
|
|
})
|
|
|
|
t.Run("missing key means no change", func(t *testing.T) {
|
|
t.Parallel()
|
|
attrs := scim.ResourceAttributes{}
|
|
assert.True(t, attributeEqual("alice", attrs, "userName"))
|
|
})
|
|
|
|
t.Run("type mismatch", func(t *testing.T) {
|
|
t.Parallel()
|
|
attrs := scim.ResourceAttributes{"userName": 42}
|
|
assert.False(t, attributeEqual("alice", attrs, "userName"))
|
|
})
|
|
}
|
|
|
|
// --- Handler tests (with DB) ---
|
|
|
|
func TestResourceUser_CaseInsensitive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ru, db, _ := setupSCIM(t)
|
|
|
|
// Seed an active user.
|
|
user := seedUser(t, db, database.User{
|
|
Status: database.UserStatusActive,
|
|
LoginType: database.LoginTypeOIDC,
|
|
})
|
|
|
|
r := scimRequest(t)
|
|
|
|
// Replace with "Active" (capital A) instead of "active".
|
|
res, err := ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
|
|
"userName": user.Username,
|
|
"Active": false,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, false, res.Attributes["active"])
|
|
|
|
// Confirm suspended via Get.
|
|
res, err = ru.Get(r, user.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, false, res.Attributes["active"])
|
|
|
|
// Patch back with map-style replace using "Active" key.
|
|
res, err = ru.Patch(r, user.ID.String(), []scim.PatchOperation{
|
|
{Op: "replace", Value: map[string]interface{}{"Active": true}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, true, res.Attributes["active"])
|
|
|
|
// Confirm reactivated via Get.
|
|
res, err = ru.Get(r, user.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, true, res.Attributes["active"])
|
|
}
|
|
|
|
func TestResourceUser_Create(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Coder does not hard-delete users. A SCIM Delete suspends the user, so
|
|
// when an IdP later re-creates the same user, the handler should match
|
|
// them by email/username and reactivate the existing row instead of
|
|
// returning 409 Conflict. See commit b3e6e0aa06.
|
|
|
|
t.Run("duplicate-active-conflict", func(t *testing.T) {
|
|
t.Parallel()
|
|
ru, db, _ := setupSCIM(t)
|
|
|
|
existing := seedUser(t, db, database.User{
|
|
Status: database.UserStatusActive,
|
|
LoginType: database.LoginTypeOIDC,
|
|
})
|
|
|
|
_, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
|
|
"userName": existing.Username,
|
|
"emails": []interface{}{
|
|
map[string]interface{}{"value": existing.Email, "primary": true},
|
|
},
|
|
"active": true,
|
|
})
|
|
require.Error(t, err)
|
|
var scimErr scimErrors.ScimError
|
|
require.ErrorAs(t, err, &scimErr)
|
|
assert.Equal(t, http.StatusConflict, scimErr.Status)
|
|
})
|
|
|
|
t.Run("suspended-user-reactivates", func(t *testing.T) {
|
|
t.Parallel()
|
|
ru, db, mockAudit := setupSCIM(t)
|
|
|
|
existing := seedUser(t, db, database.User{
|
|
Status: database.UserStatusSuspended,
|
|
LoginType: database.LoginTypeOIDC,
|
|
})
|
|
|
|
res, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
|
|
"userName": existing.Username,
|
|
"emails": []interface{}{
|
|
map[string]interface{}{"value": existing.Email, "primary": true},
|
|
},
|
|
"active": true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, existing.ID.String(), res.ID, "response should reference the existing user, not a new one")
|
|
|
|
// The SCIM response must reflect the post-update state so the IdP
|
|
// sees active=true after the recreate.
|
|
assert.Equal(t, true, res.Attributes["active"], "response should report the reactivated state")
|
|
|
|
// Suspended + active=true reactivates to Dormant (not Active) per scimUserStatus.
|
|
got, err := db.GetUserByID(dbauthz.AsSCIMProvisioner(context.Background()), existing.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, database.UserStatusDormant, got.Status, "suspended user should be marked dormant on recreate")
|
|
|
|
// Reactivation should emit one audit log for the status change.
|
|
assert.Len(t, mockAudit.AuditLogs(), 1)
|
|
})
|
|
|
|
t.Run("suspended-user-stays-suspended-when-active-false", func(t *testing.T) {
|
|
t.Parallel()
|
|
ru, db, mockAudit := setupSCIM(t)
|
|
|
|
existing := seedUser(t, db, database.User{
|
|
Status: database.UserStatusSuspended,
|
|
LoginType: database.LoginTypeOIDC,
|
|
})
|
|
|
|
res, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
|
|
"userName": existing.Username,
|
|
"emails": []interface{}{
|
|
map[string]interface{}{"value": existing.Email, "primary": true},
|
|
},
|
|
"active": false,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, existing.ID.String(), res.ID)
|
|
assert.Equal(t, false, res.Attributes["active"])
|
|
|
|
got, err := db.GetUserByID(dbauthz.AsSCIMProvisioner(context.Background()), existing.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, database.UserStatusSuspended, got.Status)
|
|
|
|
// No status change → no audit log.
|
|
assert.Empty(t, mockAudit.AuditLogs())
|
|
})
|
|
}
|
|
|
|
func TestResourceUser_Lifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ru, db, _ := setupSCIM(t)
|
|
|
|
// Seed an active user.
|
|
user := seedUser(t, db, database.User{
|
|
Status: database.UserStatusActive,
|
|
LoginType: database.LoginTypeOIDC,
|
|
})
|
|
|
|
r := scimRequest(t)
|
|
|
|
// Step 1: Get the user. Verify fields match.
|
|
res, err := ru.Get(r, user.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, user.ID.String(), res.ID)
|
|
assert.Equal(t, user.Username, res.Attributes["userName"])
|
|
assert.Equal(t, true, res.Attributes["active"])
|
|
|
|
// Step 2: Replace with active=false → suspended.
|
|
res, err = ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
|
|
"userName": user.Username,
|
|
"active": false,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, false, res.Attributes["active"])
|
|
|
|
// Step 3: Get → confirm inactive.
|
|
res, err = ru.Get(r, user.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, false, res.Attributes["active"])
|
|
|
|
// Step 4: Patch active=true → dormant (shown as active in SCIM).
|
|
res, err = ru.Patch(r, user.ID.String(), []scim.PatchOperation{
|
|
{Op: "replace", Path: mustPath("active"), Value: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, true, res.Attributes["active"])
|
|
|
|
// Step 5: Get → confirm active again.
|
|
res, err = ru.Get(r, user.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, true, res.Attributes["active"])
|
|
|
|
// Step 6: Delete → suspended.
|
|
err = ru.Delete(r, user.ID.String())
|
|
require.NoError(t, err)
|
|
|
|
// Step 7: Get → confirm inactive after delete.
|
|
res, err = ru.Get(r, user.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, false, res.Attributes["active"])
|
|
}
|
|
|
|
func TestResourceUser_GetAll(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ru, db, _ := setupSCIM(t)
|
|
|
|
// Seed 3 users.
|
|
for i := 0; i < 3; i++ {
|
|
seedUser(t, db, database.User{
|
|
LoginType: database.LoginTypeOIDC,
|
|
})
|
|
}
|
|
|
|
r := scimRequest(t)
|
|
|
|
// Get all with large count.
|
|
page, err := ru.GetAll(r, scim.ListRequestParams{Count: 100, StartIndex: 1})
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, page.TotalResults, 3)
|
|
assert.GreaterOrEqual(t, len(page.Resources), 3)
|
|
|
|
// Paginate: startIndex=2, count=1.
|
|
page, err = ru.GetAll(r, scim.ListRequestParams{Count: 1, StartIndex: 2})
|
|
require.NoError(t, err)
|
|
assert.Len(t, page.Resources, 1)
|
|
assert.GreaterOrEqual(t, page.TotalResults, 3)
|
|
}
|
|
|
|
func TestResourceUser_Errors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ru, _, _ := setupSCIM(t)
|
|
r := scimRequest(t)
|
|
missingUUID := uuid.New().String()
|
|
|
|
tests := []struct {
|
|
name string
|
|
run func() error
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "Get/non-UUID",
|
|
run: func() error { _, err := ru.Get(r, "not-a-uuid"); return err },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "Get/missing",
|
|
run: func() error { _, err := ru.Get(r, missingUUID); return err },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "Replace/non-UUID",
|
|
run: func() error { _, err := ru.Replace(r, "bad", scim.ResourceAttributes{}); return err },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "Replace/missing",
|
|
run: func() error { _, err := ru.Replace(r, missingUUID, scim.ResourceAttributes{}); return err },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "Replace/immutable-userName",
|
|
run: func() error {
|
|
// Need a real user for this test.
|
|
user := seedUser(t, ru.store, database.User{LoginType: database.LoginTypeOIDC})
|
|
_, err := ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
|
|
"userName": "different-name",
|
|
})
|
|
return err
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "Patch/non-UUID",
|
|
run: func() error { _, err := ru.Patch(r, "bad", nil); return err },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "Patch/missing",
|
|
run: func() error { _, err := ru.Patch(r, missingUUID, nil); return err },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "Delete/non-UUID",
|
|
run: func() error { return ru.Delete(r, "bad") },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "Delete/missing",
|
|
run: func() error { return ru.Delete(r, missingUUID) },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := tt.run()
|
|
require.Error(t, err)
|
|
var scimErr scimErrors.ScimError
|
|
require.ErrorAs(t, err, &scimErr)
|
|
assert.Equal(t, tt.wantStatus, scimErr.Status)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResourceUser_AuditLogs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// These tests use dbmock instead of a real database because they only
|
|
// verify audit emission logic (does an audit log fire when status
|
|
// changes?), not SQL correctness. The handlers call just GetUserByID
|
|
// and UpdateUserStatus, both trivially mockable.
|
|
|
|
makeUser := func(status database.UserStatus) (database.User, database.User) {
|
|
id := uuid.New()
|
|
user := database.User{
|
|
ID: id,
|
|
Username: "testuser",
|
|
Status: status,
|
|
LoginType: database.LoginTypeOIDC,
|
|
}
|
|
suspended := user
|
|
suspended.Status = database.UserStatusSuspended
|
|
return user, suspended
|
|
}
|
|
|
|
t.Run("Replace/status-change-emits-audit", func(t *testing.T) {
|
|
t.Parallel()
|
|
ru, mockStore, mockAudit := setupSCIMMock(t)
|
|
activeUser, suspendedUser := makeUser(database.UserStatusActive)
|
|
|
|
mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
|
|
mockStore.EXPECT().UpdateUserStatus(gomock.Any(), gomock.Any()).Return(suspendedUser, nil)
|
|
|
|
_, err := ru.Replace(scimRequest(t), activeUser.ID.String(), scim.ResourceAttributes{
|
|
"userName": activeUser.Username,
|
|
"active": false,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Len(t, mockAudit.AuditLogs(), 1)
|
|
})
|
|
|
|
t.Run("Replace/no-change-skips-audit", func(t *testing.T) {
|
|
t.Parallel()
|
|
ru, mockStore, mockAudit := setupSCIMMock(t)
|
|
activeUser, _ := makeUser(database.UserStatusActive)
|
|
|
|
mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
|
|
// No UpdateUserStatus expected: active=true on an already active user is a no-op.
|
|
|
|
_, err := ru.Replace(scimRequest(t), activeUser.ID.String(), scim.ResourceAttributes{
|
|
"userName": activeUser.Username,
|
|
"active": true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, mockAudit.AuditLogs())
|
|
})
|
|
|
|
t.Run("Delete/active-user-emits-audit", func(t *testing.T) {
|
|
t.Parallel()
|
|
ru, mockStore, mockAudit := setupSCIMMock(t)
|
|
activeUser, suspendedUser := makeUser(database.UserStatusActive)
|
|
|
|
mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
|
|
mockStore.EXPECT().UpdateUserStatus(gomock.Any(), gomock.Any()).Return(suspendedUser, nil)
|
|
|
|
err := ru.Delete(scimRequest(t), activeUser.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Len(t, mockAudit.AuditLogs(), 1)
|
|
})
|
|
|
|
t.Run("Delete/suspended-user-skips-audit", func(t *testing.T) {
|
|
t.Parallel()
|
|
ru, mockStore, mockAudit := setupSCIMMock(t)
|
|
_, suspendedUser := makeUser(database.UserStatusSuspended)
|
|
|
|
mockStore.EXPECT().GetUserByID(gomock.Any(), suspendedUser.ID).Return(suspendedUser, nil)
|
|
// No UpdateUserStatus expected: already suspended.
|
|
|
|
err := ru.Delete(scimRequest(t), suspendedUser.ID.String())
|
|
require.NoError(t, err)
|
|
assert.Empty(t, mockAudit.AuditLogs())
|
|
})
|
|
}
|
|
|
|
// mustPath parses a SCIM attribute path string into a *filter.Path
|
|
// for use in PatchOperation test data.
|
|
func mustPath(attr string) *filter.Path {
|
|
p, err := filter.ParsePath([]byte(attr))
|
|
if err != nil {
|
|
panic(fmt.Sprintf("mustPath(%q): %v", attr, err))
|
|
}
|
|
return &p
|
|
}
|