2025-01-09 12:46:36 +01:00
//go:build integration
package integration_test
import (
"context"
_ "embed"
2025-01-17 16:16:26 +01:00
"net/http"
"path"
"testing"
"time"
2025-01-14 15:44:41 +01:00
"github.com/muhlemmer/gu"
2025-01-09 12:46:36 +01:00
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
2025-01-17 16:16:26 +01:00
"golang.org/x/text/language"
"google.golang.org/grpc/codes"
2025-01-14 15:44:41 +01:00
"github.com/zitadel/zitadel/internal/api/scim/resources"
2025-01-09 12:46:36 +01:00
"github.com/zitadel/zitadel/internal/api/scim/schemas"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/scim"
2025-01-21 13:31:54 +01:00
"github.com/zitadel/zitadel/internal/test"
2025-01-09 12:46:36 +01:00
"github.com/zitadel/zitadel/pkg/grpc/management"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
//go:embed testdata/users_create_test_minimal.json
minimalUserJson [ ] byte
2025-01-14 15:44:41 +01:00
//go:embed testdata/users_create_test_minimal_inactive.json
minimalInactiveUserJson [ ] byte
2025-01-29 10:18:00 +01:00
//go:embed testdata/users_create_test_no_primary_email_phone.json
minimalNoPrimaryEmailPhoneUserJson [ ] byte
2025-01-09 12:46:36 +01:00
//go:embed testdata/users_create_test_full.json
fullUserJson [ ] byte
//go:embed testdata/users_create_test_missing_username.json
missingUserNameUserJson [ ] byte
//go:embed testdata/users_create_test_missing_name.json
missingNameUserJson [ ] byte
//go:embed testdata/users_create_test_missing_email.json
missingEmailUserJson [ ] byte
//go:embed testdata/users_create_test_invalid_password.json
invalidPasswordUserJson [ ] byte
//go:embed testdata/users_create_test_invalid_profile_url.json
invalidProfileUrlUserJson [ ] byte
//go:embed testdata/users_create_test_invalid_locale.json
invalidLocaleUserJson [ ] byte
//go:embed testdata/users_create_test_invalid_timezone.json
invalidTimeZoneUserJson [ ] byte
2025-01-21 13:31:54 +01:00
fullUser = & resources . ScimUser {
ExternalID : "701984" ,
UserName : "bjensen@example.com" ,
Name : & resources . ScimUserName {
Formatted : "Babs Jensen" , // DisplayName takes precedence in Zitadel
FamilyName : "Jensen" ,
GivenName : "Barbara" ,
MiddleName : "Jane" ,
HonorificPrefix : "Ms." ,
HonorificSuffix : "III" ,
} ,
DisplayName : "Babs Jensen" ,
NickName : "Babs" ,
ProfileUrl : test . Must ( schemas . ParseHTTPURL ( "http://login.example.com/bjensen" ) ) ,
Emails : [ ] * resources . ScimEmail {
{
Value : "bjensen@example.com" ,
Primary : true ,
} ,
} ,
Addresses : [ ] * resources . ScimAddress {
{
Type : "work" ,
StreetAddress : "100 Universal City Plaza" ,
Locality : "Hollywood" ,
Region : "CA" ,
PostalCode : "91608" ,
Country : "USA" ,
Formatted : "100 Universal City Plaza\nHollywood, CA 91608 USA" ,
Primary : true ,
} ,
{
Type : "home" ,
StreetAddress : "456 Hollywood Blvd" ,
Locality : "Hollywood" ,
Region : "CA" ,
PostalCode : "91608" ,
Country : "USA" ,
Formatted : "456 Hollywood Blvd\nHollywood, CA 91608 USA" ,
} ,
} ,
PhoneNumbers : [ ] * resources . ScimPhoneNumber {
{
Value : "+415555555555" ,
Primary : true ,
} ,
} ,
Ims : [ ] * resources . ScimIms {
{
Value : "someaimhandle" ,
Type : "aim" ,
} ,
{
Value : "twitterhandle" ,
Type : "X" ,
} ,
} ,
Photos : [ ] * resources . ScimPhoto {
{
Value : * test . Must ( schemas . ParseHTTPURL ( "https://photos.example.com/profilephoto/72930000000Ccne/F" ) ) ,
Type : "photo" ,
} ,
} ,
Roles : [ ] * resources . ScimRole {
{
Value : "my-role-1" ,
Display : "Rolle 1" ,
Type : "main-role" ,
Primary : true ,
} ,
{
Value : "my-role-2" ,
Display : "Rolle 2" ,
Type : "secondary-role" ,
Primary : false ,
} ,
} ,
Entitlements : [ ] * resources . ScimEntitlement {
{
Value : "my-entitlement-1" ,
Display : "Entitlement 1" ,
Type : "main-entitlement" ,
Primary : true ,
} ,
{
Value : "my-entitlement-2" ,
Display : "Entitlement 2" ,
Type : "secondary-entitlement" ,
Primary : false ,
} ,
} ,
Title : "Tour Guide" ,
PreferredLanguage : language . MustParse ( "en-US" ) ,
Locale : "en-US" ,
Timezone : "America/Los_Angeles" ,
Active : gu . Ptr ( true ) ,
}
2025-01-09 12:46:36 +01:00
)
func TestCreateUser ( t * testing . T ) {
tests := [ ] struct {
name string
body [ ] byte
ctx context . Context
2025-01-30 16:43:13 +01:00
orgID string
2025-01-14 15:44:41 +01:00
want * resources . ScimUser
2025-01-09 12:46:36 +01:00
wantErr bool
scimErrorType string
errorStatus int
zitadelErrID string
} {
{
name : "minimal user" ,
body : minimalUserJson ,
2025-01-14 15:44:41 +01:00
want : & resources . ScimUser {
UserName : "acmeUser1" ,
Name : & resources . ScimUserName {
FamilyName : "Ross" ,
GivenName : "Bethany" ,
} ,
Emails : [ ] * resources . ScimEmail {
{
Value : "user1@example.com" ,
Primary : true ,
} ,
} ,
} ,
} ,
{
name : "minimal inactive user" ,
body : minimalInactiveUserJson ,
want : & resources . ScimUser {
Active : gu . Ptr ( false ) ,
} ,
2025-01-09 12:46:36 +01:00
} ,
{
name : "full user" ,
body : fullUserJson ,
2025-01-21 13:31:54 +01:00
want : fullUser ,
2025-01-09 12:46:36 +01:00
} ,
2025-01-29 10:18:00 +01:00
{
name : "no primary email and phone" ,
body : minimalNoPrimaryEmailPhoneUserJson ,
want : & resources . ScimUser {
Emails : [ ] * resources . ScimEmail {
{
Value : "user1@example.com" ,
Primary : true ,
} ,
} ,
PhoneNumbers : [ ] * resources . ScimPhoneNumber {
{
Value : "+41711234567" ,
Primary : true ,
} ,
} ,
} ,
} ,
2025-01-09 12:46:36 +01:00
{
name : "missing userName" ,
wantErr : true ,
scimErrorType : "invalidValue" ,
body : missingUserNameUserJson ,
} ,
{
// this is an expected schema violation
name : "missing name" ,
wantErr : true ,
scimErrorType : "invalidValue" ,
body : missingNameUserJson ,
} ,
{
name : "missing email" ,
wantErr : true ,
scimErrorType : "invalidValue" ,
body : missingEmailUserJson ,
} ,
{
name : "password complexity violation" ,
wantErr : true ,
scimErrorType : "invalidValue" ,
body : invalidPasswordUserJson ,
} ,
{
name : "invalid profile url" ,
wantErr : true ,
scimErrorType : "invalidValue" ,
zitadelErrID : "SCIM-htturl1" ,
body : invalidProfileUrlUserJson ,
} ,
{
name : "invalid time zone" ,
wantErr : true ,
scimErrorType : "invalidValue" ,
body : invalidTimeZoneUserJson ,
} ,
{
name : "invalid locale" ,
wantErr : true ,
scimErrorType : "invalidValue" ,
body : invalidLocaleUserJson ,
} ,
{
name : "not authenticated" ,
body : minimalUserJson ,
ctx : context . Background ( ) ,
wantErr : true ,
errorStatus : http . StatusUnauthorized ,
} ,
{
name : "no permissions" ,
body : minimalUserJson ,
ctx : Instance . WithAuthorization ( CTX , integration . UserTypeNoPermission ) ,
wantErr : true ,
errorStatus : http . StatusNotFound ,
} ,
2025-01-30 16:43:13 +01:00
{
name : "another org" ,
body : minimalUserJson ,
orgID : SecondaryOrganization . OrganizationId ,
wantErr : true ,
errorStatus : http . StatusNotFound ,
} ,
2025-01-09 12:46:36 +01:00
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
ctx := tt . ctx
if ctx == nil {
ctx = CTX
}
2025-01-30 16:43:13 +01:00
orgID := tt . orgID
if orgID == "" {
orgID = Instance . DefaultOrg . Id
}
createdUser , err := Instance . Client . SCIM . Users . Create ( ctx , orgID , tt . body )
2025-01-09 12:46:36 +01:00
if ( err != nil ) != tt . wantErr {
t . Errorf ( "CreateUser() error = %v, wantErr %v" , err , tt . wantErr )
2025-01-27 13:36:07 +01:00
return
2025-01-09 12:46:36 +01:00
}
if err != nil {
statusCode := tt . errorStatus
if statusCode == 0 {
statusCode = http . StatusBadRequest
}
2025-01-09 15:12:13 +01:00
scimErr := scim . RequireScimError ( t , statusCode , err )
assert . Equal ( t , tt . scimErrorType , scimErr . Error . ScimType )
2025-01-09 12:46:36 +01:00
if tt . zitadelErrID != "" {
2025-01-09 15:12:13 +01:00
assert . Equal ( t , tt . zitadelErrID , scimErr . Error . ZitadelDetail . ID )
2025-01-09 12:46:36 +01:00
}
return
}
assert . NotEmpty ( t , createdUser . ID )
2025-01-14 15:44:41 +01:00
defer func ( ) {
_ , err = Instance . Client . UserV2 . DeleteUser ( CTX , & user . DeleteUserRequest { UserId : createdUser . ID } )
assert . NoError ( t , err )
} ( )
2025-01-09 12:46:36 +01:00
assert . EqualValues ( t , [ ] schemas . ScimSchemaType { "urn:ietf:params:scim:schemas:core:2.0:User" } , createdUser . Resource . Schemas )
assert . Equal ( t , schemas . ScimResourceTypeSingular ( "User" ) , createdUser . Resource . Meta . ResourceType )
2025-01-30 16:43:13 +01:00
assert . Equal ( t , "http://" + Instance . Host ( ) + path . Join ( schemas . HandlerPrefix , orgID , "Users" , createdUser . ID ) , createdUser . Resource . Meta . Location )
2025-01-09 12:46:36 +01:00
assert . Nil ( t , createdUser . Password )
2025-01-14 15:44:41 +01:00
if tt . want != nil {
2025-01-21 13:31:54 +01:00
if ! test . PartiallyDeepEqual ( tt . want , createdUser ) {
2025-01-14 15:44:41 +01:00
t . Errorf ( "CreateUser() got = %v, want %v" , createdUser , tt . want )
}
retryDuration , tick := integration . WaitForAndTickWithMaxDuration ( CTX , time . Minute )
require . EventuallyWithT ( t , func ( ttt * assert . CollectT ) {
// ensure the user is really stored and not just returned to the caller
fetchedUser , err := Instance . Client . SCIM . Users . Get ( CTX , Instance . DefaultOrg . Id , createdUser . ID )
require . NoError ( ttt , err )
2025-01-21 13:31:54 +01:00
if ! test . PartiallyDeepEqual ( tt . want , fetchedUser ) {
2025-01-14 15:44:41 +01:00
ttt . Errorf ( "GetUser() got = %v, want %v" , fetchedUser , tt . want )
}
} , retryDuration , tick )
}
2025-01-09 12:46:36 +01:00
} )
}
}
func TestCreateUser_duplicate ( t * testing . T ) {
createdUser , err := Instance . Client . SCIM . Users . Create ( CTX , Instance . DefaultOrg . Id , minimalUserJson )
require . NoError ( t , err )
_ , err = Instance . Client . SCIM . Users . Create ( CTX , Instance . DefaultOrg . Id , minimalUserJson )
2025-01-09 15:12:13 +01:00
scimErr := scim . RequireScimError ( t , http . StatusConflict , err )
assert . Equal ( t , "User already exists" , scimErr . Error . Detail )
2025-01-21 13:31:54 +01:00
assert . Equal ( t , "uniqueness" , scimErr . Error . ScimType )
2025-01-09 12:46:36 +01:00
_ , err = Instance . Client . UserV2 . DeleteUser ( CTX , & user . DeleteUserRequest { UserId : createdUser . ID } )
require . NoError ( t , err )
}
func TestCreateUser_metadata ( t * testing . T ) {
createdUser , err := Instance . Client . SCIM . Users . Create ( CTX , Instance . DefaultOrg . Id , fullUserJson )
require . NoError ( t , err )
2025-01-14 15:44:41 +01:00
defer func ( ) {
_ , err = Instance . Client . UserV2 . DeleteUser ( CTX , & user . DeleteUserRequest { UserId : createdUser . ID } )
require . NoError ( t , err )
} ( )
2025-01-09 12:46:36 +01:00
2025-01-14 15:44:41 +01:00
retryDuration , tick := integration . WaitForAndTickWithMaxDuration ( CTX , time . Minute )
require . EventuallyWithT ( t , func ( tt * assert . CollectT ) {
md , err := Instance . Client . Mgmt . ListUserMetadata ( CTX , & management . ListUserMetadataRequest {
Id : createdUser . ID ,
} )
require . NoError ( tt , err )
2025-01-09 12:46:36 +01:00
2025-01-14 15:44:41 +01:00
mdMap := make ( map [ string ] string )
for i := range md . Result {
mdMap [ md . Result [ i ] . Key ] = string ( md . Result [ i ] . Value )
}
2025-01-09 12:46:36 +01:00
2025-01-21 13:31:54 +01:00
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:name.honorificPrefix" , "Ms." )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:timezone" , "America/Los_Angeles" )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:photos" , ` [ { "value":"https://photos.example.com/profilephoto/72930000000Ccne/F","type":"photo"}, { "value":"https://photos.example.com/profilephoto/72930000000Ccne/T","type":"thumbnail"}] ` )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:addresses" , ` [ { "type":"work","streetAddress":"100 Universal City Plaza","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"100 Universal City Plaza\nHollywood, CA 91608 USA","primary":true}, { "type":"home","streetAddress":"456 Hollywood Blvd","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"456 Hollywood Blvd\nHollywood, CA 91608 USA"}] ` )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:entitlements" , ` [ { "value":"my-entitlement-1","display":"Entitlement 1","type":"main-entitlement","primary":true}, { "value":"my-entitlement-2","display":"Entitlement 2","type":"secondary-entitlement"}] ` )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:externalId" , "701984" )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:name.middleName" , "Jane" )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:name.honorificSuffix" , "III" )
2025-01-27 14:51:58 +01:00
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:profileUrl" , "http://login.example.com/bjensen" )
2025-01-21 13:31:54 +01:00
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:title" , "Tour Guide" )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:locale" , "en-US" )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:ims" , ` [ { "value":"someaimhandle","type":"aim"}, { "value":"twitterhandle","type":"X"}] ` )
test . AssertMapContains ( tt , mdMap , "urn:zitadel:scim:roles" , ` [ { "value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true}, { "value":"my-role-2","display":"Rolle 2","type":"secondary-role"}] ` )
2025-01-14 15:44:41 +01:00
} , retryDuration , tick )
2025-01-09 12:46:36 +01:00
}
func TestCreateUser_scopedExternalID ( t * testing . T ) {
_ , err := Instance . Client . Mgmt . SetUserMetadata ( CTX , & management . SetUserMetadataRequest {
Id : Instance . Users . Get ( integration . UserTypeOrgOwner ) . ID ,
2025-01-27 14:51:58 +01:00
Key : "urn:zitadel:scim:provisioningDomain" ,
2025-01-09 12:46:36 +01:00
Value : [ ] byte ( "fooBar" ) ,
} )
require . NoError ( t , err )
createdUser , err := Instance . Client . SCIM . Users . Create ( CTX , Instance . DefaultOrg . Id , fullUserJson )
require . NoError ( t , err )
2025-01-14 15:44:41 +01:00
defer func ( ) {
_ , err = Instance . Client . UserV2 . DeleteUser ( CTX , & user . DeleteUserRequest { UserId : createdUser . ID } )
require . NoError ( t , err )
2025-01-09 12:46:36 +01:00
2025-01-14 15:44:41 +01:00
_ , err = Instance . Client . Mgmt . RemoveUserMetadata ( CTX , & management . RemoveUserMetadataRequest {
Id : Instance . Users . Get ( integration . UserTypeOrgOwner ) . ID ,
2025-01-27 14:51:58 +01:00
Key : "urn:zitadel:scim:provisioningDomain" ,
2025-01-14 15:44:41 +01:00
} )
require . NoError ( t , err )
} ( )
2025-01-09 12:46:36 +01:00
2025-01-14 15:44:41 +01:00
retryDuration , tick := integration . WaitForAndTickWithMaxDuration ( CTX , time . Minute )
require . EventuallyWithT ( t , func ( tt * assert . CollectT ) {
// unscoped externalID should not exist
_ , err = Instance . Client . Mgmt . GetUserMetadata ( CTX , & management . GetUserMetadataRequest {
Id : createdUser . ID ,
Key : "urn:zitadel:scim:externalId" ,
} )
integration . AssertGrpcStatus ( tt , codes . NotFound , err )
// scoped externalID should exist
md , err := Instance . Client . Mgmt . GetUserMetadata ( CTX , & management . GetUserMetadataRequest {
Id : createdUser . ID ,
Key : "urn:zitadel:scim:fooBar:externalId" ,
} )
require . NoError ( tt , err )
assert . Equal ( tt , "701984" , string ( md . Metadata . Value ) )
} , retryDuration , tick )
2025-01-09 12:46:36 +01:00
}