From 27779ba15344b39ba7a7e037c4695993f99f88f3 Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 09:26:43 +0000 Subject: [PATCH 1/9] User: change func String() from pointer to non-pointer --- User.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/User.go b/User.go index 7c5878e..9b734d0 100644 --- a/User.go +++ b/User.go @@ -41,7 +41,7 @@ type PasswordProfile struct { Password string `json:"password,omitempty"` } -func (u *User) String() string { +func (u User) String() string { return fmt.Sprintf("User(ID: \"%v\", BusinessPhones: \"%v\", DisplayName: \"%v\", GivenName: \"%v\", "+ "Mail: \"%v\", MobilePhone: \"%v\", PreferredLanguage: \"%v\", Surname: \"%v\", UserPrincipalName: \"%v\", "+ "ActivePhone: \"%v\", DirectAPIConnection: %v)", From f91348a7cd9d50f0695eabf25356cb0cb11f5ae1 Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 09:27:26 +0000 Subject: [PATCH 2/9] GraphClient: CreateUser: change parameter from pointer to non-pointer --- GraphClient.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GraphClient.go b/GraphClient.go index 8c862af..69ab8c8 100644 --- a/GraphClient.go +++ b/GraphClient.go @@ -282,7 +282,7 @@ func (g *GraphClient) GetGroup(groupID string, opts ...GetQueryOption) (Group, e // CreateUser creates a new user given a user object and returns and updated object // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user-post-users -func (g *GraphClient) CreateUser(userInput *User, opts ...CreateQueryOption) (User, error) { +func (g *GraphClient) CreateUser(userInput User, opts ...CreateQueryOption) (User, error) { user := User{graphClient: g} bodyBytes, err := json.Marshal(userInput) if err != nil { From 9a79e8bd3b9a70e36b9b8e0ecad9cd1107e04c92 Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 10:41:11 +0000 Subject: [PATCH 3/9] User: add unit test for String() --- User_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/User_test.go b/User_test.go index 93e8d46..4e719da 100644 --- a/User_test.go +++ b/User_test.go @@ -1,6 +1,7 @@ package msgraph import ( + "fmt" "testing" "time" ) @@ -134,3 +135,26 @@ func TestUser_ListCalendarView(t *testing.T) { }) } } + +func TestUser_String(t *testing.T) { + u := GetTestUser(t) + tt := struct { + name string + u *User + want string + }{ + name: "Test user func String", + u: &u, + want: fmt.Sprintf("User(ID: \"%v\", BusinessPhones: \"%v\", DisplayName: \"%v\", GivenName: \"%v\", "+ + "Mail: \"%v\", MobilePhone: \"%v\", PreferredLanguage: \"%v\", Surname: \"%v\", UserPrincipalName: \"%v\", "+ + "ActivePhone: \"%v\", DirectAPIConnection: %v)", + u.ID, u.BusinessPhones, u.DisplayName, u.GivenName, u.Mail, u.MobilePhone, u.PreferredLanguage, u.Surname, + u.UserPrincipalName, u.activePhone, u.graphClient != nil), + } + + t.Run(tt.name, func(t *testing.T) { + if got := tt.u.String(); got != tt.want { + t.Errorf("User.String() = %v, want %v", got, tt.want) + } + }) +} From e99e51117692e1ce160dd6ebb1bf1f5771a7b74c Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 10:42:03 +0000 Subject: [PATCH 4/9] GraphClient: correct documentation for make*APICall --- GraphClient.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/GraphClient.go b/GraphClient.go index 69ab8c8..c22ccf5 100644 --- a/GraphClient.go +++ b/GraphClient.go @@ -132,17 +132,17 @@ func (g *GraphClient) refreshToken() error { return err } -// makeGETAPICall performs an API-Call to the msgraph API. This func uses sync.Mutex to synchronize all API-calls +// makeGETAPICall performs an API-Call to the msgraph API. func (g *GraphClient) makeGETAPICall(apiCall string, reqParams getRequestParams, v interface{}) error { return g.makeAPICall(apiCall, http.MethodGet, reqParams, nil, v) } -// makeGETAPICall performs an API-Call to the msgraph API. This func uses sync.Mutex to synchronize all API-calls +// makeGETAPICall performs an API-Call to the msgraph API. func (g *GraphClient) makePOSTAPICall(apiCall string, reqParams getRequestParams, body io.Reader, v interface{}) error { return g.makeAPICall(apiCall, http.MethodPost, reqParams, body, v) } -// makePATCHAPICall performs an API-Call to the msgraph API. This func uses sync.Mutex to synchronize all API-calls +// makePATCHAPICall performs an API-Call to the msgraph API. func (g *GraphClient) makePATCHAPICall(apiCall string, reqParams getRequestParams, body io.Reader, v interface{}) error { return g.makeAPICall(apiCall, http.MethodPatch, reqParams, body, v) } From 37a8c3b651e9fb48db4b84eb944da1559eebecf9 Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 10:44:44 +0000 Subject: [PATCH 5/9] User: add func DeleteUser --- GraphClient.go | 8 ++++++++ GraphClient_queryOptions.go | 33 +++++++++++++++++++++++++++++++++ User.go | 14 ++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/GraphClient.go b/GraphClient.go index c22ccf5..fb8c92a 100644 --- a/GraphClient.go +++ b/GraphClient.go @@ -147,6 +147,11 @@ func (g *GraphClient) makePATCHAPICall(apiCall string, reqParams getRequestParam return g.makeAPICall(apiCall, http.MethodPatch, reqParams, body, v) } +// makeDELETEAPICall performs an API-Call to the msgraph API. +func (g *GraphClient) makeDELETEAPICall(apiCall string, reqParams getRequestParams, v interface{}) error { + return g.makeAPICall(apiCall, http.MethodDelete, reqParams, nil, v) +} + // makeAPICall performs an API-Call to the msgraph API. This func uses sync.Mutex to synchronize all API-calls. // // Parameter httpMethod may be http.MethodGet, http.MethodPost or http.MethodPatch @@ -223,6 +228,9 @@ func (g *GraphClient) performRequest(req *http.Request, v interface{}) error { return fmt.Errorf("HTTP response read error: %v of http.Request: %v", err, req.URL) } + if req.Method == http.MethodDelete { // no content returned when http DELETE is used, e.g. User.DeleteUser() + return nil + } return json.Unmarshal(body, &v) // return the error of the json unmarshal } diff --git a/GraphClient_queryOptions.go b/GraphClient_queryOptions.go index 4723936..d9d3bb3 100644 --- a/GraphClient_queryOptions.go +++ b/GraphClient_queryOptions.go @@ -20,6 +20,8 @@ type CreateQueryOption func(opts *createQueryOptions) type UpdateQueryOption func(opts *updateQueryOptions) +type DeleteQueryOption func(opts *deleteQueryOptions) + var ( // GetWithContext - add a context.Context to the HTTP request e.g. to allow cancellation GetWithContext = func(ctx context.Context) GetQueryOption { @@ -77,6 +79,12 @@ var ( opts.ctx = ctx } } + // DeleteWithContext - add a context.Context to the HTTP request e.g. to allow cancellation + DeleteWithContext = func(ctx context.Context) DeleteQueryOption { + return func(opts *deleteQueryOptions) { + opts.ctx = ctx + } + } ) // getQueryOptions allow to optionally pass OData query options @@ -197,3 +205,28 @@ func compileUpdateQueryOptions(options []UpdateQueryOption) *updateQueryOptions return opts } + +// deleteQueryOptions allows to add a context to the request +type deleteQueryOptions struct { + getQueryOptions +} + +func (g *deleteQueryOptions) Context() context.Context { + if g.ctx == nil { + return context.Background() + } + return g.ctx +} + +func compileDeleteQueryOptions(options []DeleteQueryOption) *deleteQueryOptions { + var opts = &deleteQueryOptions{ + getQueryOptions: getQueryOptions{ + queryValues: url.Values{}, + }, + } + for idx := range options { + options[idx](opts) + } + + return opts +} diff --git a/User.go b/User.go index 9b734d0..7039b69 100644 --- a/User.go +++ b/User.go @@ -167,6 +167,20 @@ func (u User) UpdateUser(userInput User, opts ...UpdateQueryOption) error { return err } +// DeleteUser deletes this user instance at the Microsoft Azure AD. Use with caution. +// +// Reference: https://docs.microsoft.com/en-us/graph/api/user-delete +func (u User) DeleteUser(opts ...DeleteQueryOption) error { + if u.graphClient == nil { + return ErrNotGraphClientSourced + } + resource := fmt.Sprintf("/users/%v", u.ID) + + // TODO: check return body, maybe there is some potential success or error message hidden in it? + err := u.graphClient.makeDELETEAPICall(resource, compileDeleteQueryOptions(opts), nil) + return err +} + // PrettySimpleString returns the User-instance simply formatted for logging purposes: {FullName (email) (activePhone)} func (u User) PrettySimpleString() string { return fmt.Sprintf("{ %v (%v) (%v) }", u.GetFullName(), u.Mail, u.GetActivePhone()) From 56ce6692412615044f0039507f9c9c35cc4474b9 Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 13:30:06 +0000 Subject: [PATCH 6/9] GraphClient: add unit tests to create and delete a User --- GraphClient_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/GraphClient_test.go b/GraphClient_test.go index 8261a82..297bb30 100644 --- a/GraphClient_test.go +++ b/GraphClient_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/rand" "net/url" "os" "reflect" @@ -33,6 +34,8 @@ var ( msGraphExistingCalendarsOfUser []string // the number of expected results when searching for the msGraphExistingGroupDisplayName with $search or $filter msGraphExistingGroupDisplayNameNumRes uint64 + // a domain-name for unit tests to create a user or other objects, e.g. contoso.com - omit the @ + msGraphDomainNameForCreateTests string // the graphclient used to perform all tests graphClient *GraphClient // marker if the calendar tests should be skipped - set if msGraphExistingCalendarsOfUser is empty @@ -53,6 +56,7 @@ func TestMain(m *testing.M) { msGraphClientSecret = getEnvOrPanic("MSGraphClientSecret") msGraphExistingGroupDisplayName = getEnvOrPanic("MSGraphExistingGroupDisplayName") msGraphExistingUserPrincipalInGroup = getEnvOrPanic("MSGraphExistingUserPrincipalInGroup") + msGraphDomainNameForCreateTests = getEnvOrPanic("MSGraphDomainNameForCreateTests") if msGraphAzureADAuthEndpoint = os.Getenv("MSGraphAzureADAuthEndpoint"); msGraphAzureADAuthEndpoint == "" { msGraphAzureADAuthEndpoint = AzureADAuthEndpointGlobal @@ -76,9 +80,20 @@ func TestMain(m *testing.M) { panic(fmt.Sprintf("Cannot initialize a new GraphClient, error: %v", err)) } + rand.Seed(time.Now().UnixNano()) + os.Exit(m.Run()) } +func randomString(n int) string { + var runes = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, n) + for i := range b { + b[i] = runes[rand.Intn(len(runes))] + } + return string(b) +} + func TestNewGraphClient(t *testing.T) { if msGraphAzureADAuthEndpoint != AzureADAuthEndpointGlobal || msGraphServiceRootEndpoint != ServiceRootEndpointGlobal { t.Skip("Skipping TestNewGraphClient because the endpoint is not the default - global - endpoint") @@ -557,6 +572,43 @@ func TestGraphClient_GetGroup(t *testing.T) { } } +func TestGraphClient_CreateAndDeleteUser(t *testing.T) { + var rndstring = randomString(32) + tests := []struct { + name string + g *GraphClient + want User + wantErr bool + }{ + { + name: "Create new User", + g: graphClient, + want: User{ + AccountEnabled: true, + DisplayName: "go-msgraph unit-test generated user " + time.Now().Format("2006-01-02") + " - random " + rndstring, + MailNickname: "go-msgraph.unit-test.generated." + rndstring, + UserPrincipalName: "go-msgraph.unit-test.generated." + rndstring + "@" + msGraphDomainNameForCreateTests, + PasswordProfile: PasswordProfile{Password: randomString(32)}, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.g.CreateUser(tt.want) + if (err != nil) != tt.wantErr { + t.Errorf("GraphClient.CreateUser() error = %v, wantErr %v", err, tt.wantErr) + return + } + fmt.Printf("Got: %v\n", got) + err = got.DeleteUser() + if (err != nil) != tt.wantErr { + t.Errorf("User.DeleteUser() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestGetQueryOptions_Context(t *testing.T) { t.Parallel() tests := []struct { From 71355a16c2256c233e4784b22bdc879159a73c04 Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 13:31:52 +0000 Subject: [PATCH 7/9] Github Actions: add secret MSGraphDomainNameForCreateTests --- .github/workflows/go.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7f16063..c21287a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -34,6 +34,7 @@ jobs: MSGraphTenantID: ${{ secrets.MSGraphTenantID }} MSGraphApplicationID: ${{ secrets.MSGraphApplicationID }} MSGraphClientSecret: ${{ secrets.MSGraphClientSecret }} + MSGraphDomainNameForCreateTests: ${{ secrets.MSGraphDomainNameForCreateTests }} MSGraphExistingGroupDisplayName: ${{ secrets.MSGraphExistingGroupDisplayName }} MSGraphExistingUserPrincipalInGroup: ${{ secrets.MSGraphExistingUserPrincipalInGroup }} MSGraphExistingCalendarsOfUser: ${{ secrets.MSGraphExistingCalendarsOfUser }} From 3c740d8a9d155e9c75bb270774aa40e50015ee18 Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 14:13:40 +0000 Subject: [PATCH 8/9] GraphClient: Add user.DisableAccount and unit tests for CreateUser --- GraphClient.go | 3 ++- GraphClient_test.go | 17 ++++++++++++++++- User.go | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/GraphClient.go b/GraphClient.go index fb8c92a..69ecb27 100644 --- a/GraphClient.go +++ b/GraphClient.go @@ -228,7 +228,8 @@ func (g *GraphClient) performRequest(req *http.Request, v interface{}) error { return fmt.Errorf("HTTP response read error: %v of http.Request: %v", err, req.URL) } - if req.Method == http.MethodDelete { // no content returned when http DELETE is used, e.g. User.DeleteUser() + // no content returned when http PATCH or DELETE is used, e.g. User.DeleteUser() + if req.Method == http.MethodDelete || req.Method == http.MethodPatch { return nil } return json.Unmarshal(body, &v) // return the error of the json unmarshal diff --git a/GraphClient_test.go b/GraphClient_test.go index 297bb30..ab48424 100644 --- a/GraphClient_test.go +++ b/GraphClient_test.go @@ -595,12 +595,27 @@ func TestGraphClient_CreateAndDeleteUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // test CreateUser got, err := tt.g.CreateUser(tt.want) if (err != nil) != tt.wantErr { t.Errorf("GraphClient.CreateUser() error = %v, wantErr %v", err, tt.wantErr) return } - fmt.Printf("Got: %v\n", got) + fmt.Printf("GraphClient.CreateUser() result: %v\n", got) + // test DisableAccount + err = got.DisableAccount() + if (err != nil) != tt.wantErr { + t.Errorf("User.DisableAccount() error = %v, wantErr %v", err, tt.wantErr) + } + // get user again to compare AccountEnabled field + got, err = tt.g.GetUser(got.ID) + if (err != nil) != tt.wantErr { + t.Errorf("GraphClient.GetUser() error = %v, wantErr %v", err, tt.wantErr) + } + if got.AccountEnabled == true { + t.Errorf("User.DisableAccount() did not work, AccountEnabled is still true") + } + // Delete user again err = got.DeleteUser() if (err != nil) != tt.wantErr { t.Errorf("User.DeleteUser() error = %v, wantErr %v", err, tt.wantErr) diff --git a/User.go b/User.go index 7039b69..e7d3603 100644 --- a/User.go +++ b/User.go @@ -146,8 +146,10 @@ func (u User) GetFullName() string { } // UpdateUser patches this user object. Note, only set the fields that should be changed. -// Furthermore, the user cannot be disabled (field AccountEnabled) this way, because the -// default value of a boolean is false - and hence will not be posted via json. +// +// IMPORTANT: the user cannot be disabled (field AccountEnabled) this way, because the +// default value of a boolean is false - and hence will not be posted via json - omitempty +// is used. user func user.DisableAccount() instead. // // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user-update func (u User) UpdateUser(userInput User, opts ...UpdateQueryOption) error { @@ -162,7 +164,32 @@ func (u User) UpdateUser(userInput User, opts ...UpdateQueryOption) error { } reader := bytes.NewReader(bodyBytes) - // TODO: check return body, maybe there is some potential success or error message hidden in it? + // Hint: API-call body does not return any data / no json object. + err = u.graphClient.makePATCHAPICall(resource, compileUpdateQueryOptions(opts), reader, nil) + return err +} + +// DisableAccount disables the User-Account, hence sets the AccountEnabled-field to false. +// This function must be used instead of user.UpdateUser, because the AccountEnabled-field +// with json "omitempty" will never be sent when false. Without omitempty, the user account would +// always accidentially disabled upon an update of e.g. only "DisplayName" +// +// Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user-update +func (u User) DisableAccount(opts ...UpdateQueryOption) error { + if u.graphClient == nil { + return ErrNotGraphClientSourced + } + resource := fmt.Sprintf("/users/%v", u.ID) + + bodyBytes, err := json.Marshal(struct { + AccountEnabled bool `json:"accountEnabled"` + }{AccountEnabled: false}) + if err != nil { + return err + } + + reader := bytes.NewReader(bodyBytes) + // Hint: API-call body does not return any data / no json object. err = u.graphClient.makePATCHAPICall(resource, compileUpdateQueryOptions(opts), reader, nil) return err } From 03542fea8c3a97e9969e325a9c6c7b92a2f0cfff Mon Sep 17 00:00:00 2001 From: Felix Hartung <7132415+TerraTalpi@users.noreply.github.com> Date: Thu, 19 Aug 2021 14:13:59 +0000 Subject: [PATCH 9/9] doc: add example_user.md with DisableAccount and UpdateUser --- docs/example_Context-awareness.md | 4 --- docs/example_User.md | 48 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 docs/example_User.md diff --git a/docs/example_Context-awareness.md b/docs/example_Context-awareness.md index f8729eb..c43d54c 100644 --- a/docs/example_Context-awareness.md +++ b/docs/example_Context-awareness.md @@ -25,10 +25,6 @@ ctx, cancel := context.WithCancel(context.Background()) user, err := graphClient.GetUser("dumpty@contoso.com", msgraph.GetWithContext(ctx)) // example for List-func: users, err := graphClient.ListUsers(msgraph.ListWithContext(ctx)) -// example for Create-func: -user, err := graphClient.CreateUser(msgrpah.User: {DisplayName : "New User"}, msgraph.CreateWithContext(ctx)) -// example for update-func: -err = := user.UpdateUser(msgraph.User{DisplayName: "Even newer User"}, msgraph.UpdateWithContext(ctx)) ```` Note: the use of a context is optional. If no context is given, the context `context.Background()` will automatically be used for all API-calls. \ No newline at end of file diff --git a/docs/example_User.md b/docs/example_User.md new file mode 100644 index 0000000..86a33d5 --- /dev/null +++ b/docs/example_User.md @@ -0,0 +1,48 @@ +# Example GraphClient initialization + +## Getting, listing, filtering users + +````go +// initialize GraphClient manually +graphClient, err := msgraph.NewGraphClient("", "", "") +if err != nil { + fmt.Println("Credentials are probably wrong or system time is not synced: ", err) +} + +// List all users +users, err := graphClient.ListUsers() +// Gets all the detailled information about a user identified by it's ID or userPrincipalName +user, err := graphClient.GetUser("humpty@contoso.com") + +```` + +## Create a user + +````go +// example for Create-func: +user, err := graphClient.CreateUser( + msgraph.User{ + AccountEnabled: true, + DisplayName: "Rabbit", + MailNickname: "The rabbit", + UserPrincipalName: "rabbit@contoso.com", + PasswordProfile: PasswordProfile{Password: "SecretCarrotBasedPassphrase"}, + }, + msgraph.CreateWithContext(ctx) +) +```` + +## Update a user + +````go +// first, get the user: +user, err := graphClient.GetUser("rabbit@contoso.com") +// then create a user object, only set the fields you want to change. +err := user.UpdateUser(msgraph.User{DisplayName: "Rabbit 2.0"}, msgraph.UpdateWithContext(ctx)) +// Hint 1: UpdateWithContext is optional +// Hint 2: you cannot disable a user that way, please user user.Disable +// Hint 3: after updating the account, you have to use GetUser("...") again. + +// disable acccount +err := user.DisableAccount() +```` \ No newline at end of file