Files
coder/enterprise/coderd/scim_test.go
Steven Masley 4591212482 feat: implement SCIM handler for SCIM 2.0 compliance (#25572)
Rewrites the SCIM 2.0 user provisioning handler to be RFC 7644
compliant. Verified against an external IdP Okta.

Behavior is OPT IN
2026-05-28 10:00:37 -05:00

722 lines
23 KiB
Go

package coderd_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/imulab/go-scim/pkg/v2/handlerutil"
"github.com/imulab/go-scim/pkg/v2/spec"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/legacyscim"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
//nolint:revive
func makeScimUser(t testing.TB) legacyscim.SCIMUser {
rstr, err := cryptorand.String(10)
require.NoError(t, err)
return legacyscim.SCIMUser{
UserName: rstr,
Name: struct {
GivenName string `json:"givenName"`
FamilyName string `json:"familyName"`
}{
GivenName: rstr,
FamilyName: rstr,
},
Emails: []struct {
Primary bool `json:"primary"`
Value string `json:"value" format:"email"`
Type string `json:"type"`
Display string `json:"display"`
}{
{Primary: true, Value: fmt.Sprintf("%s@coder.com", rstr)},
},
Active: ptr.Ref(true),
}
}
func setScimAuth(key []byte) func(*http.Request) {
return func(r *http.Request) {
r.Header.Set("Authorization", string(key))
}
}
// TestLegacyScim tests the legacy SCIM handler (imulab/go-scim based).
// This is a reduced set of integration tests verifying HTTP routing, auth,
// and core CRUD. Detailed handler logic is covered by the unit tests in
// enterprise/coderd/scim/scimusers_test.go.
//
//nolint:gocritic // SCIM authenticates via a special header and bypasses internal RBAC.
func TestLegacyScim(t *testing.T) {
t.Parallel()
t.Run("disabled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: []byte("hi"),
UseLegacySCIM: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{codersdk.FeatureSCIM: 0},
},
})
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusForbidden, res.StatusCode)
})
t.Run("noAuth", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: []byte("hi"),
UseLegacySCIM: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{codersdk.FeatureSCIM: 1},
},
})
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("postUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
mockAudit := audit.NewMock()
notifyEnq := &notificationstest.FakeEnqueuer{}
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Auditor: mockAudit,
NotificationsEnqueuer: notifyEnq,
},
SCIMAPIKey: scimAPIKey,
UseLegacySCIM: true,
AuditLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScimUser(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
var createdUser legacyscim.SCIMUser
err = json.NewDecoder(res.Body).Decode(&createdUser)
require.NoError(t, err)
assert.NotEmpty(t, createdUser.ID)
assert.Equal(t, sUser.UserName, createdUser.UserName)
// Verify user exists.
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: createdUser.UserName})
require.NoError(t, err)
require.Len(t, userRes.Users, 1)
assert.Equal(t, codersdk.LoginTypeOIDC, userRes.Users[0].LoginType)
// Verify audit log.
require.True(t, len(mockAudit.AuditLogs()) > 0)
// Verify no user admin notification (SCIM skips notifications).
require.Empty(t, notifyEnq.Sent())
})
t.Run("Duplicate", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
UseLegacySCIM: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScimUser(t)
// Create same user 3 times.
for i := 0; i < 3; i++ {
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
}
// Only 1 user should exist.
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.UserName})
require.NoError(t, err)
require.Len(t, userRes.Users, 1)
})
t.Run("patchUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
mockAudit := audit.NewMock()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{Auditor: mockAudit},
SCIMAPIKey: scimAPIKey,
UseLegacySCIM: true,
AuditLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
// Create user first.
sUser := makeScimUser(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var createdUser legacyscim.SCIMUser
err = json.NewDecoder(res.Body).Decode(&createdUser)
require.NoError(t, err)
// Suspend via PATCH.
mockAudit.ResetLogs()
sUser.Active = ptr.Ref(false)
res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+createdUser.ID, sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
// Verify suspended.
userRes, err := client.User(ctx, createdUser.ID)
require.NoError(t, err)
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
})
t.Run("putUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
mockAudit := audit.NewMock()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{Auditor: mockAudit},
SCIMAPIKey: scimAPIKey,
UseLegacySCIM: true,
AuditLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
// Create user first.
sUser := makeScimUser(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var createdUser legacyscim.SCIMUser
err = json.NewDecoder(res.Body).Decode(&createdUser)
require.NoError(t, err)
// Suspend via PUT.
mockAudit.ResetLogs()
sUser.Active = ptr.Ref(false)
res, err = client.Request(ctx, "PUT", "/scim/v2/Users/"+createdUser.ID, sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
// Verify suspended.
userRes, err := client.User(ctx, createdUser.ID)
require.NoError(t, err)
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
})
}
// scim2User is a minimal struct for decoding SCIM 2.0 user responses
// returned by the elimity-com/scim library.
type scim2User struct {
ID string `json:"id"`
UserName string `json:"userName"`
Active bool `json:"active"`
}
// scim2UserBody is the request body for SCIM 2.0 POST/PUT calls.
// Unlike the legacy handler, the elimity-com/scim library validates the
// "schemas" attribute against the core User schema URI and rejects bodies
// that omit it.
type scim2UserBody struct {
Schemas []string `json:"schemas"`
UserName string `json:"userName"`
Name struct {
GivenName string `json:"givenName"`
FamilyName string `json:"familyName"`
} `json:"name"`
Emails []struct {
Primary bool `json:"primary"`
Value string `json:"value"`
} `json:"emails"`
Active *bool `json:"active,omitempty"`
}
func makeScim2User(t testing.TB) scim2UserBody {
rstr, err := cryptorand.String(10)
require.NoError(t, err)
b := scim2UserBody{
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
UserName: rstr,
Active: ptr.Ref(true),
}
b.Name.GivenName = rstr
b.Name.FamilyName = rstr
b.Emails = []struct {
Primary bool `json:"primary"`
Value string `json:"value"`
}{{Primary: true, Value: fmt.Sprintf("%s@coder.com", rstr)}}
return b
}
// TestScim exercises the SCIM 2.0 handler through real HTTP routes. It
// mirrors TestLegacyScim's structure (disabled/noAuth/post/patch/put) and
// adds coverage for behavior unique to the v2 implementation: discovery
// endpoints, 409 Conflict on duplicate active users, suspended-user
// reactivation, GET by id, and DELETE.
//
//nolint:gocritic // SCIM authenticates via a special header and bypasses internal RBAC.
func TestScim(t *testing.T) {
t.Parallel()
t.Run("disabled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: []byte("hi"),
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{codersdk.FeatureSCIM: 0},
},
})
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusForbidden, res.StatusCode)
})
t.Run("noAuth", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: []byte("hi"),
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{codersdk.FeatureSCIM: 1},
},
})
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("discovery", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{codersdk.FeatureSCIM: 1},
},
})
for _, path := range []string{
"/scim/v2/ServiceProviderConfig",
"/scim/v2/ResourceTypes",
"/scim/v2/Schemas",
} {
res, err := client.Request(ctx, "GET", path, nil, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode, "discovery endpoint %s", path)
}
})
t.Run("postUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
mockAudit := audit.NewMock()
notifyEnq := &notificationstest.FakeEnqueuer{}
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Auditor: mockAudit,
NotificationsEnqueuer: notifyEnq,
},
SCIMAPIKey: scimAPIKey,
AuditLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScim2User(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
var created scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
assert.NotEmpty(t, created.ID)
assert.Equal(t, sUser.UserName, created.UserName)
assert.True(t, created.Active)
// Verify user exists.
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: created.UserName})
require.NoError(t, err)
require.Len(t, userRes.Users, 1)
assert.Equal(t, codersdk.LoginTypeOIDC, userRes.Users[0].LoginType)
// Verify audit log.
require.True(t, len(mockAudit.AuditLogs()) > 0)
// Verify no user admin notification (SCIM skips notifications).
require.Empty(t, notifyEnq.Sent())
})
t.Run("postUserConflict", func(t *testing.T) {
// SCIM 2.0 returns 409 Conflict on duplicate active user, unlike the
// legacy handler which returned 200 with the existing user.
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScim2User(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
assert.Equal(t, http.StatusConflict, res.StatusCode)
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.UserName})
require.NoError(t, err)
require.Len(t, userRes.Users, 1)
})
t.Run("postUserReactivatesSuspended", func(t *testing.T) {
// When the SCIM client deletes a user (which only suspends in Coder),
// posting the same user again should reactivate the existing row.
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScim2User(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
var created scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
_ = res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
require.NotEmpty(t, created.ID)
// Delete (suspends) the user.
res, err = client.Request(ctx, "DELETE", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
assert.Equal(t, http.StatusNoContent, res.StatusCode)
userRes, err := client.User(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
// Re-create. The handler should reactivate the existing row.
res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
var recreated scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&recreated))
_ = res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
assert.Equal(t, created.ID, recreated.ID, "recreate should reactivate the existing row, not create a new one")
assert.True(t, recreated.Active, "recreated user should be active in the SCIM response")
// The DB user moves from suspended → dormant on reactivate; the SCIM
// response reports both Active and Dormant as active=true.
userRes, err = client.User(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, codersdk.UserStatusDormant, userRes.Status)
})
t.Run("getUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScim2User(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
var created scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
_ = res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
res, err = client.Request(ctx, "GET", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var got scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&got))
assert.Equal(t, created.ID, got.ID)
assert.Equal(t, sUser.UserName, got.UserName)
})
t.Run("patchUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
mockAudit := audit.NewMock()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{Auditor: mockAudit},
SCIMAPIKey: scimAPIKey,
AuditLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScim2User(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
var created scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
_ = res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
// PATCH with replace op setting active=false.
mockAudit.ResetLogs()
patchBody := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{"op": "replace", "path": "active", "value": false},
},
}
res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+created.ID, patchBody, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
userRes, err := client.User(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
})
t.Run("putUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScim2User(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
var created scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
_ = res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
// PUT with active=false.
sUser.Active = ptr.Ref(false)
res, err = client.Request(ctx, "PUT", "/scim/v2/Users/"+created.ID, sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
userRes, err := client.User(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
})
t.Run("deleteUser", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
client, _ := coderdenttest.New(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
sUser := makeScim2User(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
var created scim2User
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
_ = res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
res, err = client.Request(ctx, "DELETE", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
require.NoError(t, err)
_ = res.Body.Close()
assert.Equal(t, http.StatusNoContent, res.StatusCode)
// Coder does not hard-delete users. The user should remain but be suspended.
userRes, err := client.User(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
})
}
func TestLegacyScimError(t *testing.T) {
t.Parallel()
// Demonstrates that we cannot use the standard errors
rw := httptest.NewRecorder()
_ = handlerutil.WriteError(rw, spec.ErrNotFound)
resp := rw.Result()
defer resp.Body.Close()
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
// Our error wrapper works
rw = httptest.NewRecorder()
_ = handlerutil.WriteError(rw, legacyscim.NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("not found")))
resp = rw.Result()
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
}