Skip to content

Commit

Permalink
Merge pull request #16 from open-networks/develop
Browse files Browse the repository at this point in the history
Add DisableAccount and DeleteUser functions, write unit tests for new user funcs, update documentation
  • Loading branch information
TerraTalpi authored Aug 21, 2021
2 parents 7a1eecd + 03542fe commit 1a0763c
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 12 deletions.
1 change: 1 addition & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
17 changes: 13 additions & 4 deletions GraphClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,26 @@ 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)
}

// 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
Expand Down Expand Up @@ -223,6 +228,10 @@ 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)
}

// 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
}

Expand Down Expand Up @@ -282,7 +291,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 {
Expand Down
33 changes: 33 additions & 0 deletions GraphClient_queryOptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
67 changes: 67 additions & 0 deletions GraphClient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/url"
"os"
"reflect"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -557,6 +572,58 @@ 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) {
// 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("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)
}
})
}
}

func TestGetQueryOptions_Context(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
49 changes: 45 additions & 4 deletions User.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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 {
Expand All @@ -162,11 +164,50 @@ 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
}

// 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())
Expand Down
24 changes: 24 additions & 0 deletions User_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package msgraph

import (
"fmt"
"testing"
"time"
)
Expand Down Expand Up @@ -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)
}
})
}
4 changes: 0 additions & 4 deletions docs/example_Context-awareness.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ ctx, cancel := context.WithCancel(context.Background())
user, err := graphClient.GetUser("[email protected]", 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.
48 changes: 48 additions & 0 deletions docs/example_User.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Example GraphClient initialization

## Getting, listing, filtering users

````go
// initialize GraphClient manually
graphClient, err := msgraph.NewGraphClient("<TenantID>", "<ApplicationID>", "<ClientSecret>")
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("[email protected]")

````

## Create a user

````go
// example for Create-func:
user, err := graphClient.CreateUser(
msgraph.User{
AccountEnabled: true,
DisplayName: "Rabbit",
MailNickname: "The rabbit",
UserPrincipalName: "[email protected]",
PasswordProfile: PasswordProfile{Password: "SecretCarrotBasedPassphrase"},
},
msgraph.CreateWithContext(ctx)
)
````

## Update a user

````go
// first, get the user:
user, err := graphClient.GetUser("[email protected]")
// 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()
````

0 comments on commit 1a0763c

Please sign in to comment.