├── .gitattributes ├── go.mod ├── codecov.yml ├── .gitignore ├── Groups_test.go ├── docs ├── CONTRIBUTORS.md ├── example_Context-awareness.md ├── example_User.md ├── example_Query-Parameters.md ├── example_GraphClient.md └── contributing.md ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── go.yml │ └── codeql-analysis.yml ├── Attendees.go ├── Calendar_test.go ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── Groups.go ├── LICENSE ├── Calendars.go ├── EmailAddress.go ├── supportedTimeZones_test.go ├── ResponseStatus.go ├── Attendee.go ├── Attendees_test.go ├── Calendars_test.go ├── Users.go ├── CalendarEvents.go ├── ResponseStatus_test.go ├── Calendar.go ├── Attendee_test.go ├── Token.go ├── constants.go ├── Group_test.go ├── Users_test.go ├── README.md ├── Group.go ├── GraphClient_queryOptions.go ├── User_test.go ├── CalendarEvent.go ├── User.go ├── Security.go ├── supportedTimeZones.go ├── GraphClient.go └── GraphClient_test.go /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/open-networks/go-msgraph 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 50..100 3 | round: nearest 4 | precision: 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # .vscode folder 15 | .vscode 16 | -------------------------------------------------------------------------------- /Groups_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import "testing" 4 | 5 | func TestGroups_String(t *testing.T) { 6 | testGroup := GetTestGroup(t) 7 | groups := Groups{testGroup} 8 | wanted := "Groups(" + testGroup.String() + ")" 9 | if wanted != groups.String() { 10 | t.Errorf("Groups.String() result: %v, wanted: %v", testGroup, wanted) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | Initial development of the go-msgraph API project was done by [@coolbreeze000](https://github.com/coolbreeze000) (Dominik Herkel ) at the beginning of the year 2018. Further developed has been done by [@TerraTalpi](https://github.com/TerraTalpi). Current maintainer is [@TerraTalpi](https://github.com/TerraTalpi). 4 | 5 | ## Special thanks 6 | A special thanks for their work to the following contributors: 7 | 8 | * [@baez90](https://github.com/baez90) 9 | * [@Mattes83](https://github.com/Mattes83) 10 | * [@Goorsky123](https://github.com/Goorsky123) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: TerraTalpi 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or code about the feature request here. 21 | -------------------------------------------------------------------------------- /Attendees.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Attendees struct represents multiple Attendees for a CalendarEvent 8 | type Attendees []Attendee 9 | 10 | func (a Attendees) String() string { 11 | var attendees = make([]string, len(a)) 12 | for i, attendee := range a { 13 | attendees[i] = attendee.String() 14 | } 15 | return "Attendees(" + strings.Join(attendees, " | ") + ")" 16 | } 17 | 18 | // Equal compares the Attendee to the other Attendee and returns true 19 | // if the two given Attendees are equal. Otherwise returns false 20 | func (a Attendees) Equal(other Attendees) bool { 21 | Outer: 22 | for _, attendee := range a { 23 | for _, toCompare := range other { 24 | if attendee.Equal(toCompare) { 25 | continue Outer 26 | } 27 | } 28 | return false 29 | } 30 | return len(a) == len(other) // if we reach this, all attendees have been found, now return if the attendees are equally much 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: TerraTalpi 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Please include a simplified version of your source code to reproduce the issue. Maybe, you have to state the expected amount of data to be returned, e.g. listing users should give back 10000 users at once. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Errors** 20 | Please post the error message(s) you get. 21 | 22 | **Versions** 23 | - go-lang: [e.g. 1.16.3] 24 | - OS: [e.g. Windows 10 x64 build 2009, or Ubuntu 20.04] 25 | - docker-version: [e.g. 20.10.7] 26 | - docker base container: [e.g. apline:3.14.1] 27 | - ... 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /Calendar_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestCalendar_String(t *testing.T) { 9 | testCalendars := GetTestListCalendars(t) 10 | if skipCalendarTests { 11 | t.Skip("Skipping due to missing 'MSGraphExistingCalendarsOfUser' value") 12 | } 13 | 14 | for _, testCalendar := range testCalendars { 15 | tt := struct { 16 | name string 17 | c *Calendar 18 | want string 19 | }{ 20 | name: "Test first calendar", 21 | c: &testCalendar, 22 | want: fmt.Sprintf("Calendar(ID: \"%v\", Name: \"%v\", canEdit: \"%v\", canShare: \"%v\", canViewPrivateItems: \"%v\", ChangeKey: \"%v\", "+ 23 | "Owner: \"%v\")", testCalendar.ID, testCalendar.Name, testCalendar.CanEdit, testCalendar.CanShare, testCalendar.CanViewPrivateItems, testCalendar.ChangeKey, testCalendar.Owner), 24 | } 25 | t.Run(tt.name, func(t *testing.T) { 26 | if got := tt.c.String(); got != tt.want { 27 | t.Errorf("Calendar.String() = %v, want %v", got, tt.want) 28 | } 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/go/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Go version: 1, 1.16, 1.15 4 | ARG VARIANT="1" 5 | FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT} 6 | 7 | # [Option] Install Node.js 8 | ARG INSTALL_NODE="true" 9 | ARG NODE_VERSION="lts/*" 10 | RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 11 | 12 | # [Optional] Uncomment this section to install additional OS packages. 13 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 14 | # && apt-get -y install --no-install-recommends 15 | 16 | # [Optional] Uncomment the next line to use go get to install anything else you need 17 | # RUN go get -x 18 | 19 | # [Optional] Uncomment this line to install global node packages. 20 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /Groups.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Groups represents multiple Group-instances and provides funcs to work with them. 8 | type Groups []Group 9 | 10 | func (g Groups) String() string { 11 | var groups = make([]string, len(g)) 12 | for i, calendar := range g { 13 | groups[i] = calendar.String() 14 | } 15 | return "Groups(" + strings.Join(groups, " | ") + ")" 16 | } 17 | 18 | // setGraphClient sets the GraphClient within that particular instance. Hence it's directly created by GraphClient 19 | func (g Groups) setGraphClient(gC *GraphClient) Groups { 20 | for i := range g { 21 | g[i].setGraphClient(gC) 22 | } 23 | return g 24 | } 25 | 26 | // GetByDisplayName returns the Group obj of that array whose DisplayName matches 27 | // the given name. Returns an ErrFindGroup if no group exists that matches the given 28 | // DisplayName. 29 | func (g Groups) GetByDisplayName(displayName string) (Group, error) { 30 | for _, group := range g { 31 | if group.DisplayName == displayName { 32 | return group, nil 33 | } 34 | } 35 | return Group{}, ErrFindGroup 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Open Networks GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Calendars.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Calendars represents an array of Calendar instances combined with some helper-functions 8 | // 9 | // See: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/calendar 10 | type Calendars []Calendar 11 | 12 | func (c Calendars) String() string { 13 | var calendars = make([]string, len(c)) 14 | for i, calendar := range c { 15 | calendars[i] = calendar.String() 16 | } 17 | return "Calendars(" + strings.Join(calendars, " | ") + ")" 18 | } 19 | 20 | // setGraphClient sets the graphClient instance in this instance and all child-instances (if any) 21 | func (c Calendars) setGraphClient(gC *GraphClient) Calendars { 22 | for i := range c { 23 | c[i].setGraphClient(gC) 24 | } 25 | return c 26 | } 27 | 28 | // GetByName returns the calendar obj of that array whose DisplayName matches 29 | // the given name. Returns an ErrFindCalendar if no calendar exists that matches the given 30 | // name. 31 | func (c Calendars) GetByName(name string) (Calendar, error) { 32 | for _, calendar := range c { 33 | if calendar.Name == name { 34 | return calendar, nil 35 | } 36 | } 37 | return Calendar{}, ErrFindCalendar 38 | } 39 | -------------------------------------------------------------------------------- /docs/example_Context-awareness.md: -------------------------------------------------------------------------------- 1 | # Context awareness 2 | 3 | Context awareness has been implemented and can be passed to all `Get` and `List` queries of the `GraphClient`. For a general documentation about `context` see [context package documentation](https://pkg.go.dev/context). 4 | 5 | Two functions have been created to implement context awareness: 6 | 7 | * `msgraph.GetWithContext()` 8 | * `msgraph.ListWithContext()` 9 | * `msgraph.CreateWithContext()` 10 | * `msgraph.PatchWithContext()` 11 | 12 | The result of those two functions must be passed as parameter to the respective `Get` or `List` function. 13 | 14 | ## Example 15 | 16 | ````go 17 | // initialize GraphClient 18 | graphClient, err := msgraph.NewGraphClient("", "", "") 19 | if err != nil { 20 | fmt.Println("Credentials are probably wrong or system time is not synced: ", err) 21 | } 22 | // create new context: 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | // example for Get-func: 25 | user, err := graphClient.GetUser("dumpty@contoso.com", msgraph.GetWithContext(ctx)) 26 | // example for List-func: 27 | users, err := graphClient.ListUsers(msgraph.ListWithContext(ctx)) 28 | ```` 29 | 30 | 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. -------------------------------------------------------------------------------- /EmailAddress.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | // EmailAddress represents an emailAddress instance as microsoft.graph.EmailAddress. This is used at 4 | // various positions, for example in CalendarEvents for attenees, owners, organizers or in Calendar 5 | // for the owner. 6 | // 7 | // Short: The name and email address of a contact or message recipient. 8 | // 9 | // See https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/emailaddress 10 | type EmailAddress struct { 11 | Address string `json:"address"` // The email address of the person or entity. 12 | Name string `json:"name"` // The display name of the person or entity. 13 | 14 | graphClient *GraphClient // the initiator of this EMailAddress Instance 15 | } 16 | 17 | func (e EmailAddress) String() string { 18 | return e.Name + "<" + e.Address + ">" 19 | } 20 | 21 | // setGraphClient sets the graphClient instance in this instance and all child-instances (if any) 22 | func (e *EmailAddress) setGraphClient(graphClient *GraphClient) { 23 | e.graphClient = graphClient 24 | } 25 | 26 | // GetUser tries to get the real User-Instance directly from msgraph 27 | // identified by the e-mail address of the user. This should normally 28 | // be the userPrincipalName anyways. Returns an error if any from GraphClient. 29 | func (e EmailAddress) GetUser() (User, error) { 30 | return e.graphClient.GetUser(e.Address) 31 | } 32 | -------------------------------------------------------------------------------- /supportedTimeZones_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "math/rand" 5 | "testing" 6 | ) 7 | 8 | func Test_supportedTimeZones_GetTimeZoneByAlias(t *testing.T) { 9 | testuser := GetTestUser(t) 10 | timezones, _ := testuser.getTimeZoneChoices(compileGetQueryOptions(nil)) 11 | 12 | randomTimezone := timezones.Value[rand.Intn(len(timezones.Value))] 13 | _, err := timezones.GetTimeZoneByAlias(randomTimezone.Alias) 14 | if err != nil { 15 | t.Errorf("Cannot get timeZone with Alias %v, err: %v", randomTimezone.Alias, err) 16 | } 17 | _, err = timezones.GetTimeZoneByAlias("This is a non existing timezone") 18 | if err == nil { 19 | t.Errorf("Tried to get a non existing timezone, expected an error, but got nil") 20 | } 21 | } 22 | 23 | func Test_supportedTimeZones_GetTimeZoneByDisplayName(t *testing.T) { 24 | testuser := GetTestUser(t) 25 | timezones, _ := testuser.getTimeZoneChoices(compileGetQueryOptions(nil)) 26 | 27 | randomTimezone := timezones.Value[rand.Intn(len(timezones.Value))] 28 | _, err := timezones.GetTimeZoneByDisplayName(randomTimezone.DisplayName) 29 | if err != nil { 30 | t.Errorf("Cannot get timeZone with DisplayName %v, err: %v", randomTimezone.DisplayName, err) 31 | } 32 | _, err = timezones.GetTimeZoneByDisplayName("This is a non existing timezone") 33 | if err == nil { 34 | t.Errorf("Tried to get a non existing timezone, expected an error, but got nil") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/go 3 | { 4 | "name": "Go", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.15 9 | "VARIANT": "1", 10 | // Options 11 | "INSTALL_NODE": "false", 12 | "NODE_VERSION": "lts/*" 13 | } 14 | }, 15 | "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 16 | 17 | // Set *default* container specific settings.json values on container create. 18 | "settings": { 19 | "go.toolsManagement.checkForUpdates": "local", 20 | "go.useLanguageServer": true, 21 | "go.gopath": "/go", 22 | "go.goroot": "/usr/local/go" 23 | }, 24 | 25 | // Add the IDs of extensions you want installed when the container is created. 26 | "extensions": [ 27 | "golang.Go" 28 | ], 29 | 30 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 31 | // "forwardPorts": [], 32 | 33 | // Use 'postCreateCommand' to run commands after the container is created. 34 | // "postCreateCommand": "go version", 35 | 36 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 37 | "remoteUser": "vscode", 38 | "features": { 39 | "github-cli": "latest" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '0 10 * * 1' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.16 20 | - name: Build 21 | run: go build 22 | test: 23 | needs: build 24 | runs-on: ubuntu-latest 25 | if: github.ref == 'refs/heads/master' 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Set up Go 29 | uses: actions/setup-go@v2 30 | with: 31 | go-version: 1.16 32 | - name: Test 33 | env: 34 | MSGraphTenantID: ${{ secrets.MSGraphTenantID }} 35 | MSGraphApplicationID: ${{ secrets.MSGraphApplicationID }} 36 | MSGraphClientSecret: ${{ secrets.MSGraphClientSecret }} 37 | MSGraphDomainNameForCreateTests: ${{ secrets.MSGraphDomainNameForCreateTests }} 38 | MSGraphExistingGroupDisplayName: ${{ secrets.MSGraphExistingGroupDisplayName }} 39 | MSGraphExistingUserPrincipalInGroup: ${{ secrets.MSGraphExistingUserPrincipalInGroup }} 40 | MSGraphExistingCalendarsOfUser: ${{ secrets.MSGraphExistingCalendarsOfUser }} 41 | MSGraphExistingGroupDisplayNameNumRes: ${{ secrets.MSGraphExistingGroupDisplayNameNumRes }} 42 | run: go test -coverprofile=coverage.txt -covermode=atomic 43 | - name: Upload coverage to Codecov 44 | run: bash <(curl -s https://codecov.io/bash) 45 | -------------------------------------------------------------------------------- /ResponseStatus.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // ResponseStatus represents the response status for an Attendee to a CalendarEvent or just for a CalendarEvent 10 | type ResponseStatus struct { 11 | Response string // status of the response, may be organizer, accepted, declined etc. 12 | Time time.Time // represents the time when the response was performed 13 | } 14 | 15 | func (s ResponseStatus) String() string { 16 | return fmt.Sprintf("Response: %s, Time: %s", s.Response, s.Time.Format(time.RFC3339Nano)) 17 | } 18 | 19 | // Equal compares the ResponseStatus to the other Response status and returns true 20 | // if the Response and time is equal 21 | func (s ResponseStatus) Equal(other ResponseStatus) bool { 22 | return s.Response == other.Response && s.Time.Equal(other.Time) 23 | } 24 | 25 | // UnmarshalJSON implements the json unmarshal to be used by the json-library 26 | func (s *ResponseStatus) UnmarshalJSON(data []byte) error { 27 | tmp := struct { 28 | Response string `json:"response"` 29 | Timestamp string `json:"time"` 30 | }{} 31 | 32 | err := json.Unmarshal(data, &tmp) 33 | if err != nil { 34 | return err 35 | } 36 | if tmp.Response == "" { 37 | return fmt.Errorf("response-field is empty") 38 | } 39 | 40 | s.Response = tmp.Response 41 | s.Time, err = time.Parse(time.RFC3339Nano, tmp.Timestamp) // the timeZone is normally ALWAYS UTC, microsoft converts time date & time to that, but it does not matter here 42 | if err != nil { 43 | return fmt.Errorf("cannot parse timestamp with RFC3339Nano: %v", err) 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /docs/example_User.md: -------------------------------------------------------------------------------- 1 | # Example GraphClient initialization 2 | 3 | ## Getting, listing, filtering users 4 | 5 | ````go 6 | // initialize GraphClient manually 7 | graphClient, err := msgraph.NewGraphClient("", "", "") 8 | if err != nil { 9 | fmt.Println("Credentials are probably wrong or system time is not synced: ", err) 10 | } 11 | 12 | // List all users 13 | users, err := graphClient.ListUsers() 14 | // Gets all the detailled information about a user identified by it's ID or userPrincipalName 15 | user, err := graphClient.GetUser("humpty@contoso.com") 16 | 17 | ```` 18 | 19 | ## Create a user 20 | 21 | ````go 22 | // example for Create-func: 23 | user, err := graphClient.CreateUser( 24 | msgraph.User{ 25 | AccountEnabled: true, 26 | DisplayName: "Rabbit", 27 | MailNickname: "The rabbit", 28 | UserPrincipalName: "rabbit@contoso.com", 29 | PasswordProfile: PasswordProfile{Password: "SecretCarrotBasedPassphrase"}, 30 | }, 31 | msgraph.CreateWithContext(ctx) 32 | ) 33 | ```` 34 | 35 | ## Update a user 36 | 37 | ````go 38 | // first, get the user: 39 | user, err := graphClient.GetUser("rabbit@contoso.com") 40 | // then create a user object, only set the fields you want to change. 41 | err := user.UpdateUser(msgraph.User{DisplayName: "Rabbit 2.0"}, msgraph.UpdateWithContext(ctx)) 42 | // Hint 1: UpdateWithContext is optional 43 | // Hint 2: you cannot disable a user that way, please user user.Disable 44 | // Hint 3: after updating the account, you have to use GetUser("...") again. 45 | 46 | // disable acccount 47 | err := user.DisableAccount() 48 | // enable user account again 49 | err := user.UpdateUser(User{AccountEnabled: true}) 50 | // delete a user, use with caution! 51 | err := user.DeleteUser() 52 | ```` -------------------------------------------------------------------------------- /Attendee.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // Attendee struct represents an attendee for a CalendarEvent 9 | type Attendee struct { 10 | Type string // the type of the invitation, e.g. required, optional etc. 11 | Name string // the name of the person, comes from the E-Mail Address - hence not a reliable name to search for 12 | Email string // the e-mail address of the person - use this to identify the user 13 | ResponseStatus ResponseStatus // the ResponseStatus for that particular Attendee for the CalendarEvent 14 | } 15 | 16 | func (a Attendee) String() string { 17 | return fmt.Sprintf("Name: %s, Type: %s, E-mail: %s, ResponseStatus: %v", a.Name, a.Type, a.Email, a.ResponseStatus) 18 | } 19 | 20 | // Equal compares the Attendee to the other Attendee and returns true 21 | // if the two given Attendees are equal. Otherwise returns false 22 | func (a Attendee) Equal(other Attendee) bool { 23 | return a.Type == other.Type && a.Name == other.Name && a.Email == other.Email && a.ResponseStatus.Equal(other.ResponseStatus) 24 | } 25 | 26 | // UnmarshalJSON implements the json unmarshal to be used by the json-library 27 | func (a *Attendee) UnmarshalJSON(data []byte) error { 28 | tmp := struct { 29 | Type string `json:"type"` 30 | Status ResponseStatus `json:"status"` 31 | EmailAddress struct { 32 | Name string `json:"name"` 33 | Address string `json:"address"` 34 | } `json:"emailAddress"` 35 | }{} 36 | 37 | err := json.Unmarshal(data, &tmp) 38 | if err != nil { 39 | return fmt.Errorf("Attendee: %v", err.Error()) 40 | } 41 | 42 | a.Type = tmp.Type 43 | a.Name = tmp.EmailAddress.Name 44 | a.Email = tmp.EmailAddress.Address 45 | a.ResponseStatus = tmp.Status 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /Attendees_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestAttendees_String(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | a Attendees 12 | want string 13 | }{ 14 | { 15 | name: "test eq", 16 | a: Attendees{testAttendee1, testAttendee2}, 17 | want: "Attendees(Name: Testname, Type: attendee, E-mail: testname@contoso.com, ResponseStatus: Response: accepted, Time: " + 18 | testAttendee1.ResponseStatus.Time.Format(time.RFC3339Nano) + 19 | " | Name: Testuser, Type: attendee, E-mail: testuser@contoso.com, ResponseStatus: Response: declined, Time: " + 20 | testAttendee2.ResponseStatus.Time.Format(time.RFC3339Nano) + ")", 21 | }, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | if got := tt.a.String(); got != tt.want { 26 | t.Errorf("Attendees.String() = %v, want %v", got, tt.want) 27 | } 28 | }) 29 | } 30 | } 31 | func TestAttendees_Equal(t *testing.T) { 32 | type args struct { 33 | other Attendees 34 | } 35 | tests := []struct { 36 | name string 37 | a Attendees 38 | args args 39 | want bool 40 | }{ 41 | { 42 | name: "Equal, different order", 43 | a: Attendees{testAttendee1, testAttendee2}, 44 | args: args{other: Attendees{testAttendee2, testAttendee1}}, 45 | want: true, 46 | }, { 47 | name: "non-equal, missing one", 48 | a: Attendees{testAttendee1, testAttendee2}, 49 | args: args{other: Attendees{testAttendee1}}, 50 | want: false, 51 | }, { 52 | name: "non-equal, too many", 53 | a: Attendees{testAttendee1}, 54 | args: args{other: Attendees{testAttendee2, testAttendee1}}, 55 | want: false, 56 | }, 57 | } 58 | for _, tt := range tests { 59 | t.Run(tt.name, func(t *testing.T) { 60 | if got := tt.a.Equal(tt.args.other); got != tt.want { 61 | t.Errorf("Attendees.Equal() = %v, want %v", got, tt.want) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/example_Query-Parameters.md: -------------------------------------------------------------------------------- 1 | # Query Parameters 2 | 3 | Support for the following three query parameters has been added: 4 | 5 | * `$select` - only return the specified fields of the object. This reduces the used bandwidth and therefore improves performance 6 | * `$search` - search with `ConsistencyLevel` set to `eventual` 7 | * `$filter` - filter results server-side and only return matching results 8 | 9 | See [Query Parameters Documentation](https://docs.microsoft.com/en-us/graph/query-parameters) from Microsoft. 10 | 11 | They can be passed as a parameter to all `Get` and `List` functions with the following helper functions: 12 | 13 | * `msgraph.GetWithSelect("displayName")` 14 | * `msgraph.ListWithSelect("displayName,createdDateTime")` 15 | * ``msgraph.ListWithSearch(`"displayName:alice"`)`` 16 | * `msgraph.ListWithFilter("displayName eq 'bob')` 17 | 18 | ## Example 19 | 20 | ````go 21 | // initialize GraphClient 22 | graphClient, err := msgraph.NewGraphClient("", "", "") 23 | if err != nil { 24 | fmt.Println("Credentials are probably wrong or system time is not synced: ", err) 25 | } 26 | 27 | // Get only displayName of the user 28 | user, err := graphClient.GetUser("alice@contoso.com", msgraph.GetWithSelect("displayName")) 29 | // Get a list of users using search, that containing "alice" in their displayName: 30 | searchUser := "alice" 31 | users, err := graphClient.ListUsers(msgraph.ListWithSearch(fmt.Sprintf(`"displayName:%s"`, searchUser))) 32 | searchUser = "Alice Contoso" 33 | // Get a list of users using filter, that contain "alice" in their displayName: 34 | users, err := graphClient.ListUsers(msgraph.ListWithFilter(fmt.Sprintf("displayName eq '%s'", searchUser))) 35 | 36 | // The two above examples can also be combined, for example search and select: 37 | users, err := graphClient.ListUsers( 38 | msgraph.ListWithSelect("displayName"), 39 | msgraph.ListWithSearch(fmt.Sprintf(`"displayName:%s"`, searchUser)), 40 | ) 41 | 42 | // Last, but not least, a context can also be added to the query: 43 | users, err := graphClient.ListUsers( 44 | msgraph.ListWithSelect("displayName"), 45 | msgraph.ListWithSearch(fmt.Sprintf("displayName:%s", searchUser)), 46 | msgraph.ListWithContext(ctx.Background()), 47 | ) 48 | ```` 49 | -------------------------------------------------------------------------------- /Calendars_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // GetTestListCalendars tries to get a valid User from the User-test cases 9 | // and then uses ListCalendars() for this instance. 10 | func GetTestListCalendars(t *testing.T) Calendars { 11 | t.Helper() 12 | testUser := GetTestUser(t) 13 | if skipCalendarTests { 14 | t.Skip("Skipping due to missing 'MSGraphExistingCalendarsOfUser' value") 15 | } 16 | testCalendars, err := testUser.ListCalendars() 17 | if err != nil { 18 | t.Fatalf("Cannot User.ListCalendars() for user %v: %v", testUser, err) 19 | } 20 | if len(testCalendars) == 0 { 21 | t.Fatalf("Cannot User.ListCalendars() for user %v, 0 calendars returned", testUser) 22 | } 23 | return testCalendars 24 | } 25 | 26 | func TestCalendars_GetByName(t *testing.T) { 27 | testCalendars := GetTestListCalendars(t) 28 | if skipCalendarTests { 29 | t.Skip("Skipping due to missing 'MSGraphExistingCalendarsOfUser' value") 30 | } 31 | type args struct { 32 | name string 33 | } 34 | tests := []struct { 35 | name string 36 | c Calendars 37 | args args 38 | wantErr bool 39 | }{ 40 | { 41 | name: "Find valid Calendar", 42 | c: testCalendars, 43 | args: args{name: msGraphExistingCalendarsOfUser[0]}, 44 | wantErr: false, 45 | }, { 46 | name: "non-existing calendar", 47 | c: testCalendars, 48 | args: args{name: "DSfk9sdf89io23rasdfasfasdfasdf"}, 49 | wantErr: true, 50 | }, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | _, err := tt.c.GetByName(tt.args.name) 55 | if (err != nil) != tt.wantErr { 56 | t.Errorf("Calendars.GetByName() error = %v, wantErr %v", err, tt.wantErr) 57 | return 58 | } 59 | }) 60 | } 61 | } 62 | 63 | func TestCalendars_String(t *testing.T) { 64 | if skipCalendarTests { 65 | t.Skip("Skipping due to missing 'MSGraphExistingCalendarsOfUser' value") 66 | } 67 | testCalendars := GetTestListCalendars(t) 68 | 69 | // write custom string func 70 | var calendars = make([]string, len(testCalendars)) 71 | for i, calendar := range testCalendars { 72 | calendars[i] = calendar.String() 73 | } 74 | want := "Calendars(" + strings.Join(calendars, " | ") + ")" 75 | 76 | t.Run("Test Calendars of TestUser", func(t *testing.T) { 77 | if got := testCalendars.String(); got != want { 78 | t.Errorf("Calendars.String() = %v, want %v", got, want) 79 | } 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /Users.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Users represents multiple Users, used in JSON unmarshal 9 | type Users []User 10 | 11 | // GetUserByShortName returns the first User object that has the given shortName. 12 | // Will return an error ErrFindUser if the user cannot be found 13 | func (u Users) GetUserByShortName(shortName string) (User, error) { 14 | for _, user := range u { 15 | if user.GetShortName() == shortName { 16 | return user, nil 17 | } 18 | } 19 | return User{}, ErrFindUser 20 | } 21 | 22 | // GetUserByActivePhone returns the User-instance whose activeNumber equals the given phone number. 23 | // Will return an error ErrFindUser if the user cannot be found 24 | func (u Users) GetUserByActivePhone(activePhone string) (User, error) { 25 | for _, user := range u { 26 | if user.GetActivePhone() == activePhone { 27 | return user, nil 28 | } 29 | } 30 | return User{}, ErrFindUser 31 | } 32 | 33 | // GetUserByMail returns the User-instance that e-mail address matches the given e-mail addr. 34 | // Will return an error ErrFindUser if the user cannot be found. 35 | func (u Users) GetUserByMail(email string) (User, error) { 36 | for _, user := range u { 37 | if user.Mail == email { 38 | return user, nil 39 | } 40 | } 41 | return User{}, ErrFindUser 42 | } 43 | 44 | func (u Users) String() string { 45 | var strs = make([]string, len(u)) 46 | for i, user := range u { 47 | strs[i] = user.String() 48 | } 49 | return fmt.Sprintf("Users(%v)", strings.Join(strs, ", ")) 50 | } 51 | 52 | // PrettySimpleString returns the whole []Users pretty simply formatted for logging purposes 53 | func (u Users) PrettySimpleString() string { 54 | var strs = make([]string, len(u)) 55 | for i, user := range u { 56 | strs[i] = user.PrettySimpleString() 57 | } 58 | return fmt.Sprintf("Users(%v)", strings.Join(strs, ", ")) 59 | } 60 | 61 | // setGraphClient sets the GraphClient within that particular instance. Hence it's directly created by GraphClient 62 | func (u Users) setGraphClient(gC *GraphClient) Users { 63 | for i := range u { 64 | u[i].setGraphClient(gC) 65 | } 66 | return u 67 | } 68 | 69 | // Equal compares the Users to the other Users and returns true 70 | // if the two given Users are equal. Otherwise returns false 71 | func (u Users) Equal(other Users) bool { 72 | Outer: 73 | for _, user := range u { 74 | for _, toCompare := range other { 75 | if user.Equal(toCompare) { 76 | continue Outer 77 | } 78 | } 79 | return false 80 | } 81 | return len(u) == len(other) // if we reach this, all users have been found, now return if len of the users are equal 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '33 12 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /CalendarEvents.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // CalendarEvents represents multiple events of a Calendar. The amount of entries is determined by the timespan that is used to load the Calendar 12 | type CalendarEvents []CalendarEvent 13 | 14 | func (c CalendarEvents) String() string { 15 | var events = make([]string, len(c)) 16 | for i, calendarEvent := range c { 17 | events[i] = calendarEvent.String() 18 | } 19 | return fmt.Sprintf("CalendarEvents(%v)", strings.Join(events, ", ")) 20 | } 21 | 22 | // PrettySimpleString returns all Calendar Events in a readable format, mostly used for logging purposes 23 | func (c CalendarEvents) PrettySimpleString() string { 24 | var events = make([]string, len(c)) 25 | for i, calendarEvent := range c { 26 | events[i] = calendarEvent.PrettySimpleString() 27 | } 28 | return fmt.Sprintf("CalendarEvents(%v)", strings.Join(events, ", ")) 29 | } 30 | 31 | // SortByStartDateTime sorts the array in this CalendarEvents instance 32 | func (c CalendarEvents) SortByStartDateTime() { 33 | sort.Slice(c, func(i, j int) bool { return c[i].StartTime.Before(c[j].StartTime) }) 34 | } 35 | 36 | // GetCalendarEventsAtCertainTime returns a subset of CalendarEvents that either start or end 37 | // at the givenTime or whose StartTime is before and EndTime is After the givenTime 38 | func (c CalendarEvents) GetCalendarEventsAtCertainTime(givenTime time.Time) CalendarEvents { 39 | var events []CalendarEvent 40 | for _, event := range c { 41 | if event.StartTime.Equal(givenTime) || event.EndTime.Equal(givenTime) || (event.StartTime.Before(givenTime) && event.EndTime.After(givenTime)) { 42 | events = append(events, event) 43 | } 44 | } 45 | return events 46 | } 47 | 48 | // Equal returns true if the two CalendarEvent[] are equal. The order of the events doesn't matter 49 | func (c CalendarEvents) Equal(others CalendarEvents) bool { 50 | Outer: 51 | for _, event := range c { 52 | for _, toCompare := range others { 53 | if event.Equal(toCompare) { 54 | continue Outer 55 | } 56 | } 57 | return false 58 | } 59 | return len(c) == len(others) // if we reach this, all CAlendarEvents in c have been found in others 60 | } 61 | 62 | // UnmarshalJSON implements the json unmarshal to be used by the json-library. The only 63 | // purpose of this overwrite is to immediately sort the []CalendarEvent by StartDateTime 64 | func (c *CalendarEvents) UnmarshalJSON(data []byte) error { 65 | tmp := struct { 66 | CalendarEvents []CalendarEvent `json:"value"` 67 | }{} 68 | 69 | err := json.Unmarshal(data, &tmp) 70 | if err != nil { 71 | return fmt.Errorf("cannot UnmarshalJSON: %v | Data: %v", err, string(data)) 72 | } 73 | 74 | *c = tmp.CalendarEvents // re-assign the 75 | 76 | //c.CalendarEvents = tmp.CalendarEvents 77 | c.SortByStartDateTime() 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /ResponseStatus_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestResponseStatus_Equal(t *testing.T) { 10 | 11 | testTime := time.Now() 12 | type args struct { 13 | other ResponseStatus 14 | } 15 | tests := []struct { 16 | name string 17 | s ResponseStatus 18 | args args 19 | want bool 20 | }{ 21 | { 22 | name: "Equal", 23 | s: ResponseStatus{Response: "accepted", Time: testTime}, 24 | args: args{other: ResponseStatus{Response: "accepted", Time: testTime}}, 25 | want: true, 26 | }, { 27 | name: "non-equal response", 28 | s: ResponseStatus{Response: "declined", Time: testTime}, 29 | args: args{other: ResponseStatus{Response: "accepted", Time: testTime}}, 30 | want: false, 31 | }, { 32 | name: "non-equal time", 33 | s: ResponseStatus{Response: "accepted", Time: testTime.Add(time.Minute)}, 34 | args: args{other: ResponseStatus{Response: "accepted", Time: testTime}}, 35 | want: false, 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | if got := tt.s.Equal(tt.args.other); got != tt.want { 41 | t.Errorf("ResponseStatus.Equal() = %v, want %v", got, tt.want) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func TestResponseStatus_UnmarshalJSON(t *testing.T) { 48 | fmt.Println(time.Now().Format(time.RFC3339Nano)) 49 | type args struct { 50 | data []byte 51 | } 52 | tests := []struct { 53 | name string 54 | s *ResponseStatus 55 | args args 56 | wantErr bool 57 | }{ 58 | { 59 | name: "all good", 60 | s: &ResponseStatus{}, 61 | args: args{data: []byte("{\"response\" : \"accepted\", \"time\" : \"" + time.Now().Format(time.RFC3339Nano) + "\"}")}, 62 | wantErr: false, 63 | }, { 64 | name: "invalid time format", 65 | s: &ResponseStatus{}, 66 | args: args{data: []byte("{\"response\" : \"accepted\", \"time\" : \"" + time.Now().Format(time.RFC1123) + "\"}")}, 67 | wantErr: true, 68 | }, { 69 | name: "response-field empty", 70 | s: &ResponseStatus{}, 71 | args: args{data: []byte("{\"time\" : \"" + time.Now().Format(time.RFC1123) + "\"}")}, 72 | wantErr: true, 73 | }, { 74 | name: "time-field empty", 75 | s: &ResponseStatus{}, 76 | args: args{data: []byte("{\"response\" : \"accepted\"}")}, 77 | wantErr: true, 78 | }, { 79 | name: "invalid json", 80 | s: &ResponseStatus{}, 81 | args: args{data: []byte("{\"response\" \"accepted\", \"time\" : \"" + time.Now().Format(time.RFC3339Nano) + "\"}")}, 82 | wantErr: true, 83 | }, 84 | } 85 | for _, tt := range tests { 86 | t.Run(tt.name, func(t *testing.T) { 87 | if err := tt.s.UnmarshalJSON(tt.args.data); (err != nil) != tt.wantErr { 88 | t.Errorf("ResponseStatus.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Calendar.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // Calendar represents a single calendar of a user 9 | // 10 | // See https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/calendar 11 | type Calendar struct { 12 | ID string // The group's unique identifier. Read-only. 13 | Name string // The calendar name. 14 | CanEdit bool // True if the user can write to the calendar, false otherwise. This property is true for the user who created the calendar. This property is also true for a user who has been shared a calendar and granted write access. 15 | CanShare bool // True if the user has the permission to share the calendar, false otherwise. Only the user who created the calendar can share it. 16 | CanViewPrivateItems bool // True if the user can read calendar items that have been marked private, false otherwise. 17 | ChangeKey string // Identifies the version of the calendar object. Every time the calendar is changed, changeKey changes as well. This allows Exchange to apply changes to the correct version of the object. Read-only. 18 | 19 | Owner EmailAddress // If set, this represents the user who created or added the calendar. For a calendar that the user created or added, the owner property is set to the user. For a calendar shared with the user, the owner property is set to the person who shared that calendar with the user. 20 | 21 | graphClient *GraphClient // the graphClient that created this instance 22 | } 23 | 24 | func (c Calendar) String() string { 25 | return fmt.Sprintf("Calendar(ID: \"%v\", Name: \"%v\", canEdit: \"%v\", canShare: \"%v\", canViewPrivateItems: \"%v\", ChangeKey: \"%v\", "+ 26 | "Owner: \"%v\")", c.ID, c.Name, c.CanEdit, c.CanShare, c.CanViewPrivateItems, c.ChangeKey, c.Owner) 27 | } 28 | 29 | // setGraphClient sets the graphClient instance in this instance and all child-instances (if any) 30 | func (c *Calendar) setGraphClient(graphClient *GraphClient) { 31 | c.graphClient = graphClient 32 | c.Owner.setGraphClient(graphClient) 33 | } 34 | 35 | // UnmarshalJSON implements the json unmarshal to be used by the json-library 36 | func (c *Calendar) UnmarshalJSON(data []byte) error { 37 | tmp := struct { 38 | ID string `json:"id"` // the calendars ID 39 | Name string `json:"name"` // the name of the calendar 40 | CanShare bool `json:"canShare"` // true if the current account can shares this calendar 41 | CanViewPrivateItems bool `json:"canViewPrivateItems"` // true if the current account can view private entries 42 | CanEdit bool `json:"canEdit"` // true if the current account can edit the calendar 43 | ChangeKey string `json:"changeKey"` 44 | Owner EmailAddress 45 | }{} 46 | err := json.Unmarshal(data, &tmp) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | c.ID = tmp.ID 52 | c.Name = tmp.Name 53 | c.CanEdit = tmp.CanEdit 54 | c.CanShare = tmp.CanShare 55 | c.CanViewPrivateItems = tmp.CanViewPrivateItems 56 | c.ChangeKey = tmp.ChangeKey 57 | 58 | c.Owner = tmp.Owner 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /Attendee_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | var ( 9 | // First Attendee used for the Unit tests 10 | testAttendee1 = Attendee{Name: "Testname", Email: "testname@contoso.com", Type: "attendee", ResponseStatus: ResponseStatus{Response: "accepted", Time: time.Now()}} 11 | // Second Attendee used for the Unit tests 12 | testAttendee2 = Attendee{Name: "Testuser", Email: "testuser@contoso.com", Type: "attendee", ResponseStatus: ResponseStatus{Response: "declined", Time: time.Now()}} 13 | ) 14 | 15 | func TestAttendee_Equal(t *testing.T) { 16 | type args struct { 17 | other Attendee 18 | } 19 | tests := []struct { 20 | name string 21 | a Attendee 22 | args args 23 | want bool 24 | }{ 25 | { 26 | name: "All equal", 27 | a: testAttendee1, 28 | args: args{other: testAttendee1}, 29 | want: true, 30 | }, { 31 | name: "non-equal", 32 | a: testAttendee1, 33 | args: args{other: testAttendee2}, 34 | want: false, 35 | }, 36 | } 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | if got := tt.a.Equal(tt.args.other); got != tt.want { 40 | t.Errorf("Attendee.Equal() = %v, want %v", got, tt.want) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestAttendee_UnmarshalJSON(t *testing.T) { 47 | type args struct { 48 | data []byte 49 | } 50 | tests := []struct { 51 | name string 52 | a *Attendee 53 | args args 54 | wantErr bool 55 | }{ 56 | { 57 | name: "All good", 58 | a: &Attendee{}, 59 | args: args{data: []byte( 60 | `{ "Type" : "organizer", 61 | "Status": {"response" : "accepted", "time" : "` + time.Now().Format(time.RFC3339Nano) + `"}, 62 | "emailAddress" : { 63 | "name" : "TestUserName", 64 | "address": "TestUserName@contoso.com" 65 | }}`)}, 66 | wantErr: false, 67 | }, { 68 | name: "wrong json", 69 | a: &Attendee{}, 70 | args: args{data: []byte( 71 | `{ "Type" : "organizer", 72 | "Status": {"response" : "accepted", "time" : "` + time.Now().Format(time.RFC3339Nano) + `"}, 73 | "emailAddress" : { 74 | "name" : "TestUserName", 75 | "address": "TestUserName@contoso.com", 76 | }}`)}, 77 | wantErr: true, 78 | }, 79 | } 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | if err := tt.a.UnmarshalJSON(tt.args.data); (err != nil) != tt.wantErr { 83 | t.Errorf("Attendee.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 84 | } 85 | }) 86 | } 87 | } 88 | 89 | func TestAttendee_String(t *testing.T) { 90 | tests := []struct { 91 | name string 92 | a Attendee 93 | want string 94 | }{ 95 | { 96 | name: "Test String-func", 97 | a: testAttendee1, 98 | want: "Name: Testname, Type: attendee, E-mail: testname@contoso.com, ResponseStatus: Response: accepted, Time: " + testAttendee1.ResponseStatus.Time.Format(time.RFC3339Nano), 99 | }, 100 | } 101 | for _, tt := range tests { 102 | t.Run(tt.name, func(t *testing.T) { 103 | if got := tt.a.String(); got != tt.want { 104 | t.Errorf("Attendee.String() = %v, want %v", got, tt.want) 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /docs/example_GraphClient.md: -------------------------------------------------------------------------------- 1 | # Example GraphClient initialization 2 | 3 | To get your credentials to access the Microsoft Graph API visit: [Register an application with Azure AD and create a service principal](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#register-an-application-with-azure-ad-and-create-a-service-principal) 4 | 5 | ## Manual initialization of a GraphClient 6 | 7 | ````go 8 | // initialize GraphClient manually with Global Authentication and Service endpoint 9 | graphClient, err := msgraph.NewGraphClient("", "", "") 10 | if err != nil { 11 | fmt.Println("Credentials are probably wrong or system time is not synced: ", err) 12 | } 13 | // initialize GraphClient manually with US Government L4 endpoint. 14 | graphClient, err := msgraph.NewGraphClient("", "", "", msgraph.AzureADauthEndpointUSGov, msgraph.ServiceRootEndpointUSGovL4) 15 | if err != nil { 16 | fmt.Println("Credentials are probably wrong or system time is not synced: ", err) 17 | } 18 | ```` 19 | 20 | All of the available endpoints are created as `const` variables: 21 | 22 | * `msgraph.AzureADauthEndpoint` 23 | * `msgraph.ServiceRootEndpoint` 24 | 25 | The Microsoft documentation for all available service endpoints can be found here: 26 | 27 | * Azure AD authentication endpoints: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 28 | * Serivce Root Endpoints: https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints. 29 | 30 | 31 | ## JSON initialize the Graphclient 32 | 33 | The GraphClient can be initilized directly via a JSON-file, also nested in other objects. The GraphClient will immediately initialize upon `json.Unmarshal`, and therefore check if the credentials are valid and a valid token can be aquired. If this fails, the `json.Unmarshal` will return an error. 34 | 35 | example contents of the json-file `./msgraph-credentials.json`: 36 | ````json 37 | { 38 | "TenantID": "67dce6ac-xxxx-xxxx-xxxx-0807c45243a7", 39 | "ApplicationID": "1b99ac3b-xxxx-xxxx-xxxx-6f7998277091", 40 | "ClientSecret": "PZ.Wzfbxxxxxxxxxxxx2oe++TOid/YVG", 41 | "AzureADAuthEndpoint": "https://login.microsoftonline.com", // this is optional 42 | "ServiceRootEndpoint" : "https://graph.microsoft.com" // this is optional 43 | } 44 | ```` 45 | 46 | *Hint*: `AzureADAuthEndpoint` and `ServiceRootEndpoint` are optional and default to the two `Global` endpoints: `msgraph.AzureADAuthEndpointGlobal` and `msgraph.ServiceRootEndpointGlobal` 47 | 48 | Example to initialize the `GraphClient` with the json file: 49 | 50 | ````go 51 | // initialize the GraphClient via JSON-Load. Please do proper error-handling (!) 52 | // Specify JSON-Fields TenantID, ApplicationID and ClientSecret 53 | fileContents, err := ioutil.ReadFile("./msgraph-credentials.json") 54 | if (err != nil) { 55 | fmt.println("Cannot read ./msgraph-credentials.json: ", err) 56 | } 57 | var graphClient msgraph.GraphClient 58 | err = json.Unmarshal(fileContents, &graphClient) 59 | if (err != nil) { 60 | fmt.println("Could not initialize GraphClient from JSON: ", err) 61 | } 62 | ```` 63 | 64 | ## Other options 65 | 66 | I could think about an initialization directly with a `yaml` file, or via enviroment variables. If you need this in your code, please feel free to implement it and open a pull-request. 67 | -------------------------------------------------------------------------------- /Token.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Token struct holds the Microsoft Graph API authentication token used by GraphClient to authenticate API-requests to the ms graph API 10 | type Token struct { 11 | TokenType string // should always be "Bearer" for msgraph API-calls 12 | NotBefore time.Time // time when the access token starts to be valid 13 | ExpiresOn time.Time // time when the access token expires 14 | Resource string // will most likely be https://graph.microsoft.*, hence the Service Root Endpoint 15 | AccessToken string // the access-token itself 16 | } 17 | 18 | func (t Token) String() string { 19 | return fmt.Sprintf("Token {TokenType: \"%v\", NotBefore: \"%v\", ExpiresOn: \"%v\", "+ 20 | "Resource: \"%v\", AccessToken: \"%v\"}", 21 | t.TokenType, t.ExpiresOn, t.NotBefore, t.Resource, t.AccessToken) 22 | } 23 | 24 | // GetAccessToken teturns the API access token in Bearer format representation ready to send to the API interface. 25 | func (t Token) GetAccessToken() string { 26 | return fmt.Sprintf("%v %v", t.TokenType, t.AccessToken) 27 | } 28 | 29 | // IsValid returns true if the token is already valid and is still valid. Otherwise false. 30 | // 31 | // Hint: this is a wrapper for >>token.IsAlreadyValid() && token.IsStillValid()<< 32 | func (t Token) IsValid() bool { 33 | return t.IsAlreadyValid() && t.IsStillValid() 34 | } 35 | 36 | // IsAlreadyValid returns true if the token is already valid, hence the 37 | // NotBefore is before the current time. Otherwise false. 38 | // 39 | // Hint: The current time is determined by time.Now() 40 | func (t Token) IsAlreadyValid() bool { 41 | return time.Now().After(t.NotBefore) 42 | } 43 | 44 | // IsStillValid returns true if the token is still valid, hence the current time is before ExpiresOn. 45 | // Does NOT check it the token is yet valid or in the future. 46 | // 47 | // Hint: The current time is determined by time.Now() 48 | func (t Token) IsStillValid() bool { 49 | return time.Now().Before(t.ExpiresOn) 50 | } 51 | 52 | // HasExpired returns true if the token has already expired. 53 | // 54 | // Hint: this is a wrapper for >>!token.IsStillValid()<< 55 | func (t Token) HasExpired() bool { 56 | return !t.IsStillValid() 57 | } 58 | 59 | // WantsToBeRefreshed returns true if the token is already invalid or close to 60 | // expire (10 second before ExpiresOn), otherwise false. time.Now() is used to 61 | // determine the current time. 62 | func (t Token) WantsToBeRefreshed() bool { 63 | return !t.IsValid() || time.Now().After(t.ExpiresOn.Add(-10*time.Second)) 64 | } 65 | 66 | // UnmarshalJSON implements the json unmarshal to be used by the json-library. 67 | // 68 | // Hint: the UnmarshalJSON also checks immediately if the token is valid, hence 69 | // the current time.Now() is after NotBefore and before ExpiresOn 70 | func (t *Token) UnmarshalJSON(data []byte) error { 71 | tmp := struct { 72 | TokenType string `json:"token_type"` // should normally be "Bearer" 73 | ExpiresOn int64 `json:"expires_on,string"` // = UNIX timestamp, parse to int64 immediately 74 | NotBefore int64 `json:"not_before,string"` // = UNIX timestamp, parse to int64 immediately 75 | Resource string `json:"resource"` // will typically be https://graph.microsoft.com or wherever it came from 76 | AccessToken string `json:"access_token"` // the actual access token - veeery long string 77 | //ExpiresIn string `json:"expires_in"` // not used 78 | }{} 79 | 80 | // unmarshal to tmp-struct, return if error 81 | if err := json.Unmarshal(data, &tmp); err != nil { 82 | return fmt.Errorf("err on json.Unmarshal: %v | Data: %v", err, string(data)) 83 | } 84 | 85 | t.TokenType = tmp.TokenType 86 | t.ExpiresOn = time.Unix(tmp.ExpiresOn, 0) 87 | t.NotBefore = time.Unix(tmp.NotBefore, 0) 88 | t.Resource = tmp.Resource 89 | t.AccessToken = tmp.AccessToken 90 | 91 | if t.HasExpired() { 92 | return fmt.Errorf("Access-Token ExpiresOn %v is before current system-time %v", t.ExpiresOn, time.Now()) 93 | } 94 | if !t.IsAlreadyValid() { 95 | return fmt.Errorf("Access-Token NotBefore %v is after current system-time %v", t.NotBefore, time.Now()) 96 | } 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /constants.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | // FullDayEventTimeZone is used by CalendarEvent.UnmarshalJSON to set the timezone for full day events. 9 | // 10 | // That method json-unmarshal automatically sets the Begin/End Date to 00:00 with the correnct days then. 11 | // This has to be done because Microsoft always sets the timezone to UTC for full day events. To work 12 | // with that within your program is probably a bad idea, hence configure this as you need or 13 | // probably even back to time.UTC 14 | var FullDayEventTimeZone = time.Local 15 | 16 | const ( 17 | 18 | // Azure AD authentication endpoint "Global". Used to aquire a token for the ms graph API connection. 19 | // 20 | // Microsoft Documentation: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 21 | AzureADAuthEndpointGlobal string = "https://login.microsoftonline.com" 22 | 23 | // Azure AD authentication endpoint "Germany". Used to aquire a token for the ms graph API connection. 24 | // 25 | // Microsoft Documentation: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 26 | AzureADAuthEndpointGermany string = "https://login.microsoftonline.de" 27 | 28 | // Azure AD authentication endpoint "US Government". Used to aquire a token for the ms graph API connection. 29 | // 30 | // Microsoft Documentation: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 31 | AzureADAuthEndpointUSGov string = "https://login.microsoftonline.us" 32 | 33 | // Azure AD authentication endpoint "China by 21 Vianet". Used to aquire a token for the ms graph API connection. 34 | // 35 | // Microsoft Documentation: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 36 | AzureADAuthEndpointChina string = "https://login.partner.microsoftonline.cn" 37 | 38 | // ServiceRootEndpointGlobal represents the default Service Root Endpoint used to perform all ms graph 39 | // API-calls, hence the Service Root Endpoint. 40 | // 41 | // See https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 42 | ServiceRootEndpointGlobal string = "https://graph.microsoft.com" 43 | 44 | // Service Root Endpoint "US Government L4". 45 | // 46 | // See https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints 47 | ServiceRootEndpointUSGovL4 string = "https://graph.microsoft.us" 48 | 49 | // Service Root Endpoint "US Government L5 (DOD)". 50 | // 51 | // See https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints 52 | ServiceRootEndpointUSGovL5 string = "https://dod-graph.microsoft.us" 53 | 54 | // Service Root Endpoint "Germany". 55 | // 56 | // See https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints 57 | ServiceRootEndpointGermany string = "https://graph.microsoft.de" 58 | 59 | // Service Root Endpoint "China operated by 21Vianet". 60 | // 61 | // See https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints 62 | ServiceRootEndpointChina string = "https://microsoftgraph.chinacloudapi.cn" 63 | ) 64 | 65 | // APIVersion represents the APIVersion of msgraph used by this implementation 66 | const APIVersion string = "v1.0" 67 | 68 | // MaxPageSize is the maximum Page size for an API-call. This will be rewritten to use paging some day. Currently limits environments to 999 entries (e.g. Users, CalendarEvents etc.) 69 | const MaxPageSize int = 999 70 | 71 | var ( 72 | // ErrFindUser is returned on any func that tries to find a user with the given parameters that cannot be found 73 | ErrFindUser = errors.New("unable to find user") 74 | // ErrFindGroup is returned on any func that tries to find a group with the given parameters that cannot be found 75 | ErrFindGroup = errors.New("unable to find group") 76 | // ErrFindCalendar is returned on any func that tries to find a calendar with the given parameters that cannot be found 77 | ErrFindCalendar = errors.New("unable to find calendar") 78 | // ErrNotGraphClientSourced is returned if e.g. a ListMembers() is called but the Group has not been created by a graphClient query 79 | ErrNotGraphClientSourced = errors.New("instance is not created from a GraphClient API-Call, cannot directly get further information") 80 | ) 81 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Atom 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | The following is a set of guidelines for contributing to go-msgraph. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. 6 | 7 | ## How Can I Contribute? 8 | 9 | You can either report bugs, create feature requests as an issue so that the community can work on it, or you are very welcome to implement the code yourself and submit it as a pull-request. 10 | 11 | ## Suggested Development Environment 12 | 13 | The program is as of 08/2021 developed with [Visual Studio Code](https://code.visualstudio.com/) with several plugins and [docker](https://docs.docker.com/). With that setup it's easy to update to the latest docker version and not have any golang version installed locally. 14 | 15 | Install the following to use this: 16 | 17 | 1. [Visual Studio Code](https://code.visualstudio.com/) with the following Plugins: 18 | * [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go) 19 | * [Markdown All in One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one) 20 | * [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 21 | 2. [Docker Engine](https://docs.docker.com/install) 22 | 23 | The repository contains a `.devcontainer/devcontainer.json` that automatically configures this docker container used for development. 24 | 25 | ## Code Testing 26 | 27 | If you want to run `go test` locally, you *must* set the following environment variables: 28 | 29 | * `MSGraphTenantID`: Microsoft Graph API TenantID 30 | * `MSGraphApplicationID`: Microsoft Graph Application ID 31 | * `MSGraphClientSecret`: Microsoft Graph ClientSecret 32 | * `MSGraphExistingGroupDisplayName`: The name of an existing Group in Azure Active Directory 33 | * `MSGraphExistingGroupDisplayNameNumRes`: The number of results when searching for the above group. E.g. the group "users" will return three results upon searching, if the following groups exist: "users", "users-sales", "technicians-users-only" 34 | * `MSGraphExistingUserPrincipalInGroup`: a `userPrincipalName` that is member of the group above, format: `alice@contoso.com` 35 | 36 | Furthermore, the following environment variables are *optional*: 37 | * `MSGraphAzureADAuthEndpoint`: Defaults to `msgraph.AzureADAuthEndpoint`, hence https://login.microsoftonline.com. Set this environment variable to use e.g. a US endpoint 38 | * `MSGraphServiceRootEndpoint`: Defaults to `msgraph.ServiceRootEndpoint`, hence https://graph.microsoft.com. Set this environment variable to use e.g. a US endpoint 39 | * `MSGraphExistingCalendarsOfUser`: Existing calendars of the above users, separated by a comma (`,`). This is optional, tests are skipped if not present. In case you dont use Office 365 / Mailboxes. 40 | 41 | This may be done locally, or in `.devcontainer/devcontainer.json` if you use my suggested environment, but be careful not to add the changes to the commit (!): 42 | 43 | ````json 44 | { 45 | // ... 46 | "settings": { 47 | "go.toolsManagement.checkForUpdates": "local", 48 | "go.useLanguageServer": true, 49 | "go.gopath": "/go", 50 | "go.goroot": "/usr/local/go", 51 | "go.testEnvVars": { 52 | "MSGraphTenantID": "67dce6ac-xxxx-xxxx-xxxx-0807c45243a7", 53 | "MSGraphApplicationID": "1b99ac3b-xxxx-xxxx-xxxx-6f7998277091", 54 | "MSGraphClientSecret": "PZ.Wzfbxxxxxxxxxxxx2oe++TOid/YVG", 55 | "MSGraphExistingGroupDisplayName": "technicians", 56 | "MSGraphExistingGroupDisplayNameNumRes" : "1", 57 | "MSGraphExistingUserPrincipalInGroup": "alice@contoso.com", 58 | "MSGraphAzureADAuthEndpoint": "https://login.microsoftonline.com", 59 | "MSGraphServiceRootEndpoint": "https://graph.microsoft.com", 60 | "MSGraphExistingCalendarsOfUser" : "Calendar,Birthdays", 61 | }, 62 | }, 63 | // ... 64 | } 65 | ```` 66 | 67 | If you use Visual Studio Code without `Remote Contaienrs`, you may place it in `.vscode/settings.json`: 68 | 69 | ````json 70 | { 71 | "go.testEnvVars": { 72 | "MSGraphTenantID": "67dce6ac-xxxx-xxxx-xxxx-0807c45243a7", 73 | "MSGraphApplicationID": "1b99ac3b-xxxx-xxxx-xxxx-6f7998277091", 74 | "MSGraphClientSecret": "PZ.Wzfbxxxxxxxxxxxx2oe++TOid/YVG", 75 | "MSGraphExistingGroupDisplayName": "technicians", 76 | "MSGraphExistingGroupDisplayNameNumRes" : "1", 77 | "MSGraphExistingUserPrincipalInGroup": "alice@contoso.com", 78 | "MSGraphExistingCalendarsOfUser" : "Calendar,Birthdays", 79 | }, 80 | } 81 | ```` 82 | 83 | ## Code Syleguide 84 | 85 | * code is formatted with `go fmt` 86 | -------------------------------------------------------------------------------- /Group_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func GetTestGroup(t *testing.T) Group { 9 | t.Helper() 10 | groups, err := graphClient.ListGroups() 11 | if err != nil { 12 | t.Fatalf("Cannot GraphClient.ListGroups(): %v", err) 13 | } 14 | groupTest, err := groups.GetByDisplayName(msGraphExistingGroupDisplayName) 15 | if err != nil { 16 | t.Fatalf("Cannot groups.GetByDisplayName(%v): %v", msGraphExistingGroupDisplayName, err) 17 | } 18 | return groupTest 19 | } 20 | 21 | func TestGroup_ListMembers(t *testing.T) { 22 | groupTest := GetTestGroup(t) 23 | 24 | tests := []struct { 25 | name string 26 | g Group 27 | want Users 28 | wantErr bool 29 | }{ 30 | { 31 | name: "GraphClient created Group", 32 | g: groupTest, 33 | want: Users{User{UserPrincipalName: msGraphExistingUserPrincipalInGroup}}, 34 | wantErr: false, 35 | }, { 36 | name: "Not GraphClient created Group", 37 | g: Group{DisplayName: "Test"}, 38 | want: Users{}, 39 | wantErr: true, 40 | }, 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | got, err := tt.g.ListMembers() 45 | if (err != nil) != tt.wantErr { 46 | t.Errorf("Group.ListMembers() error = %v, wantErr %v", err, tt.wantErr) 47 | return 48 | } 49 | var found bool 50 | for _, searchObj := range tt.want { 51 | for _, checkObj := range got { 52 | found = found || searchObj.UserPrincipalName == checkObj.UserPrincipalName 53 | } 54 | } 55 | if !found && len(tt.want) > 0 { 56 | t.Errorf("GraphClient.ListGroups() = %v, searching for one of %v", got, tt.want) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestGroup_String(t *testing.T) { 63 | testGroup := GetTestGroup(t) 64 | 65 | tests := []struct { 66 | name string 67 | g Group 68 | want string 69 | }{ 70 | { 71 | name: "Test All Groups", 72 | g: testGroup, 73 | want: fmt.Sprintf("Group(ID: \"%v\", Description: \"%v\" DisplayName: \"%v\", CreatedDateTime: \"%v\", GroupTypes: \"%v\", Mail: \"%v\", MailEnabled: \"%v\", MailNickname: \"%v\", OnPremisesLastSyncDateTime: \"%v\", OnPremisesSecurityIdentifier: \"%v\", OnPremisesSyncEnabled: \"%v\", ProxyAddresses: \"%v\", SecurityEnabled \"%v\", Visibility: \"%v\", DirectAPIConnection: %v)", 74 | testGroup.ID, testGroup.Description, testGroup.DisplayName, testGroup.CreatedDateTime, testGroup.GroupTypes, testGroup.Mail, testGroup.MailEnabled, testGroup.MailNickname, testGroup.OnPremisesLastSyncDateTime, testGroup.OnPremisesSecurityIdentifier, testGroup.OnPremisesSyncEnabled, testGroup.ProxyAddresses, testGroup.SecurityEnabled, testGroup.Visibility, testGroup.graphClient != nil), 75 | }, 76 | } 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | if got := tt.g.String(); got != tt.want { 80 | t.Errorf("Group.String() = %v, want %v", got, tt.want) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestGroup_ListTransitiveMembers(t *testing.T) { 87 | testGroup := GetTestGroup(t) 88 | 89 | tests := []struct { 90 | name string 91 | g Group 92 | wantErr bool 93 | }{ 94 | { 95 | name: "GraphClient created Group", 96 | g: testGroup, 97 | wantErr: false, 98 | }, { 99 | name: "Not GraphClient created Group", 100 | g: Group{DisplayName: "Test not GraphClient sourced"}, 101 | wantErr: true, 102 | }, 103 | } 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | got, err := tt.g.ListTransitiveMembers() 107 | if (err != nil) != tt.wantErr { 108 | t.Errorf("Group.ListTransitiveMembers() error = %v, wantErr %v", err, tt.wantErr) 109 | return 110 | } 111 | if !tt.wantErr && len(got) == 0 { 112 | t.Errorf("Group.ListTransitiveMembers() = %v, len(%d), want at least one member of that group", got, len(got)) 113 | } 114 | }) 115 | } 116 | } 117 | 118 | func TestGroup_GetMemberGroupsAsStrings(t *testing.T) { 119 | testGroup := GetTestGroup(t) 120 | 121 | tests := []struct { 122 | name string 123 | g Group 124 | opts []GetQueryOption 125 | wantErr bool 126 | }{ 127 | { 128 | name: "Test group func GetMembershipGroupsAsStrings", 129 | g: testGroup, 130 | wantErr: false, 131 | }, { 132 | name: "Test group func GetMembershipGroupsAsStrings - no securityGroupsEnabeledF", 133 | g: testGroup, 134 | wantErr: false, 135 | }, 136 | { 137 | name: "Group not initialized by GraphClient", 138 | wantErr: true, 139 | }, 140 | } 141 | for _, tt := range tests { 142 | t.Run(tt.name, func(t *testing.T) { 143 | got, err := tt.g.GetMemberGroupsAsStrings(tt.opts...) 144 | if (err != nil) != tt.wantErr { 145 | t.Errorf("Group.GetMemberGroupsAsStrings() error = %v, wantErr %v", err, tt.wantErr) 146 | return 147 | } 148 | if !tt.wantErr && len(got) == 0 { 149 | t.Errorf("Group.GetMemberGroupsAsStrings() = %v, len(%d), want at least one value", got, len(got)) 150 | } 151 | }) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Users_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var ( 9 | testUser1 = User{ID: "567898765678", UserPrincipalName: "test1@contoso.com", Mail: "test1@contoso.com", MobilePhone: "+1 12346789"} 10 | testUser2 = User{ID: "123456", UserPrincipalName: "test2@contoso.com", Mail: "test2@contoso.com", BusinessPhones: []string{"+9 9876543"}} 11 | ) 12 | 13 | func TestUsers_GetUserByShortName(t *testing.T) { 14 | type args struct { 15 | shortName string 16 | } 17 | tests := []struct { 18 | name string 19 | u Users 20 | args args 21 | want User 22 | wantErr bool 23 | }{ 24 | { 25 | name: "Find user", 26 | u: Users{testUser1, testUser2}, 27 | args: args{shortName: testUser1.GetShortName()}, 28 | want: testUser1, 29 | wantErr: false, 30 | }, { 31 | name: "Missing user", 32 | u: Users{testUser1, testUser2}, 33 | args: args{shortName: "forsurenotexistingusername@contoso.com"}, 34 | want: User{}, 35 | wantErr: true, 36 | }, { 37 | name: "faulty userprincipal", 38 | u: Users{testUser1, testUser2}, 39 | args: args{shortName: "forsurenotexistingusername"}, 40 | want: User{}, 41 | wantErr: true, 42 | }, 43 | } 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | got, err := tt.u.GetUserByShortName(tt.args.shortName) 47 | if (err != nil) != tt.wantErr { 48 | t.Errorf("Users.GetUserByShortName() error = %v, wantErr %v", err, tt.wantErr) 49 | return 50 | } 51 | if !reflect.DeepEqual(got, tt.want) { 52 | t.Errorf("Users.GetUserByShortName() = %v, want %v", got, tt.want) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestUsers_GetUserByActivePhone(t *testing.T) { 59 | type args struct { 60 | activePhone string 61 | } 62 | tests := []struct { 63 | name string 64 | u Users 65 | args args 66 | want User 67 | wantErr bool 68 | }{ 69 | { 70 | name: "Find user with mobile phone", 71 | u: Users{testUser1, testUser2}, 72 | args: args{activePhone: testUser1.GetActivePhone()}, 73 | want: testUser1, 74 | wantErr: false, 75 | }, { 76 | name: "Find user with business phone", 77 | u: Users{testUser1, testUser2}, 78 | args: args{activePhone: testUser2.GetActivePhone()}, 79 | want: testUser2, 80 | wantErr: false, 81 | }, { 82 | name: "Missing user", 83 | u: Users{testUser1, testUser2}, 84 | args: args{activePhone: "123"}, 85 | want: User{}, 86 | wantErr: true, 87 | }, 88 | } 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | got, err := tt.u.GetUserByActivePhone(tt.args.activePhone) 92 | if (err != nil) != tt.wantErr { 93 | t.Errorf("Users.GetUserByActivePhone() error = %v, wantErr %v", err, tt.wantErr) 94 | return 95 | } 96 | if !reflect.DeepEqual(got, tt.want) { 97 | t.Errorf("Users.GetUserByActivePhone() = %v, want %v", got, tt.want) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func TestUsers_GetUserByMail(t *testing.T) { 104 | type args struct { 105 | email string 106 | } 107 | tests := []struct { 108 | name string 109 | u Users 110 | args args 111 | want User 112 | wantErr bool 113 | }{ 114 | { 115 | name: "Find user", 116 | u: Users{testUser1, testUser2}, 117 | args: args{email: testUser1.Mail}, 118 | want: testUser1, 119 | wantErr: false, 120 | }, { 121 | name: "Missing user", 122 | u: Users{testUser1, testUser2}, 123 | args: args{email: "forsurenotexistingusername@contoso.com"}, 124 | want: User{}, 125 | wantErr: true, 126 | }, 127 | } 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | got, err := tt.u.GetUserByMail(tt.args.email) 131 | if (err != nil) != tt.wantErr { 132 | t.Errorf("Users.GetUserByMail() error = %v, wantErr %v", err, tt.wantErr) 133 | return 134 | } 135 | if !reflect.DeepEqual(got, tt.want) { 136 | t.Errorf("Users.GetUserByMail() = %v, want %v", got, tt.want) 137 | } 138 | }) 139 | } 140 | } 141 | 142 | func TestUsers_Equal(t *testing.T) { 143 | type args struct { 144 | other Users 145 | } 146 | tests := []struct { 147 | name string 148 | u Users 149 | args args 150 | want bool 151 | }{ 152 | { 153 | name: "Equal Users", 154 | u: Users{testUser1, testUser2}, 155 | args: args{other: Users{testUser2, testUser1}}, 156 | want: true, 157 | }, { 158 | name: "non-equal Users", 159 | u: Users{testUser1, testUser2}, 160 | args: args{other: Users{testUser2}}, 161 | want: false, 162 | }, { 163 | name: "too many users", 164 | u: Users{testUser1}, 165 | args: args{other: Users{testUser1, testUser2}}, 166 | want: false, 167 | }, 168 | } 169 | for _, tt := range tests { 170 | t.Run(tt.name, func(t *testing.T) { 171 | if got := tt.u.Equal(tt.args.other); got != tt.want { 172 | t.Errorf("Users.Equal() = %v, want %v", got, tt.want) 173 | } 174 | }) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Golang Microsoft Graph API implementation 2 | 3 | [![Latest Release](https://img.shields.io/github/v/release/open-networks/go-msgraph)](https://github.com/open-networks/go-msgraph/releases) 4 | [![Github Actions](https://github.com/open-networks/go-msgraph/actions/workflows/go.yml/badge.svg)](https://github.com/open-networks/go-msgraph/actions) 5 | [![godoc](https://godoc.org/github.com/open-networks/go-msgraph?status.svg)](https://godoc.org/github.com/open-networks/go-msgraph) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/open-networks/go-msgraph)](https://goreportcard.com/report/github.com/open-networks/go-msgraph) 7 | [![codebeat badge](https://codebeat.co/badges/9d93c0c6-a981-42d3-97a7-bb48c296257f)](https://codebeat.co/projects/github-com-open-networks-go-msgraph-master) 8 | [![codecov](https://codecov.io/gh/open-networks/go-msgraph/branch/master/graph/badge.svg)](https://codecov.io/gh/open-networks/go-msgraph) 9 | [![MIT License](https://img.shields.io/github/license/open-networks/go-msgraph)](LICENSE) 10 | 11 | `go-msgraph` is an incomplete go lang implementation of the Microsoft Graph API. See [Overview of Microsoft Graph](https://docs.microsoft.com/en-us/graph/overview) 12 | 13 | ## General 14 | 15 | This implementation has been written to get various user, group and calendar details out of a Microsoft Azure Active Directory and create / update the same. 16 | 17 | ## :warning: Deprecation warning 18 | 19 | This code was created as part of a software project developed by Open Networks GmbH, Austria (the one in Europe :wink:) in the year 2017. The company has been bought by Bechtle AG, Germany in 2021 and the software project is planned to be replaced by end of 2022. Furthermore, the employee mainly working on this project also left the company by April 2022 and may only contribute in his leisure time. 20 | 21 | Back in the days, there was no official support from Microsoft for Go lang. This support has been added in 2021, see [Issue #25](https://github.com/open-networks/go-msgraph/issues/25). 22 | 23 | Therefore, I strongly advise you to use the new official implementation from Microsoft at [Golang MSGraph SDK by Microsoft](https://github.com/microsoftgraph/msgraph-sdk-go). 24 | 25 | ## Features 26 | 27 | working & tested: 28 | 29 | - list users, groups, calendars, calendarevents 30 | - automatically grab & refresh token for API-access 31 | - json-load the GraphClient struct & initialize it 32 | - set timezone for full-day CalendarEvent 33 | - use `$select`, `$search` and `$filter` when querying data 34 | - `context`-aware API calls, can be cancelled. 35 | - loading huge data sets with paging, thanks to PR #20 - [@Goorsky123](https://github.com/Goorsky123) 36 | 37 | planned: 38 | 39 | - String func that only prints non-empty values of an object, e.g. User 40 | - add further support for mail, personal contacts (outlook), devices and apps, files etc. See [https://docs.microsoft.com/en-us/graph/overview](https://docs.microsoft.com/en-us/graph/overview) 41 | 42 | ## Example 43 | 44 | To get your credentials to access the Microsoft Graph API visit: [Register an application with Azure AD and create a service principal](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#register-an-application-with-azure-ad-and-create-a-service-principal) 45 | 46 | More examples can be found at the [docs](docs/). Here's a brief summary of some of the most common API-queries, ready to copy'n'paste: 47 | 48 | ````go 49 | // initialize GraphClient manually 50 | graphClient, err := msgraph.NewGraphClient("", "", "") 51 | if err != nil { 52 | fmt.Println("Credentials are probably wrong or system time is not synced: ", err) 53 | } 54 | 55 | // List all users 56 | users, err := graphClient.ListUsers() 57 | // Gets all the detailled information about a user identified by it's ID or userPrincipalName 58 | user, err := graphClient.GetUser("humpty@contoso.com") 59 | // List all groups 60 | groups, err := graphClient.ListGroups() 61 | // List all members of a group. 62 | groupMembers, err := groups[0].ListMembers() 63 | // Lists all Calendars of a user 64 | calendars, err := user.ListCalendars() 65 | 66 | // Let all full-day calendar events that are loaded from ms graph be set to timezone Europe/Vienna: 67 | // Standard is time.Local 68 | msgraph.FullDayEventTimeZone, _ = time.LoadLocation("Europe/Vienna") 69 | 70 | // Lists all CalendarEvents of the given userPrincipalName/ID that starts/ends within the the next 7 days 71 | startTime := time.Now() 72 | endTime := time.Now().Add(time.Hour * 24 * 7) 73 | events, err := graphClient.ListCalendarView("alice@contoso.com", startTime, endTime) 74 | ```` 75 | 76 | ## Versioning & backwards compatibility 77 | 78 | This project uses [Semantic versioning](https://semver.org/) with all tags prefixed with a `v`. Altough currently the case, I cannot promise to really keep everything backwards compatible for the 0.x version. If a 1.x version of this repository is ever released with enough API-calls implemented, I will keep this promise for sure. Any Breaking changes will be marked as such in the release notes of each release. 79 | 80 | ## Installation 81 | 82 | I recommend to use [go modules](https://blog.golang.org/using-go-modules) and always use the latest tagged [release](https://github.com/open-networks/go-msgraph/releases). You may directly download the source code there, but the preffered way to install and update is with `go get`: 83 | 84 | ```shell 85 | # Initially install 86 | go get github.com/open-networks/go-msgraph 87 | # Update 88 | go get -u github.com/open-networks/go-msgraph 89 | go mod tidy 90 | ``` 91 | 92 | ## Documentation 93 | 94 | There is some example code placed in the [docs/](docs/) folder. The code itself is pretty well documented with comments, hence see [http://godoc.org/github.com/open-networks/go-msgraph](http://godoc.org/github.com/open-networks/go-msgraph) or run: 95 | 96 | ```shell 97 | godoc github.com/open-networks/go-msgraph 98 | ``` 99 | 100 | ## License 101 | 102 | [MIT](LICENSE) 103 | -------------------------------------------------------------------------------- /Group.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Group represents one group of ms graph 10 | // 11 | // See: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/group_get 12 | type Group struct { 13 | ID string 14 | Description string 15 | DisplayName string 16 | CreatedDateTime time.Time 17 | GroupTypes []string 18 | Mail string 19 | MailEnabled bool 20 | MailNickname string 21 | OnPremisesLastSyncDateTime time.Time // defaults to 0001-01-01 00:00:00 +0000 UTC if there's none 22 | OnPremisesSecurityIdentifier string 23 | OnPremisesSyncEnabled bool 24 | ProxyAddresses []string 25 | SecurityEnabled bool 26 | Visibility string 27 | 28 | graphClient *GraphClient // the graphClient that called the group 29 | } 30 | 31 | func (g Group) String() string { 32 | return fmt.Sprintf("Group(ID: \"%v\", Description: \"%v\" DisplayName: \"%v\", CreatedDateTime: \"%v\", GroupTypes: \"%v\", Mail: \"%v\", MailEnabled: \"%v\", MailNickname: \"%v\", OnPremisesLastSyncDateTime: \"%v\", OnPremisesSecurityIdentifier: \"%v\", OnPremisesSyncEnabled: \"%v\", ProxyAddresses: \"%v\", SecurityEnabled \"%v\", Visibility: \"%v\", DirectAPIConnection: %v)", 33 | g.ID, g.Description, g.DisplayName, g.CreatedDateTime, g.GroupTypes, g.Mail, g.MailEnabled, g.MailNickname, g.OnPremisesLastSyncDateTime, g.OnPremisesSecurityIdentifier, g.OnPremisesSyncEnabled, g.ProxyAddresses, g.SecurityEnabled, g.Visibility, g.graphClient != nil) 34 | } 35 | 36 | // setGraphClient sets the graphClient instance in this instance and all child-instances (if any) 37 | func (g *Group) setGraphClient(gC *GraphClient) { 38 | g.graphClient = gC 39 | } 40 | 41 | // ListMembers - Get a list of the group's direct members. A group can have users, 42 | // contacts, and other groups as members. This operation is not transitive. This 43 | // method will currently ONLY return User-instances of members 44 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 45 | // 46 | // See https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/group_list_members 47 | func (g Group) ListMembers(opts ...ListQueryOption) (Users, error) { 48 | if g.graphClient == nil { 49 | return nil, ErrNotGraphClientSourced 50 | } 51 | resource := fmt.Sprintf("/groups/%v/members", g.ID) 52 | 53 | var marsh struct { 54 | Users Users `json:"value"` 55 | } 56 | marsh.Users.setGraphClient(g.graphClient) 57 | return marsh.Users, g.graphClient.makeGETAPICall(resource, compileListQueryOptions(opts), &marsh) 58 | } 59 | 60 | // Get a list of the group's members. A group can have users, devices, organizational contacts, and other groups as members. 61 | // This operation is transitive and returns a flat list of all nested members. 62 | // This method will currently ONLY return User-instances of members 63 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 64 | // 65 | // See https://docs.microsoft.com/en-us/graph/api/group-list-transitivemembers?view=graph-rest-1.0&tabs=http 66 | func (g Group) ListTransitiveMembers(opts ...ListQueryOption) (Users, error) { 67 | if g.graphClient == nil { 68 | return nil, ErrNotGraphClientSourced 69 | } 70 | resource := fmt.Sprintf("/groups/%v/transitiveMembers", g.ID) 71 | 72 | var marsh struct { 73 | Users Users `json:"value"` 74 | } 75 | marsh.Users.setGraphClient(g.graphClient) 76 | return marsh.Users, g.graphClient.makeGETAPICall(resource, compileListQueryOptions(opts), &marsh) 77 | } 78 | 79 | // GetMemberGroupsAsStrings returns a list of all group IDs the user is a member of. 80 | // 81 | // opts ...GetQueryOption - only msgraph.GetWithContext is supported. 82 | // 83 | // Reference: https://docs.microsoft.com/en-us/graph/api/directoryobject-getmembergroups?view=graph-rest-1.0&tabs=http 84 | func (g Group) GetMemberGroupsAsStrings(opts ...GetQueryOption) ([]string, error) { 85 | if g.graphClient == nil { 86 | return nil, ErrNotGraphClientSourced 87 | } 88 | return g.graphClient.getMemberGroups(g.ID, false, opts...) // securityEnabledOnly is not supported for Groups, see documentation / API-reference 89 | } 90 | 91 | // UnmarshalJSON implements the json unmarshal to be used by the json-library 92 | func (g *Group) UnmarshalJSON(data []byte) error { 93 | tmp := struct { 94 | ID string `json:"id"` 95 | Description string `json:"description"` 96 | DisplayName string `json:"displayName"` 97 | CreatedDateTime string `json:"createdDateTime"` 98 | GroupTypes []string `json:"groupTypes"` 99 | Mail string `json:"mail"` 100 | MailEnabled bool `json:"mailEnabled"` 101 | MailNickname string `json:"mailNickname"` 102 | OnPremisesLastSyncDateTime string `json:"onPremisesLastSyncDateTime"` 103 | OnPremisesSecurityIdentifier string `json:"onPremisesSecurityIdentifier"` 104 | OnPremisesSyncEnabled bool `json:"onPremisesSyncEnabled"` 105 | ProxyAddresses []string `json:"proxyAddresses"` 106 | SecurityEnabled bool `json:"securityEnabled"` 107 | Visibility string `json:"visibility"` 108 | }{} 109 | 110 | err := json.Unmarshal(data, &tmp) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | g.ID = tmp.ID 116 | g.Description = tmp.Description 117 | g.DisplayName = tmp.DisplayName 118 | g.CreatedDateTime, err = time.Parse(time.RFC3339, tmp.CreatedDateTime) 119 | if err != nil && tmp.CreatedDateTime != "" { 120 | return fmt.Errorf("cannot parse CreatedDateTime %v with RFC3339: %v", tmp.CreatedDateTime, err) 121 | } 122 | g.GroupTypes = tmp.GroupTypes 123 | g.Mail = tmp.Mail 124 | g.MailEnabled = tmp.MailEnabled 125 | g.MailNickname = tmp.MailNickname 126 | g.OnPremisesLastSyncDateTime, err = time.Parse(time.RFC3339, tmp.OnPremisesLastSyncDateTime) 127 | if err != nil && tmp.OnPremisesLastSyncDateTime != "" { 128 | return fmt.Errorf("cannot parse OnPremisesLastSyncDateTime %v with RFC3339: %v", tmp.OnPremisesLastSyncDateTime, err) 129 | } 130 | g.OnPremisesSecurityIdentifier = tmp.OnPremisesSecurityIdentifier 131 | g.OnPremisesSyncEnabled = tmp.OnPremisesSyncEnabled 132 | g.ProxyAddresses = tmp.ProxyAddresses 133 | g.SecurityEnabled = tmp.SecurityEnabled 134 | g.Visibility = tmp.Visibility 135 | 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /GraphClient_queryOptions.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | ) 8 | 9 | type getRequestParams interface { 10 | Context() context.Context 11 | Values() url.Values 12 | Headers() http.Header 13 | } 14 | 15 | type GetQueryOption func(opts *getQueryOptions) 16 | 17 | type ListQueryOption func(opts *listQueryOptions) 18 | 19 | type CreateQueryOption func(opts *createQueryOptions) 20 | 21 | type UpdateQueryOption func(opts *updateQueryOptions) 22 | 23 | type DeleteQueryOption func(opts *deleteQueryOptions) 24 | 25 | var ( 26 | // GetWithContext - add a context.Context to the HTTP request e.g. to allow cancellation 27 | GetWithContext = func(ctx context.Context) GetQueryOption { 28 | return func(opts *getQueryOptions) { 29 | opts.ctx = ctx 30 | } 31 | } 32 | 33 | // GetWithSelect - $select - Filters properties (columns) - https://docs.microsoft.com/en-us/graph/query-parameters#select-parameter 34 | GetWithSelect = func(selectParam string) GetQueryOption { 35 | return func(opts *getQueryOptions) { 36 | opts.queryValues.Add(odataSelectParamKey, selectParam) 37 | } 38 | } 39 | 40 | // ListWithContext - add a context.Context to the HTTP request e.g. to allow cancellation 41 | ListWithContext = func(ctx context.Context) ListQueryOption { 42 | return func(opts *listQueryOptions) { 43 | opts.ctx = ctx 44 | } 45 | } 46 | 47 | // ListWithSelect - $select - Filters properties (columns) - https://docs.microsoft.com/en-us/graph/query-parameters#select-parameter 48 | ListWithSelect = func(selectParam string) ListQueryOption { 49 | return func(opts *listQueryOptions) { 50 | opts.queryValues.Add(odataSelectParamKey, selectParam) 51 | } 52 | } 53 | 54 | // ListWithFilter - $filter - Filters results (rows) - https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter 55 | ListWithFilter = func(filterParam string) ListQueryOption { 56 | return func(opts *listQueryOptions) { 57 | opts.queryValues.Add(odataFilterParamKey, filterParam) 58 | } 59 | } 60 | 61 | // ListWithSearch - $search - Returns results based on search criteria - https://docs.microsoft.com/en-us/graph/query-parameters#search-parameter 62 | ListWithSearch = func(searchParam string) ListQueryOption { 63 | return func(opts *listQueryOptions) { 64 | opts.queryHeaders.Add("ConsistencyLevel", "eventual") 65 | opts.queryValues.Add(odataSearchParamKey, searchParam) 66 | } 67 | } 68 | 69 | // CreateWithContext - add a context.Context to the HTTP request e.g. to allow cancellation 70 | CreateWithContext = func(ctx context.Context) CreateQueryOption { 71 | return func(opts *createQueryOptions) { 72 | opts.ctx = ctx 73 | } 74 | } 75 | 76 | // UpdateWithContext - add a context.Context to the HTTP request e.g. to allow cancellation 77 | UpdateWithContext = func(ctx context.Context) UpdateQueryOption { 78 | return func(opts *updateQueryOptions) { 79 | opts.ctx = ctx 80 | } 81 | } 82 | // DeleteWithContext - add a context.Context to the HTTP request e.g. to allow cancellation 83 | DeleteWithContext = func(ctx context.Context) DeleteQueryOption { 84 | return func(opts *deleteQueryOptions) { 85 | opts.ctx = ctx 86 | } 87 | } 88 | ) 89 | 90 | // getQueryOptions allow to optionally pass OData query options 91 | // see https://docs.microsoft.com/en-us/graph/query-parameters 92 | type getQueryOptions struct { 93 | ctx context.Context 94 | queryValues url.Values 95 | } 96 | 97 | func (g *getQueryOptions) Context() context.Context { 98 | if g.ctx == nil { 99 | return context.Background() 100 | } 101 | return g.ctx 102 | } 103 | 104 | func (g getQueryOptions) Values() url.Values { 105 | return g.queryValues 106 | } 107 | 108 | func (g getQueryOptions) Headers() http.Header { 109 | return http.Header{} 110 | } 111 | 112 | func compileGetQueryOptions(options []GetQueryOption) *getQueryOptions { 113 | var opts = &getQueryOptions{ 114 | queryValues: url.Values{}, 115 | } 116 | for idx := range options { 117 | options[idx](opts) 118 | } 119 | 120 | return opts 121 | } 122 | 123 | // listQueryOptions allow to optionally pass OData query options 124 | // see https://docs.microsoft.com/en-us/graph/query-parameters 125 | type listQueryOptions struct { 126 | getQueryOptions 127 | queryHeaders http.Header 128 | } 129 | 130 | func (g *listQueryOptions) Context() context.Context { 131 | if g.ctx == nil { 132 | return context.Background() 133 | } 134 | return g.ctx 135 | } 136 | 137 | func (g listQueryOptions) Values() url.Values { 138 | return g.queryValues 139 | } 140 | 141 | func (g listQueryOptions) Headers() http.Header { 142 | return g.queryHeaders 143 | } 144 | 145 | func compileListQueryOptions(options []ListQueryOption) *listQueryOptions { 146 | var opts = &listQueryOptions{ 147 | getQueryOptions: getQueryOptions{ 148 | queryValues: url.Values{}, 149 | }, 150 | queryHeaders: http.Header{}, 151 | } 152 | for idx := range options { 153 | options[idx](opts) 154 | } 155 | 156 | return opts 157 | } 158 | 159 | // createQueryOptions allows to add a context to the request 160 | type createQueryOptions struct { 161 | getQueryOptions 162 | } 163 | 164 | func (g *createQueryOptions) Context() context.Context { 165 | if g.ctx == nil { 166 | return context.Background() 167 | } 168 | return g.ctx 169 | } 170 | 171 | func compileCreateQueryOptions(options []CreateQueryOption) *createQueryOptions { 172 | var opts = &createQueryOptions{ 173 | getQueryOptions: getQueryOptions{ 174 | queryValues: url.Values{}, 175 | }, 176 | } 177 | for idx := range options { 178 | options[idx](opts) 179 | } 180 | 181 | return opts 182 | } 183 | 184 | // updateQueryOptions allows to add a context to the request 185 | type updateQueryOptions struct { 186 | getQueryOptions 187 | } 188 | 189 | func (g *updateQueryOptions) Context() context.Context { 190 | if g.ctx == nil { 191 | return context.Background() 192 | } 193 | return g.ctx 194 | } 195 | 196 | func compileUpdateQueryOptions(options []UpdateQueryOption) *updateQueryOptions { 197 | var opts = &updateQueryOptions{ 198 | getQueryOptions: getQueryOptions{ 199 | queryValues: url.Values{}, 200 | }, 201 | } 202 | for idx := range options { 203 | options[idx](opts) 204 | } 205 | 206 | return opts 207 | } 208 | 209 | // deleteQueryOptions allows to add a context to the request 210 | type deleteQueryOptions struct { 211 | getQueryOptions 212 | } 213 | 214 | func (g *deleteQueryOptions) Context() context.Context { 215 | if g.ctx == nil { 216 | return context.Background() 217 | } 218 | return g.ctx 219 | } 220 | 221 | func compileDeleteQueryOptions(options []DeleteQueryOption) *deleteQueryOptions { 222 | var opts = &deleteQueryOptions{ 223 | getQueryOptions: getQueryOptions{ 224 | queryValues: url.Values{}, 225 | }, 226 | } 227 | for idx := range options { 228 | options[idx](opts) 229 | } 230 | 231 | return opts 232 | } 233 | -------------------------------------------------------------------------------- /User_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // GetTestUser returns a valid User instance for testing. Will issue a t.Fatalf if the 10 | // user cannot be loaded 11 | func GetTestUser(t *testing.T) User { 12 | t.Helper() 13 | userToTest, errUserToTest := graphClient.GetUser(msGraphExistingUserPrincipalInGroup) 14 | if errUserToTest != nil { 15 | t.Fatalf("Cannot find user %v for Testing", msGraphExistingUserPrincipalInGroup) 16 | } 17 | return userToTest 18 | } 19 | 20 | func TestUser_getTimeZoneChoices(t *testing.T) { 21 | userToTest := GetTestUser(t) 22 | if skipCalendarTests { 23 | t.Skip("Skipping due to missing 'MSGraphExistingCalendarsOfUser' value") 24 | } 25 | 26 | tests := []struct { 27 | name string 28 | u User 29 | want []supportedTimeZones 30 | wantErr bool 31 | }{ 32 | { 33 | name: "Test all", 34 | u: userToTest, 35 | want: nil, 36 | wantErr: false, 37 | }, 38 | } 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | _, err := tt.u.getTimeZoneChoices(compileGetQueryOptions([]GetQueryOption{})) 42 | if (err != nil) != tt.wantErr { 43 | t.Errorf("User.getTimeZoneChoices() error = %v, wantErr %v", err, tt.wantErr) 44 | return 45 | } 46 | 47 | //t.Errorf("Printing for sure") 48 | }) 49 | } 50 | } 51 | 52 | func TestUser_ListCalendars(t *testing.T) { 53 | if skipCalendarTests { 54 | t.Skip("Skipping due to missing 'MSGraphExistingCalendarsOfUser' value") 55 | } 56 | // testing for ErrNotGraphClientSourced 57 | notGraphClientSourcedUser := User{ID: "none"} 58 | _, err := notGraphClientSourcedUser.ListCalendars() 59 | if err != ErrNotGraphClientSourced { 60 | t.Errorf("Expected error \"ErrNotGraphClientSourced\", but got: %v", err) 61 | } 62 | // continue with normal tests 63 | userToTest := GetTestUser(t) 64 | 65 | var wantedCalendars []Calendar 66 | for _, calendarName := range msGraphExistingCalendarsOfUser { 67 | wantedCalendars = append(wantedCalendars, Calendar{Name: calendarName}) 68 | } 69 | 70 | tests := []struct { 71 | name string 72 | u User 73 | want Calendars 74 | wantErr bool 75 | }{ 76 | { 77 | name: "List All Calendars", 78 | u: userToTest, 79 | want: wantedCalendars, 80 | wantErr: false, 81 | }, 82 | } 83 | for _, tt := range tests { 84 | t.Run(tt.name, func(t *testing.T) { 85 | got, err := tt.u.ListCalendars() 86 | if (err != nil) != tt.wantErr { 87 | t.Errorf("GraphClient.ListUserCalendars() error = %v, wantErr %v", err, tt.wantErr) 88 | return 89 | } 90 | Outer: 91 | for _, searchCalendar := range tt.want { 92 | for _, toCompare := range got { 93 | if searchCalendar.Name == toCompare.Name { 94 | continue Outer 95 | } 96 | } 97 | t.Errorf("GraphClient.ListUserCalendars() = %v, want %v", got, tt.want) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func TestUser_ListCalendarView(t *testing.T) { 104 | if skipCalendarTests { 105 | t.Skip("Skipping due to missing 'MSGraphExistingCalendarsOfUser' value") 106 | } 107 | userToTest := GetTestUser(t) 108 | 109 | type args struct { 110 | startdate time.Time 111 | enddate time.Time 112 | } 113 | tests := []struct { 114 | name string 115 | u User 116 | args args 117 | wantErr bool 118 | }{ 119 | { 120 | name: "Existing User", 121 | u: userToTest, 122 | args: args{startdate: time.Now(), enddate: time.Now().Add(7 * 24 * time.Hour)}, 123 | wantErr: false, 124 | }, { 125 | name: "User not initialized by GraphClient", 126 | u: User{UserPrincipalName: "testuser"}, 127 | args: args{startdate: time.Now(), enddate: time.Now().Add(7 * 24 * time.Hour)}, 128 | wantErr: true, 129 | }, 130 | } 131 | for _, tt := range tests { 132 | t.Run(tt.name, func(t *testing.T) { 133 | got, err := tt.u.ListCalendarView(tt.args.startdate, tt.args.enddate) 134 | //fmt.Println("Got User.ListCalendarview(): ", got) 135 | if (err != nil) != tt.wantErr { 136 | t.Errorf("User.ListCalendarView() error = %v, wantErr %v", err, tt.wantErr) 137 | return 138 | } 139 | if err == nil && len(got) == 0 { 140 | t.Errorf("User.ListCalendarView() len is 0, but want > 0, Got: %v", got) 141 | } 142 | }) 143 | } 144 | } 145 | 146 | func TestUser_String(t *testing.T) { 147 | u := GetTestUser(t) 148 | tt := struct { 149 | name string 150 | u *User 151 | want string 152 | }{ 153 | name: "Test user func String", 154 | u: &u, 155 | want: fmt.Sprintf("User(ID: \"%v\", BusinessPhones: \"%v\", DisplayName: \"%v\", GivenName: \"%v\", JobTitle: \"%v\", "+ 156 | "Mail: \"%v\", MobilePhone: \"%v\", PreferredLanguage: \"%v\", Surname: \"%v\", UserPrincipalName: \"%v\", "+ 157 | "ActivePhone: \"%v\", DirectAPIConnection: %v)", 158 | u.ID, u.BusinessPhones, u.DisplayName, u.GivenName, u.JobTitle, u.Mail, u.MobilePhone, u.PreferredLanguage, u.Surname, 159 | u.UserPrincipalName, u.activePhone, u.graphClient != nil), 160 | } 161 | 162 | t.Run(tt.name, func(t *testing.T) { 163 | if got := tt.u.String(); got != tt.want { 164 | t.Errorf("User.String() = %v, want %v", got, tt.want) 165 | } 166 | }) 167 | } 168 | 169 | func TestUser_GetMemberGroupsAsStrings(t *testing.T) { 170 | u := GetTestUser(t) 171 | tests := []struct { 172 | name string 173 | u User 174 | securityGroupsEnabeled bool 175 | opt GetQueryOption 176 | wantErr bool 177 | }{ 178 | { 179 | name: "Test user func GetMembershipGroupsAsStrings", 180 | u: u, 181 | securityGroupsEnabeled: true, 182 | opt: GetWithContext(nil), 183 | wantErr: false, 184 | }, { 185 | name: "Test user func GetMembershipGroupsAsStrings - no securityGroupsEnabeledF", 186 | u: u, 187 | securityGroupsEnabeled: false, 188 | opt: GetWithContext(nil), 189 | wantErr: false, 190 | }, 191 | { 192 | name: "User not initialized by GraphClient", 193 | securityGroupsEnabeled: true, 194 | opt: GetWithContext(nil), 195 | wantErr: true, 196 | }, 197 | } 198 | for _, tt := range tests { 199 | t.Run(tt.name, func(t *testing.T) { 200 | got, err := tt.u.GetMemberGroupsAsStrings(tt.securityGroupsEnabeled, tt.opt) 201 | if (err != nil) != tt.wantErr { 202 | t.Errorf("User.GetMemberGroupsAsStrings() error = %v, wantErr %v", err, tt.wantErr) 203 | return 204 | } 205 | if !tt.wantErr && len(got) == 0 { 206 | t.Errorf("User.GetMemberGroupsAsStrings() = %v, len(%d), want at least one value", got, len(got)) 207 | } 208 | }) 209 | } 210 | } 211 | 212 | func TestUser_UpdateUser(t *testing.T) { 213 | // testing for ErrNotGraphClientSourced 214 | notGraphClientSourcedUser := User{ID: "none"} 215 | err := notGraphClientSourcedUser.UpdateUser(User{}) 216 | if err != ErrNotGraphClientSourced { 217 | t.Errorf("Expected error \"ErrNotGraphClientSourced\", but got: %v", err) 218 | } 219 | 220 | // continue with normal tests 221 | testuser := createUnitTestUser(t) 222 | 223 | targetedCompanyName := "go-msgraph unit test suite UpdateUser" + randomString(25) 224 | testuser.UpdateUser(User{CompanyName: targetedCompanyName}) 225 | getUser, err := graphClient.GetUser(testuser.ID, GetWithSelect("id,userPrincipalName,displayName,givenName,companyName")) 226 | if err != nil { 227 | testuser.DeleteUser() 228 | t.Errorf("Cannot perform User.UpdateUser, error: %v", err) 229 | } 230 | if getUser.CompanyName != targetedCompanyName { 231 | testuser.DeleteUser() 232 | t.Errorf("Performed User.UpdateUser, but CompanyName is still \"%v\" instead of wanted \"%v\"", getUser.CompanyName, targetedCompanyName) 233 | } 234 | err = testuser.DeleteUser() 235 | if err != nil { 236 | t.Errorf("Could not User.DeleteUser() (for %v) after User.UpdateUser tests: %v", testuser, err) 237 | } 238 | } 239 | 240 | func TestUser_GetShortName(t *testing.T) { 241 | // test a normal case 242 | testuser := User{UserPrincipalName: "dumpty@contoso.com"} 243 | if testuser.GetShortName() != "dumpty" { 244 | t.Errorf("user.GetShortName() should return \"dumpty\", but returns: %v", testuser.GetShortName()) 245 | } 246 | // test a case that actually should never happen... but we all know murphy 247 | testuser = User{UserPrincipalName: "alice"} 248 | if testuser.GetShortName() != "alice" { 249 | t.Errorf("user.GetShortName() should return \"alice\", but returns: %v", testuser.GetShortName()) 250 | } 251 | } 252 | 253 | func TestUser_GetFullName(t *testing.T) { 254 | testuser := User{GivenName: "Bob", Surname: "Rabbit"} 255 | wanted := fmt.Sprintf("%v %v", testuser.GivenName, testuser.Surname) 256 | if testuser.GetFullName() != wanted { 257 | t.Errorf("user.GetFullName() should return \"%v\", but returns: \"%v\"", wanted, testuser.GetFullName()) 258 | } 259 | } 260 | 261 | func TestUser_PrettySimpleString(t *testing.T) { 262 | testuser := User{GivenName: "Bob", Surname: "Rabbit", Mail: "bob.rabbit@contoso.com", MobilePhone: "+1 23456789"} 263 | wanted := fmt.Sprintf("{ %v (%v) (%v) }", testuser.GetFullName(), testuser.Mail, testuser.GetActivePhone()) 264 | if testuser.PrettySimpleString() != wanted { 265 | t.Errorf("user.GetFullName() should return \"%v\", but returns: \"%v\"", wanted, testuser.PrettySimpleString()) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /CalendarEvent.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // CalendarEvent represents a single event within a calendar 10 | type CalendarEvent struct { 11 | ID string 12 | CreatedDateTime time.Time // Creation time of the CalendarEvent, has the correct timezone set from OriginalStartTimeZone (json) 13 | LastModifiedDateTime time.Time // Last modified time of the CalendarEvent, has the correct timezone set from OriginalEndTimeZone (json) 14 | OriginalStartTimeZone *time.Location // The original start-timezone, is already integrated in the calendartimes. Caution: is UTC on full day events 15 | OriginalEndTimeZone *time.Location // The original end-timezone, is already integrated in the calendartimes. Caution: is UTC on full day events 16 | ICalUID string 17 | Subject string 18 | Importance string 19 | Sensitivity string 20 | IsAllDay bool // true = full day event, otherwise false 21 | IsCancelled bool // calendar event has been cancelled but is still in the calendar 22 | IsOrganizer bool // true if the calendar owner is the organizer 23 | SeriesMasterID string // the ID of the master-entry of this series-event if any 24 | ShowAs string 25 | Type string 26 | ResponseStatus ResponseStatus // how the calendar-owner responded to the event (normally "organizer" because support-calendar is the host) 27 | StartTime time.Time // starttime of the Event, correct timezone is set 28 | EndTime time.Time // endtime of the event, correct timezone is set 29 | 30 | Attendees Attendees // represents all attendees to this CalendarEvent 31 | OrganizerName string // the name of the organizer from the e-mail, not reliable to identify anyone 32 | OrganizerEMail string // the e-mail address of the organizer, use this to identify the user 33 | } 34 | 35 | // GetFirstAttendee returns the first Attendee that is not the organizer of the event from the Attendees array. 36 | // If none is found then an Attendee with the Name of "None" will be returned. 37 | func (c CalendarEvent) GetFirstAttendee() Attendee { 38 | for _, attendee := range c.Attendees { 39 | if attendee.Email != c.OrganizerEMail { 40 | return attendee 41 | } 42 | } 43 | 44 | return Attendee{Name: "None"} 45 | } 46 | 47 | func (c CalendarEvent) String() string { 48 | return fmt.Sprintf("CalendarEvent(ID: \"%v\", CreatedDateTime: \"%v\", LastModifiedDateTime: \"%v\", "+ 49 | "ICalUId: \"%v\", Subject: \"%v\", "+ 50 | "Importance: \"%v\", Sensitivity: \"%v\", IsAllDay: \"%v\", IsCancelled: \"%v\", "+ 51 | "IsOrganizer: \"%v\", SeriesMasterId: \"%v\", ShowAs: \"%v\", Type: \"%v\", ResponseStatus: \"%v\", "+ 52 | "Attendees: \"%v\", Organizer: \"%v\", Start: \"%v\", End: \"%v\")", c.ID, c.CreatedDateTime, c.LastModifiedDateTime, 53 | c.ICalUID, c.Subject, c.Importance, 54 | c.Sensitivity, c.IsAllDay, c.IsCancelled, c.IsOrganizer, c.SeriesMasterID, c.ShowAs, 55 | c.Type, c.ResponseStatus, c.Attendees, c.OrganizerName+" "+c.OrganizerEMail, c.StartTime, c.EndTime) 56 | } 57 | 58 | // PrettySimpleString returns all Calendar Events in a readable format, mostly used for logging purposes 59 | func (c CalendarEvent) PrettySimpleString() string { 60 | return fmt.Sprintf("{ %v (%v) [%v - %v] }", c.Subject, c.GetFirstAttendee().Name, c.StartTime, c.EndTime) 61 | } 62 | 63 | // Equal returns wether the CalendarEvent is identical to the given CalendarEvent 64 | func (c CalendarEvent) Equal(other CalendarEvent) bool { 65 | return c.ID == other.ID && c.CreatedDateTime.Equal(other.CreatedDateTime) && c.LastModifiedDateTime.Equal(other.LastModifiedDateTime) && 66 | c.ICalUID == other.ICalUID && c.Subject == other.Subject && c.Importance == other.Importance && c.Sensitivity == other.Sensitivity && 67 | c.IsAllDay == other.IsAllDay && c.IsCancelled == other.IsCancelled && c.IsOrganizer == other.IsOrganizer && 68 | c.SeriesMasterID == other.SeriesMasterID && c.ShowAs == other.ShowAs && c.Type == other.Type && c.ResponseStatus.Equal(other.ResponseStatus) && 69 | c.StartTime.Equal(other.StartTime) && c.EndTime.Equal(other.EndTime) && 70 | c.Attendees.Equal(other.Attendees) && c.OrganizerName == other.OrganizerName && c.OrganizerEMail == other.OrganizerEMail 71 | } 72 | 73 | // UnmarshalJSON implements the json unmarshal to be used by the json-library 74 | func (c *CalendarEvent) UnmarshalJSON(data []byte) error { 75 | tmp := struct { 76 | ID string `json:"id"` 77 | CreatedDateTime string `json:"createdDateTime"` 78 | LastModifiedDateTime string `json:"lastModifiedDateTime"` 79 | OriginalStartTimeZone string `json:"originalStartTimeZone"` 80 | OriginalEndTimeZone string `json:"originalEndTimeZone"` 81 | ICalUID string `json:"iCalUId"` 82 | Subject string `json:"subject"` 83 | Importance string `json:"importance"` 84 | Sensitivity string `json:"sensitivity"` 85 | IsAllDay bool `json:"isAllDay"` 86 | IsCancelled bool `json:"isCancelled"` 87 | IsOrganizer bool `json:"isOrganizer"` 88 | SeriesMasterID string `json:"seriesMasterId"` 89 | ShowAs string `json:"showAs"` 90 | Type string `json:"type"` 91 | ResponseStatus ResponseStatus `json:"responseStatus"` 92 | Start map[string]string `json:"start"` 93 | End map[string]string `json:"end"` 94 | Attendees Attendees `json:"attendees"` 95 | Organizer struct { 96 | EmailAddress struct { 97 | Name string `json:"name"` 98 | Address string `json:"address"` 99 | } `json:"emailAddress"` 100 | } `json:"organizer"` 101 | }{} 102 | 103 | var err error 104 | // unmarshal to tmp-struct, return if error 105 | if err = json.Unmarshal(data, &tmp); err != nil { 106 | return fmt.Errorf("error on json.Unmarshal: %v | Data: %v", err, string(data)) 107 | } 108 | 109 | c.ID = tmp.ID 110 | c.CreatedDateTime, err = time.Parse(time.RFC3339Nano, tmp.CreatedDateTime) 111 | if err != nil { 112 | return fmt.Errorf("cannot time.Parse with RFC3339Nano createdDateTime %v: %v", tmp.CreatedDateTime, err) 113 | } 114 | c.LastModifiedDateTime, err = time.Parse(time.RFC3339Nano, tmp.LastModifiedDateTime) 115 | if err != nil { 116 | return fmt.Errorf("cannot time.Parse with RFC3339Nano lastModifiedDateTime %v: %v", tmp.LastModifiedDateTime, err) 117 | } 118 | c.OriginalStartTimeZone, err = mapTimeZoneStrings(tmp.OriginalStartTimeZone) 119 | if err != nil { 120 | return fmt.Errorf("cannot time.LoadLocation originalStartTimeZone %v: %v", tmp.OriginalStartTimeZone, err) 121 | } 122 | c.OriginalEndTimeZone, err = mapTimeZoneStrings(tmp.OriginalEndTimeZone) 123 | if err != nil { 124 | return fmt.Errorf("cannot time.LoadLocation originalEndTimeZone %v: %v", tmp.OriginalEndTimeZone, err) 125 | } 126 | c.ICalUID = tmp.ICalUID 127 | c.Subject = tmp.Subject 128 | c.Importance = tmp.Importance 129 | c.Sensitivity = tmp.Sensitivity 130 | c.IsAllDay = tmp.IsAllDay 131 | c.IsCancelled = tmp.IsCancelled 132 | c.IsOrganizer = tmp.IsOrganizer 133 | c.SeriesMasterID = tmp.SeriesMasterID 134 | c.ShowAs = tmp.ShowAs 135 | c.Type = tmp.Type 136 | c.ResponseStatus = tmp.ResponseStatus 137 | c.Attendees = tmp.Attendees 138 | c.OrganizerName = tmp.Organizer.EmailAddress.Name 139 | c.OrganizerEMail = tmp.Organizer.EmailAddress.Address 140 | 141 | // Parse event start & endtime with timezone 142 | c.StartTime, err = parseTimeAndLocation(tmp.Start["dateTime"], tmp.Start["timeZone"]) // the timeZone is normally ALWAYS UTC, microsoft converts time date & time to that 143 | if err != nil { 144 | return fmt.Errorf("cannot parse start-dateTime %v AND timeZone %v: %v", tmp.Start["dateTime"], tmp.Start["timeZone"], err) 145 | } 146 | c.EndTime, err = parseTimeAndLocation(tmp.End["dateTime"], tmp.End["timeZone"]) // the timeZone is normally ALWAYS UTC, microsoft converts time date & time to that 147 | if err != nil { 148 | return fmt.Errorf("cannot parse end-dateTime %v AND timeZone %v: %v", tmp.End["dateTime"], tmp.End["timeZone"], err) 149 | } 150 | 151 | // Hint: OriginalStartTimeZone & end are UTC (set by microsoft) if it is a full-day event, this will be handled in the next section 152 | c.StartTime = c.StartTime.In(c.OriginalStartTimeZone) // move the StartTime to the orignal start-timezone 153 | c.EndTime = c.EndTime.In(c.OriginalEndTimeZone) // move the EndTime to the orignal end-timezone 154 | 155 | // Now check if it's a full-day event, if yes, the event is UTC anyway. We need it to be accurate for the program to work 156 | // hence we set it to time.Local. It can later be manipulated by the program to a different timezone but the times also have 157 | // to be recalculated. E.g. we set it to UTC+2 hence it will start at 02:00 and end at 02:00, not 00:00 -> manually set to 00:00 158 | if c.IsAllDay && FullDayEventTimeZone != time.UTC { 159 | // set to local location 160 | c.StartTime = c.StartTime.In(FullDayEventTimeZone) 161 | c.EndTime = c.EndTime.In(FullDayEventTimeZone) 162 | // get offset in seconds 163 | _, startOffSet := c.StartTime.Zone() 164 | _, endOffSet := c.EndTime.Zone() 165 | // decrease time to 00:00 again 166 | c.StartTime = c.StartTime.Add(-1 * time.Second * time.Duration(startOffSet)) 167 | c.EndTime = c.EndTime.Add(-1 * time.Second * time.Duration(endOffSet)) 168 | } 169 | 170 | return nil 171 | } 172 | 173 | // parseTimeAndLocation is just a helper method to shorten the code in the Unmarshal json 174 | func parseTimeAndLocation(timeToParse, locationToParse string) (time.Time, error) { 175 | parsedTime, err := time.Parse("2006-01-02T15:04:05.999999999", timeToParse) 176 | if err != nil { 177 | return time.Time{}, err 178 | } 179 | parsedTimeZone, err := time.LoadLocation(locationToParse) 180 | if err != nil { 181 | return time.Time{}, err 182 | } 183 | return parsedTime.In(parsedTimeZone), nil 184 | } 185 | 186 | // mapTimeZoneStrings maps various Timezones used by Microsoft to go-understandable timezones or returns the source-zone if no mapping is found 187 | func mapTimeZoneStrings(timeZone string) (*time.Location, error) { 188 | if timeZone == "tzone://Microsoft/Custom" { 189 | return FullDayEventTimeZone, nil 190 | } 191 | return globalSupportedTimeZones.GetTimeZoneByAlias(timeZone) 192 | } 193 | -------------------------------------------------------------------------------- /User.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // User represents a user from the ms graph API 12 | type User struct { 13 | ID string `json:"id,omitempty"` 14 | BusinessPhones []string `json:"businessPhones,omitempty"` 15 | DisplayName string `json:"displayName,omitempty"` 16 | GivenName string `json:"givenName,omitempty"` 17 | JobTitle string `json:"jobTitle,omitempty"` 18 | Mail string `json:"mail,omitempty"` 19 | MobilePhone string `json:"mobilePhone,omitempty"` 20 | PreferredLanguage string `json:"preferredLanguage,omitempty"` 21 | Surname string `json:"surname,omitempty"` 22 | UserPrincipalName string `json:"userPrincipalName,omitempty"` 23 | AccountEnabled bool `json:"accountEnabled,omitempty"` 24 | AssignedLicenses []AssignedLicense `json:"assignedLicenses,omitempty"` 25 | CompanyName string `json:"companyName,omitempty"` 26 | Department string `json:"department,omitempty"` 27 | MailNickname string `json:"mailNickname,omitempty"` 28 | PasswordProfile PasswordProfile `json:"passwordProfile,omitempty"` 29 | 30 | activePhone string // private cache for the active phone number 31 | graphClient *GraphClient // the graphClient that called the user 32 | } 33 | 34 | type AssignedLicense struct { 35 | DisabledPlans []string `json:"disabledPlans,omitempty"` 36 | SkuID string `json:"skuId,omitempty"` 37 | } 38 | 39 | type PasswordProfile struct { 40 | ForceChangePasswordNextSignIn bool `json:"forceChangePasswordNextSignIn,omitempty"` 41 | ForceChangePasswordNextSignInWithMfa bool `json:"forceChangePasswordNextSignInWithMfa,omitempty"` 42 | Password string `json:"password,omitempty"` 43 | } 44 | 45 | func (u User) String() string { 46 | return fmt.Sprintf("User(ID: \"%v\", BusinessPhones: \"%v\", DisplayName: \"%v\", GivenName: \"%v\", "+ 47 | "JobTitle: \"%v\", Mail: \"%v\", MobilePhone: \"%v\", PreferredLanguage: \"%v\", Surname: \"%v\", "+ 48 | "UserPrincipalName: \"%v\", ActivePhone: \"%v\", DirectAPIConnection: %v)", 49 | u.ID, u.BusinessPhones, u.DisplayName, u.GivenName, u.JobTitle, u.Mail, u.MobilePhone, u.PreferredLanguage, u.Surname, 50 | u.UserPrincipalName, u.activePhone, u.graphClient != nil) 51 | } 52 | 53 | // setGraphClient sets the graphClient instance in this instance and all child-instances (if any) 54 | func (u *User) setGraphClient(gC *GraphClient) { 55 | u.graphClient = gC 56 | } 57 | 58 | // ListCalendars returns all calendars associated to that user. 59 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 60 | // 61 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_calendars 62 | func (u User) ListCalendars(opts ...ListQueryOption) (Calendars, error) { 63 | if u.graphClient == nil { 64 | return Calendars{}, ErrNotGraphClientSourced 65 | } 66 | resource := fmt.Sprintf("/users/%v/calendars", u.ID) 67 | 68 | var marsh struct { 69 | Calendars Calendars `json:"value"` 70 | } 71 | err := u.graphClient.makeGETAPICall(resource, compileListQueryOptions(opts), &marsh) 72 | marsh.Calendars.setGraphClient(u.graphClient) 73 | return marsh.Calendars, err 74 | } 75 | 76 | // ListCalendarView returns the CalendarEvents of the given user within the specified 77 | // start- and endDateTime. The calendar used is the default calendar of the user. 78 | // Returns an error if the user it not GraphClient sourced or if there is any error 79 | // during the API-call. 80 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 81 | // 82 | // See https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_calendarview 83 | func (u User) ListCalendarView(startDateTime, endDateTime time.Time, opts ...ListQueryOption) (CalendarEvents, error) { 84 | if u.graphClient == nil { 85 | return CalendarEvents{}, ErrNotGraphClientSourced 86 | } 87 | 88 | if len(globalSupportedTimeZones.Value) == 0 { 89 | var err error 90 | // TODO: this is a dirty fix, because opts could contain other things than a context, e.g. select 91 | // parameters. This could produce unexpected outputs and therefore break the globalSupportedTimeZones variable. 92 | globalSupportedTimeZones, err = u.getTimeZoneChoices(compileListQueryOptions(opts)) 93 | if err != nil { 94 | return CalendarEvents{}, err 95 | } 96 | } 97 | 98 | resource := fmt.Sprintf("/users/%v/calendar/calendarview", u.ID) 99 | 100 | // set GET-Params for start and end time 101 | var reqOpt = compileListQueryOptions(opts) 102 | reqOpt.queryValues.Add("startdatetime", startDateTime.Format("2006-01-02T00:00:00")) 103 | reqOpt.queryValues.Add("enddatetime", endDateTime.Format("2006-01-02T00:00:00")) 104 | 105 | var calendarEvents CalendarEvents 106 | return calendarEvents, u.graphClient.makeGETAPICall(resource, reqOpt, &calendarEvents) 107 | } 108 | 109 | // getTimeZoneChoices grabs all supported time zones from microsoft for this user. 110 | // This should actually be the same for every user. Only used internally by this 111 | // msgraph package. 112 | // 113 | // See https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/outlookuser_supportedtimezones 114 | func (u User) getTimeZoneChoices(opts getRequestParams) (supportedTimeZones, error) { 115 | var ret supportedTimeZones 116 | err := u.graphClient.makeGETAPICall(fmt.Sprintf("/users/%s/outlook/supportedTimeZones", u.ID), opts, &ret) 117 | return ret, err 118 | } 119 | 120 | // GetActivePhone returns the space-trimmed active phone-number of the user. The active 121 | // phone number is either the MobilePhone number or the first business-Phone number 122 | func (u *User) GetActivePhone() string { 123 | if u.activePhone != "" { // use cached value if any 124 | return u.activePhone 125 | } 126 | // no cached active phone number, evaluate & cache it now: 127 | u.activePhone = strings.Replace(u.MobilePhone, " ", "", -1) 128 | if u.activePhone == "" && len(u.BusinessPhones) > 0 { 129 | u.activePhone = strings.Replace(u.BusinessPhones[0], " ", "", -1) 130 | } 131 | return u.activePhone 132 | } 133 | 134 | // GetShortName returns the first part of UserPrincipalName before the @. If there 135 | // is no @, then just the UserPrincipalName will be returned 136 | func (u User) GetShortName() string { 137 | supn := strings.Split(u.UserPrincipalName, "@") 138 | if len(supn) != 2 { 139 | return u.UserPrincipalName 140 | } 141 | return supn[0] 142 | } 143 | 144 | // GetFullName returns the full name in that format: 145 | func (u User) GetFullName() string { 146 | return fmt.Sprintf("%v %v", u.GivenName, u.Surname) 147 | } 148 | 149 | // GetMemberGroupsAsStrings returns a list of all group IDs the user is a member of. 150 | // You can specify the securityGroupsEnabeled parameter to only return security group IDs. 151 | // 152 | // opts ...GetQueryOption - only msgraph.GetWithContext is supported. 153 | // 154 | // Reference: https://docs.microsoft.com/en-us/graph/api/directoryobject-getmembergroups?view=graph-rest-1.0&tabs=http 155 | func (u User) GetMemberGroupsAsStrings(securityGroupsEnabeled bool, opts ...GetQueryOption) ([]string, error) { 156 | if u.graphClient == nil { 157 | return nil, ErrNotGraphClientSourced 158 | } 159 | return u.graphClient.getMemberGroups(u.ID, securityGroupsEnabeled, opts...) 160 | } 161 | 162 | // PrettySimpleString returns the User-instance simply formatted for logging purposes: {FullName (email) (activePhone)} 163 | func (u User) PrettySimpleString() string { 164 | return fmt.Sprintf("{ %v (%v) (%v) }", u.GetFullName(), u.Mail, u.GetActivePhone()) 165 | } 166 | 167 | // UpdateUser patches this user object. Note, only set the fields that should be changed. 168 | // 169 | // IMPORTANT: the user cannot be disabled (field AccountEnabled) this way, because the 170 | // default value of a boolean is false - and hence will not be posted via json - omitempty 171 | // is used. user func user.DisableAccount() instead. 172 | // 173 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user-update 174 | func (u User) UpdateUser(userInput User, opts ...UpdateQueryOption) error { 175 | if u.graphClient == nil { 176 | return ErrNotGraphClientSourced 177 | } 178 | resource := fmt.Sprintf("/users/%v", u.ID) 179 | 180 | bodyBytes, err := json.Marshal(userInput) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | reader := bytes.NewReader(bodyBytes) 186 | // Hint: API-call body does not return any data / no json object. 187 | err = u.graphClient.makePATCHAPICall(resource, compileUpdateQueryOptions(opts), reader, nil) 188 | return err 189 | } 190 | 191 | // DisableAccount disables the User-Account, hence sets the AccountEnabled-field to false. 192 | // This function must be used instead of user.UpdateUser, because the AccountEnabled-field 193 | // with json "omitempty" will never be sent when false. Without omitempty, the user account would 194 | // always accidentially disabled upon an update of e.g. only "DisplayName" 195 | // 196 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user-update 197 | func (u User) DisableAccount(opts ...UpdateQueryOption) error { 198 | if u.graphClient == nil { 199 | return ErrNotGraphClientSourced 200 | } 201 | resource := fmt.Sprintf("/users/%v", u.ID) 202 | 203 | bodyBytes, err := json.Marshal(struct { 204 | AccountEnabled bool `json:"accountEnabled"` 205 | }{AccountEnabled: false}) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | reader := bytes.NewReader(bodyBytes) 211 | // Hint: API-call body does not return any data / no json object. 212 | err = u.graphClient.makePATCHAPICall(resource, compileUpdateQueryOptions(opts), reader, nil) 213 | return err 214 | } 215 | 216 | // DeleteUser deletes this user instance at the Microsoft Azure AD. Use with caution. 217 | // 218 | // Reference: https://docs.microsoft.com/en-us/graph/api/user-delete 219 | func (u User) DeleteUser(opts ...DeleteQueryOption) error { 220 | if u.graphClient == nil { 221 | return ErrNotGraphClientSourced 222 | } 223 | resource := fmt.Sprintf("/users/%v", u.ID) 224 | 225 | // TODO: check return body, maybe there is some potential success or error message hidden in it? 226 | err := u.graphClient.makeDELETEAPICall(resource, compileDeleteQueryOptions(opts), nil) 227 | return err 228 | } 229 | 230 | // Equal returns wether the user equals the other User by comparing every property 231 | // of the user including the ID 232 | func (u User) Equal(other User) bool { 233 | var equalBool = true 234 | for i := 0; i < len(u.BusinessPhones) && i < len(other.BusinessPhones); i++ { 235 | equalBool = equalBool && u.BusinessPhones[i] == other.BusinessPhones[i] 236 | } 237 | equalBool = equalBool && len(u.BusinessPhones) == len(other.BusinessPhones) 238 | return equalBool && u.ID == other.ID && u.DisplayName == other.DisplayName && u.GivenName == other.GivenName && 239 | u.JobTitle == other.JobTitle && u.Mail == other.Mail && u.MobilePhone == other.MobilePhone && 240 | u.PreferredLanguage == other.PreferredLanguage && u.Surname == other.Surname && u.UserPrincipalName == other.UserPrincipalName 241 | } 242 | -------------------------------------------------------------------------------- /Security.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | // Alert represents a security alert. 9 | type Alert struct { 10 | ActivityGroupName string `json:"activityGroupName"` 11 | AssignedTo string `json:"assignedTo"` 12 | AzureSubscriptionID string `json:"azureSubscriptionId"` 13 | AzureTenantID string `json:"azureTenantId"` 14 | Category string `json:"category"` 15 | ClosedDateTime time.Time `json:"closedDateTime"` 16 | CloudAppStates []CloudAppSecurityState `json:"cloudAppStates"` 17 | Comments []string `json:"comments"` 18 | Confidence int32 `json:"confidence"` 19 | CreatedDateTime time.Time `json:"createdDateTime"` 20 | Description string `json:"description"` 21 | DetectionIDs []string `json:"detectionIds"` 22 | EventDateTime time.Time `json:"eventDateTime"` 23 | Feedback string `json:"feedback"` 24 | FileStates []FileSecurityState `json:"fileStates"` 25 | HostStates []HostSecurityState `json:"hostStates"` 26 | ID string `json:"id"` 27 | IncidentIDs []string `json:"incidentIds"` 28 | LastModifiedDateTime time.Time `json:"lastModifiedDateTime"` 29 | MalwareStates []MalwareState `json:"malwareStates"` 30 | NetworkConnections []NetworkConnection `json:"networkConnections"` 31 | Processes []Process `json:"processes"` 32 | RecommendedActions []string `json:"recommendedActions"` 33 | RegistryKeyStates []RegistryKeyState `json:"registryKeyStates"` 34 | SecurityResources []SecurityResource `json:"securityResources"` 35 | Severity string `json:"severity"` 36 | SourceMaterials []string `json:"sourceMaterials"` 37 | Status string `json:"status"` 38 | Tags []string `json:"tags"` 39 | Title string `json:"title"` 40 | Triggers []AlertTrigger `json:"triggers"` 41 | UserStates []UserSecurityState `json:"userStates"` 42 | VendorInformation SecurityVendorInformation `json:"vendorInformation"` 43 | VulnerabilityStates []VulnerabilityState `json:"vulnerabilityStates"` 44 | } 45 | 46 | // CloudAppSecurityState contains stateful information about a cloud application related to an alert. 47 | type CloudAppSecurityState struct { 48 | DestinationServiceIP net.IP `json:"destinationServiceIp"` 49 | DestinationServiceName string `json:"destinationServiceName"` 50 | RiskScore string `json:"riskScore"` 51 | } 52 | 53 | // FileSecurityState contains information about a file (not process) related to an alert. 54 | type FileSecurityState struct { 55 | FileHash FileHash `json:"fileHash"` 56 | Name string `json:"name"` 57 | Path string `json:"path"` 58 | RiskScore string `json:"riskScore"` 59 | } 60 | 61 | // FileHash contains hash information related to a file. 62 | type FileHash struct { 63 | HashType string `json:"hashType"` 64 | HashValue string `json:"hashValue"` 65 | } 66 | 67 | // HostSecurityState contains information about a host (computer, device, etc.) related to an alert. 68 | type HostSecurityState struct { 69 | FQDN string `json:"fqdn"` 70 | IsAzureAADJoined bool `json:"isAzureAadJoined"` 71 | IsAzurAADRegistered bool `json:"isAzureAadRegistered"` 72 | IsHybridAzureDomainJoined bool `json:"isHybridAzureDomainJoined"` 73 | NetBiosName string `json:"netBiosName"` 74 | OS string `json:"os"` 75 | PrivateIPAddress net.IP `json:"privateIpAddress"` 76 | PublicIPAddress net.IP `json:"publicIpAddress"` 77 | RiskScore string `json:"riskScore"` 78 | } 79 | 80 | // MalwareState contains information about a malware entity. 81 | type MalwareState struct { 82 | Category string `json:"category"` 83 | Family string `json:"family"` 84 | Name string `json:"name"` 85 | Severity string `json:"severity"` 86 | WasRunning bool `json:"wasRunning"` 87 | } 88 | 89 | // NetworkConnection contains stateful information describing a network connection related to an alert. 90 | type NetworkConnection struct { 91 | ApplicationName string `json:"applicationName"` 92 | DestinationAddress net.IP `json:"destinationAddress"` 93 | DestinationLocation string `json:"destinationLocation"` 94 | DestinationDomain string `json:"destinationDomain"` 95 | DestinationPort string `json:"destinationPort"` // spec calls it a string, not a number 96 | DestinationURL string `json:"destinationUrl"` 97 | Direction string `json:"direction"` 98 | DomainRegisteredDateTime time.Time `json:"domainRegisteredDateTime"` 99 | LocalDNSName string `json:"localDnsName"` 100 | NATDestinationAddress net.IP `json:"natDestinationAddress"` 101 | NATDestinationPort string `json:"natDestinationPort"` 102 | NATSourceAddress net.IP `json:"natSourceAddress"` 103 | NATSourcePort string `json:"natSourcePort"` 104 | Protocol string `json:"protocol"` 105 | RiskScore string `json:"riskScore"` 106 | SourceAddress net.IP `json:"sourceAddress"` 107 | SourceLocation string `json:"sourceLocation"` 108 | SourcePort string `json:"sourcePort"` 109 | Status string `json:"status"` 110 | URLParameters string `json:"urlParameters"` 111 | } 112 | 113 | // Process describes a process related to an alert. 114 | type Process struct { 115 | AccountName string `json:"accountName"` 116 | CommandLine string `json:"commandLine"` 117 | CreatedDateTime time.Time `json:"createdDateTime"` // translated 118 | FileHash FileHash `json:"fileHash"` 119 | IntegrityLevel string `json:"integrityLevel"` 120 | IsElevated bool `json:"isElevated"` 121 | Name string `json:"name"` 122 | ParentProcessCreatedDateTime time.Time `json:"parentProcessCreatedDateTime"` // translated 123 | ParentProcessID int32 `json:"parentProcessId"` 124 | ParentProcessName string `json:"parentProcessName"` 125 | Path string `json:"path"` 126 | ProcessID int32 `json:"processId"` 127 | } 128 | 129 | // RegistryKeyState contains information about registry key changes related to an alert, and about the process which changed the keys. 130 | type RegistryKeyState struct { 131 | Hive string `json:"hive"` 132 | Key string `json:"key"` 133 | OldKey string `json:"oldKey"` 134 | OldValueData string `json:"oldValueData"` 135 | OldValueName string `json:"oldValueName"` 136 | Operation string `json:"operation"` 137 | ProcessID int32 `json:"processId"` 138 | ValueData string `json:"valueData"` 139 | ValueName string `json:"valueName"` 140 | ValueType string `json:"valueType"` 141 | } 142 | 143 | // SecurityResource represents resources related to an alert. 144 | type SecurityResource struct { 145 | Resource string `json:"resource"` 146 | ResourceType string `json:"resourceType"` 147 | } 148 | 149 | // AlertTrigger contains information about a property which triggered an alert detection. 150 | type AlertTrigger struct { 151 | Name string `json:"name"` 152 | Type string `json:"type"` 153 | Value string `json:"value"` 154 | } 155 | 156 | // UserSecurityState contains stateful information about a user account related to an alert. 157 | type UserSecurityState struct { 158 | AADUserID string `json:"aadUserId"` 159 | AccountName string `json:"accountName"` 160 | DomainName string `json:"domainName"` 161 | EmailRole string `json:"emailRole"` 162 | IsVPN bool `json:"isVpn"` 163 | LogonDateTime time.Time `json:"logonDateTime"` 164 | LogonID string `json:"logonId"` 165 | LogonIP net.IP `json:"logonIp"` 166 | LogonLocation string `json:"logonLocation"` 167 | LogonType string `json:"logonType"` 168 | OnPremisesSecurityIdentifier string `json:"onPremisesSecurityIdentifier"` 169 | RiskScore string `json:"riskScore"` 170 | UserAccountType string `json:"userAccountType"` 171 | UserPrincipalName string `json:"userPrincipalName"` 172 | } 173 | 174 | // SecurityVendorInformation contains details about the vendor of a particular security product. 175 | type SecurityVendorInformation struct { 176 | Provider string `json:"provider"` 177 | ProviderVersion string `json:"providerVersion"` 178 | SubProvider string `json:"subProvider"` 179 | Vendor string `json:"vendor"` 180 | } 181 | 182 | // VulnerabilityState contains information about a particular vulnerability. 183 | type VulnerabilityState struct { 184 | CVE string `json:"cve"` 185 | Severity string `json:"severity"` 186 | WasRunning bool `json:"wasRunning"` 187 | } 188 | 189 | // ListAlerts returns a slice of Alert objects from MS Graph's security API. Each Alert represents a security event reported by some component. 190 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 191 | func (g *GraphClient) ListAlerts(opts ...ListQueryOption) ([]Alert, error) { 192 | resource := "/security/alerts" 193 | var marsh struct { 194 | Alerts []Alert `json:"value"` 195 | } 196 | err := g.makeGETAPICall(resource, compileListQueryOptions(opts), &marsh) 197 | return marsh.Alerts, err 198 | } 199 | 200 | // SecureScore represents the security score of a tenant for a particular day. 201 | type SecureScore struct { 202 | ID string `json:"id"` 203 | AzureTenantID string `json:"azureTenantId"` 204 | ActiveUserCount int32 `json:"activeUserCount"` 205 | CreatedDateTime time.Time `json:"createdDateTime"` 206 | CurrentScore float64 `json:"currentScore"` 207 | EnabledServices []string `json:"enabledServices"` 208 | LicensedUserCount int32 `json:"licensedUserCount"` 209 | MaxScore float64 `json:"maxScore"` 210 | AverageComparativeScores []AverageComparativeScore `json:"averageComparativeScores"` 211 | ControlScores []ControlScore `json:"controlScores"` 212 | VendorInformation SecurityVendorInformation `json:"vendorInformation"` 213 | } 214 | 215 | // AverageComparativeScore describes average scores across a variety of different scopes. 216 | // The Basis field may contain the strings "AllTenants", "TotalSeats", or "IndustryTypes". 217 | type AverageComparativeScore struct { 218 | Basis string `json:"basis"` 219 | AverageScore float64 `json:"averageScore"` 220 | } 221 | 222 | // ControlScore contains a score for a single security control. 223 | type ControlScore struct { 224 | ControlName string `json:"controlName"` 225 | Score float64 `json:"score"` 226 | ControlCategory string `json:"controlCategory"` 227 | Description string `json:"description"` 228 | } 229 | 230 | // ListSecureScores returns a slice of SecureScore objects. Each SecureScore represents 231 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 232 | // a tenant's security score for a particular day. 233 | func (g *GraphClient) ListSecureScores(opts ...ListQueryOption) ([]SecureScore, error) { 234 | resource := "/security/secureScores" 235 | var marsh struct { 236 | Scores []SecureScore `json:"value"` 237 | } 238 | err := g.makeGETAPICall(resource, compileListQueryOptions(opts), &marsh) 239 | return marsh.Scores, err 240 | } 241 | 242 | // SecureScoreControlProfile describes in greater detail the parameters of a given security 243 | // score control. 244 | type SecureScoreControlProfile struct { 245 | ID string `json:"id"` 246 | AzureTenantID string `json:"azureTenantId"` 247 | ActionType string `json:"actionType"` 248 | ActionURL string `json:"actionUrl"` 249 | ControlCategory string `json:"controlCategory"` 250 | Title string `json:"title"` 251 | Deprecated bool `json:"deprecated"` 252 | ImplementationCost string `json:"implementationCost"` 253 | LastModifiedDateTime time.Time `json:"lastModifiedDateTime"` 254 | MaxScore float64 `json:"maxScore"` 255 | Rank int32 `json:"rank"` 256 | Remediation string `json:"remediation"` 257 | RemediationImpact string `json:"remediationImpact"` 258 | Service string `json:"service"` 259 | Threats []string `json:"threats"` 260 | Tier string `json:"tier"` 261 | UserImpact string `json:"userImpact"` 262 | ComplianceInformation []ComplianceInformation `json:"complianceInformation"` 263 | ControlStateUpdates []SecureScoreControlStateUpdate `json:"controlStateUpdates"` 264 | VendorInformation SecurityVendorInformation `json:"vendorInformation"` 265 | } 266 | 267 | // ComplianceInformation contains compliance data associated with a secure score control. 268 | type ComplianceInformation struct { 269 | CertificationName string `json:"certificationName"` 270 | CertificationControls []CertificationControl `json:"certificationControls"` 271 | } 272 | 273 | // CertificationControl contains compliance certification data associated with a secure score control. 274 | type CertificationControl struct { 275 | Name string `json:"name"` 276 | URL string `json:"url"` 277 | } 278 | 279 | // SecureScoreControlStateUpdate records a particular historical state of the control state 280 | // as updated by the user. 281 | type SecureScoreControlStateUpdate struct { 282 | AssignedTo string `json:"assignedTo"` 283 | Comment string `json:"comment"` 284 | State string `json:"state"` 285 | UpdatedBy string `json:"updatedBy"` 286 | UpdatedDateTime time.Time `json:"updatedDateTime"` 287 | } 288 | 289 | // ListSecureScoreControlProfiles returns a slice of SecureScoreControlProfile objects. 290 | // Each object represents a secure score control profile, which is used when calculating 291 | // a tenant's secure score. 292 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 293 | func (g *GraphClient) ListSecureScoreControlProfiles(opts ...ListQueryOption) ([]SecureScoreControlProfile, error) { 294 | resource := "/security/secureScoreControlProfiles" 295 | var marsh struct { 296 | Profiles []SecureScoreControlProfile `json:"value"` 297 | } 298 | err := g.makeGETAPICall(resource, compileListQueryOptions(opts), &marsh) 299 | return marsh.Profiles, err 300 | } 301 | -------------------------------------------------------------------------------- /supportedTimeZones.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // globalSupportedTimeZones represents the instance that will be initialized once on runtime 10 | // and load all TimeZones form Microsoft, correlate them to IANA and set proper time.Location 11 | var globalSupportedTimeZones supportedTimeZones 12 | 13 | // supportedTimeZones represents multiple instances grabbed by https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/outlookuser_supportedtimezones 14 | type supportedTimeZones struct { 15 | Value []supportedTimeZone 16 | } 17 | 18 | // GetTimeZoneByAlias searches in the given set of supportedTimeZones for the TimeZone with the given alias. Returns 19 | // either the time.Location or an error if it cannot be found. 20 | func (s supportedTimeZones) GetTimeZoneByAlias(alias string) (*time.Location, error) { 21 | for _, searchItem := range s.Value { 22 | if searchItem.Alias == alias { 23 | return searchItem.TimeLoc, nil 24 | } 25 | } 26 | return nil, fmt.Errorf("could not find given time.Location for Alias %v", alias) 27 | } 28 | 29 | // GetTimeZoneByAlias searches in the given set of supportedTimeZones for the TimeZone with the given alias. Returns 30 | // either the time.Location or an error if it cannot be found. 31 | func (s supportedTimeZones) GetTimeZoneByDisplayName(displayName string) (*time.Location, error) { 32 | for _, searchItem := range s.Value { 33 | if searchItem.DisplayName == displayName { 34 | return searchItem.TimeLoc, nil 35 | } 36 | } 37 | return nil, fmt.Errorf("could not find given time.Location for DisplayName %v", displayName) 38 | } 39 | 40 | // supportedTimeZone represents one instance grabbed by https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/outlookuser_supportedtimezones 41 | type supportedTimeZone struct { 42 | Alias string 43 | DisplayName string 44 | TimeLoc *time.Location 45 | } 46 | 47 | func (s *supportedTimeZone) UnmarshalJSON(data []byte) error { 48 | tmp := struct { 49 | Alias string `json:"alias"` 50 | DisplayName string `json:"displayName"` 51 | }{} 52 | 53 | err := json.Unmarshal(data, &tmp) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | s.Alias = tmp.Alias 59 | s.DisplayName = tmp.DisplayName 60 | 61 | ianaName, ok := WinIANA[s.DisplayName] 62 | if !ok { 63 | return fmt.Errorf("cannot map %v to IANA", s.DisplayName) 64 | } 65 | 66 | loc, err := time.LoadLocation(ianaName) 67 | if err != nil { 68 | return fmt.Errorf("cannot time.LoadLocation for original \"%v\" mapped to IANA \"%v\"", s.DisplayName, ianaName) 69 | } 70 | s.TimeLoc = loc 71 | 72 | return nil 73 | } 74 | 75 | // WinIANA contains a mapping for all Windows Time Zones to IANA time zones usable for time.LoadLocation. 76 | // This list was initially copied from https://github.com/thinkovation/windowsiana/blob/master/windowsiana.go 77 | // on 30th of August 2018, 14:00 and then extended on the same day. 78 | // 79 | // The full list of time zones that have been added and are now supported come from an an API-Call described here: 80 | // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/outlookuser_supportedtimezones 81 | var WinIANA = map[string]string{ 82 | "(UTC-12:00) International Date Line West": "Etc/GMT+12", 83 | "(UTC-11:00) Co-ordinated Universal Time-11": "Etc/GMT+11", 84 | "(UTC-11:00) Coordinated Universal Time-11": "Etc/GMT+11", 85 | "(UTC-10:00) Aleutian Islands": "US/Aleutian", 86 | "(UTC-10:00) Hawaii": "Pacific/Honolulu", 87 | "(UTC-09:30) Marquesas Islands": "Pacific/Marquesas", 88 | "(UTC-09:00) Alaska": "America/Anchorage", 89 | "(UTC-09:00) Co-ordinated Universal Time-09": "Etc/GMT+9", 90 | "(UTC-09:00) Coordinated Universal Time-09": "Etc/GMT+9", 91 | "(UTC-08:00) Baja California": "America/Tijuana", 92 | "(UTC-08:00) Co-ordinated Universal Time-08": "Etc/GMT+8", 93 | "(UTC-08:00) Coordinated Universal Time-08": "Etc/GMT+8", 94 | "(UTC-08:00) Pacific Time (US & Canada)": "America/Los_Angeles", 95 | "(UTC-07:00) Arizona": "America/Phoenix", 96 | "(UTC-07:00) Chihuahua, La Paz, Mazatlan": "America/Chihuahua", 97 | "(UTC-07:00) Mountain Time (US & Canada)": "America/Denver", 98 | "(UTC-07:00) Yukon": "Etc/GMT+7", 99 | "(UTC-07:00) La Paz, Mazatlan": "America/Mazatlan", 100 | "(UTC-06:00) Central America": "America/Guatemala", 101 | "(UTC-06:00) Central Time (US & Canada)": "America/Chicago", 102 | "(UTC-06:00) Easter Island": "Pacific/Easter", 103 | "(UTC-06:00) Guadalajara, Mexico City, Monterrey": "America/Mexico_City", 104 | "(UTC-06:00) Saskatchewan": "America/Regina", 105 | "(UTC-05:00) Bogota, Lima, Quito, Rio Branco": "America/Bogota", 106 | "(UTC-05:00) Chetumal": "America/Cancun", 107 | "(UTC-05:00) Eastern Time (US & Canada)": "America/New_York", 108 | "(UTC-05:00) Haiti": "America/Port-au-Prince", 109 | "(UTC-05:00) Havana": "America/Havana", 110 | "(UTC-05:00) Indiana (East)": "America/Indianapolis", 111 | "(UTC-05:00) Turks and Caicos": "Etc/GMT+5", 112 | "(UTC-04:00) Asuncion": "America/Asuncion", 113 | "(UTC-04:00) Atlantic Time (Canada)": "America/Halifax", 114 | "(UTC-04:00) Caracas": "America/Caracas", 115 | "(UTC-04:00) Cuiaba": "America/Cuiaba", 116 | "(UTC-04:00) Georgetown, La Paz, Manaus, San Juan": "America/La_Paz", 117 | "(UTC-04:00) Santiago": "America/Santiago", 118 | "(UTC-04:00) Turks and Caicos": "America/Grand_Turk", 119 | "(UTC-03:30) Newfoundland": "America/St_Johns", 120 | "(UTC-03:00) Araguaina": "America/Araguaina", 121 | "(UTC-03:00) Brasilia": "America/Sao_Paulo", 122 | "(UTC-03:00) Cayenne, Fortaleza": "America/Cayenne", 123 | "(UTC-03:00) City of Buenos Aires": "America/Buenos_Aires", 124 | "(UTC-03:00) Greenland": "America/Godthab", 125 | "(UTC-03:00) Montevideo": "America/Montevideo", 126 | "(UTC-03:00) Punta Arenas": "America/Punta_Arenas", 127 | "(UTC-03:00) Saint Pierre and Miquelon": "America/Miquelon", 128 | "(UTC-03:00) Salvador": "America/Bahia", 129 | "(UTC-02:00) Co-ordinated Universal Time-02": "Etc/GMT+2", 130 | "(UTC-02:00) Coordinated Universal Time-02": "Etc/GMT+2", 131 | "(UTC-02:00) Mid-Atlantic - Old": "Etc/GMT+2", 132 | "(UTC-01:00) Azores": "Atlantic/Azores", 133 | "(UTC-01:00) Cabo Verde Is.": "Atlantic/Cape_Verde", 134 | "(UTC) Co-ordinated Universal Time": "Etc/GMT", 135 | "(UTC) Coordinated Universal Time": "Etc/GMT", 136 | "(UTC+00:00) Casablanca": "Africa/Casablanca", 137 | "(UTC+00:00) Dublin, Edinburgh, Lisbon, London": "Europe/London", 138 | "(UTC+00:00) Monrovia, Reykjavik": "Atlantic/Reykjavik", 139 | "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna": "Europe/Berlin", 140 | "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague": "Europe/Budapest", 141 | "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris": "Europe/Paris", 142 | "(UTC+01:00) Casablanca": "Africa/Casablanca", 143 | "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb": "Europe/Warsaw", 144 | "(UTC+01:00) West Central Africa": "Africa/Lagos", 145 | "(UTC+01:00) Windhoek": "Africa/Windhoek", 146 | "(UTC+02:00) Amman": "Asia/Amman", 147 | "(UTC+02:00) Athens, Bucharest": "Europe/Bucharest", 148 | "(UTC+02:00) Beirut": "Asia/Beirut", 149 | "(UTC+02:00) Cairo": "Africa/Cairo", 150 | "(UTC+02:00) Chisinau": "Europe/Chisinau", 151 | "(UTC+02:00) Damascus": "Asia/Damascus", 152 | "(UTC+02:00) Gaza, Hebron": "Asia/Gaza", 153 | "(UTC+02:00) Harare, Pretoria": "Africa/Johannesburg", 154 | "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius": "Europe/Kiev", 155 | "(UTC+02:00) Istanbul": "Europe/Istanbul", 156 | "(UTC+03:00) Istanbul": "Europe/Istanbul", 157 | "(UTC+02:00) Jerusalem": "Asia/Jerusalem", 158 | "(UTC+02:00) Juba": "Africa/Juba", 159 | "(UTC+02:00) Kaliningrad": "Europe/Kaliningrad", 160 | "(UTC+02:00) Windhoek": "Africa/Windhoek", 161 | "(UTC+02:00) Khartoum": "Africa/Khartoum", 162 | "(UTC+02:00) Tripoli": "Africa/Tripoli", 163 | "(UTC+03:00) Amman": "Asia/Amman", 164 | "(UTC+03:00) Baghdad": "Asia/Baghdad", 165 | "(UTC+03:00) Kuwait, Riyadh": "Asia/Riyadh", 166 | "(UTC+03:00) Minsk": "Europe/Minsk", 167 | "(UTC+03:00) Moscow, St. Petersburg": "Europe/Moscow", 168 | "(UTC+03:00) Moscow, St. Petersburg, Volgograd": "Europe/Moscow", 169 | "(UTC+03:00) Nairobi": "Africa/Nairobi", 170 | "(UTC+03:00) Volgograd": "Europe/Volgograd", 171 | "(UTC+03:30) Tehran": "Asia/Tehran", 172 | "(UTC+04:00) Abu Dhabi, Muscat": "Asia/Dubai", 173 | "(UTC+04:00) Astrakhan, Ulyanovsk": "Europe/Samara", 174 | "(UTC+04:00) Baku": "Asia/Baku", 175 | "(UTC+04:00) Izhevsk, Samara": "Europe/Samara", 176 | "(UTC+04:00) Port Louis": "Indian/Mauritius", 177 | "(UTC+04:00) Saratov": "Europe/Saratov", 178 | "(UTC+04:00) Tbilisi": "Asia/Tbilisi", 179 | "(UTC+04:00) Volgograd": "Europe/Volgograd", 180 | "(UTC+04:00) Yerevan": "Asia/Yerevan", 181 | "(UTC+04:30) Kabul": "Asia/Kabul", 182 | "(UTC+05:00) Ashgabat, Tashkent": "Asia/Tashkent", 183 | "(UTC+05:00) Ekaterinburg": "Asia/Yekaterinburg", 184 | "(UTC+05:00) Islamabad, Karachi": "Asia/Karachi", 185 | "(UTC+05:00) Qyzylorda": "Asia/Qyzylorda", 186 | "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi": "Asia/Calcutta", 187 | "(UTC+05:30) Sri Jayawardenepura": "Asia/Colombo", 188 | "(UTC+05:45) Kathmandu": "Asia/Kathmandu", 189 | "(UTC+06:00) Astana": "Asia/Almaty", 190 | "(UTC+06:00) Dhaka": "Asia/Dhaka", 191 | "(UTC+06:00) Omsk": "Asia/Omsk", 192 | "(UTC+06:00) Novosibirsk": "Asia/Novosibirsk", 193 | "(UTC+06:00) Nur-Sultan": "Asia/Almaty", 194 | "(UTC+06:30) Yangon (Rangoon)": "Asia/Rangoon", 195 | "(UTC+07:00) Bangkok, Hanoi, Jakarta": "Asia/Bangkok", 196 | "(UTC+07:00) Barnaul, Gorno-Altaysk": "Asia/Krasnoyarsk", 197 | "(UTC+07:00) Hovd": "Asia/Hovd", 198 | "(UTC+07:00) Krasnoyarsk": "Asia/Krasnoyarsk", 199 | "(UTC+07:00) Novosibirsk": "Asia/Novosibirsk", 200 | "(UTC+07:00) Tomsk": "Asia/Tomsk", 201 | "(UTC+08:00) Beijing, Chongqing, Hong Kong SAR, Urumqi": "Asia/Shanghai", 202 | "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi": "Asia/Shanghai", 203 | "(UTC+08:00) Irkutsk": "Asia/Irkutsk", 204 | "(UTC+08:00) Kuala Lumpur, Singapore": "Asia/Singapore", 205 | "(UTC+08:00) Perth": "Australia/Perth", 206 | "(UTC+08:00) Taipei": "Asia/Taipei", 207 | "(UTC+08:00) Ulaanbaatar": "Asia/Ulaanbaatar", 208 | "(UTC+08:30) Pyongyang": "Asia/Pyongyang", 209 | "(UTC+09:00) Pyongyang": "Asia/Pyongyang", 210 | "(UTC+08:45) Eucla": "Australia/Eucla", 211 | "(UTC+09:00) Chita": "Asia/Chita", 212 | "(UTC+09:00) Osaka, Sapporo, Tokyo": "Asia/Tokyo", 213 | "(UTC+09:00) Seoul": "Asia/Seoul", 214 | "(UTC+09:00) Yakutsk": "Asia/Yakutsk", 215 | "(UTC+09:30) Adelaide": "Australia/Adelaide", 216 | "(UTC+09:30) Darwin": "Australia/Darwin", 217 | "(UTC+10:00) Brisbane": "Australia/Brisbane", 218 | "(UTC+10:00) Canberra, Melbourne, Sydney": "Australia/Sydney", 219 | "(UTC+10:00) Guam, Port Moresby": "Pacific/Port_Moresby", 220 | "(UTC+10:00) Hobart": "Australia/Hobart", 221 | "(UTC+10:00) Vladivostok": "Asia/Vladivostok", 222 | "(UTC+10:30) Lord Howe Island": "Australia/Lord_Howe", 223 | "(UTC+11:00) Bougainville Island": "Pacific/Bougainville", 224 | "(UTC+11:00) Chokurdakh": "Asia/Srednekolymsk", 225 | "(UTC+11:00) Magadan": "Asia/Magadan", 226 | "(UTC+11:00) Norfolk Island": "Pacific/Norfolk", 227 | "(UTC+11:00) Sakhalin": "Asia/Sakhalin", 228 | "(UTC+11:00) Solomon Is., New Caledonia": "Pacific/Guadalcanal", 229 | "(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky": "Asia/Kamchatka", 230 | "(UTC+12:00) Auckland, Wellington": "Pacific/Auckland", 231 | "(UTC+12:00) Co-ordinated Universal Time+12": "Etc/GMT-12", 232 | "(UTC+12:00) Coordinated Universal Time+12": "Etc/GMT-12", 233 | "(UTC+12:00) Petropavlovsk-Kamchatsky - Old": "Etc/GMT-12", 234 | "(UTC+12:00) Fiji": "Pacific/Fiji", 235 | "(UTC+12:45) Chatham Islands": "Pacific/Chatham", 236 | "(UTC+13:00) Nuku'alofa": "Pacific/Tongatapu", 237 | "(UTC+13:00) Co-ordinated Universal Time+13": "Etc/GMT-13", 238 | "(UTC+13:00) Coordinated Universal Time+13": "Etc/GMT-13", 239 | "(UTC+13:00) Samoa": "Pacific/Apia", 240 | "(UTC+14:00) Kiritimati Island": "Pacific/Kiritimati"} 241 | -------------------------------------------------------------------------------- /GraphClient.go: -------------------------------------------------------------------------------- 1 | // Package msgraph is a go lang implementation of the Microsoft Graph API 2 | // 3 | // See: https://developer.microsoft.com/en-us/graph/docs/concepts/overview 4 | package msgraph 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "net/http" 13 | "net/url" 14 | "strconv" 15 | "sync" 16 | "time" 17 | ) 18 | 19 | const ( 20 | odataSearchParamKey = "$search" 21 | odataFilterParamKey = "$filter" 22 | odataSelectParamKey = "$select" 23 | ) 24 | 25 | // GraphClient represents a msgraph API connection instance. 26 | // 27 | // An instance can also be json-unmarshalled and will immediately be initialized, hence a Token will be 28 | // grabbed. If grabbing a token fails the JSON-Unmarshal returns an error. 29 | type GraphClient struct { 30 | apiCall sync.Mutex // lock it when performing an API-call to synchronize it 31 | 32 | TenantID string // See https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal#get-tenant-id 33 | ApplicationID string // See https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal#get-application-id-and-authentication-key 34 | ClientSecret string // See https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal#get-application-id-and-authentication-key 35 | 36 | token Token // the current token to be used 37 | 38 | // azureADAuthEndpoint is used for this instance of GraphClient. For available endpoints see https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 39 | azureADAuthEndpoint string 40 | // serviceRootEndpoint is the basic API-url used for this instance of GraphClient, namely Microsoft Graph service root endpoints. For available endpoints see https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints. 41 | serviceRootEndpoint string 42 | } 43 | 44 | func (g *GraphClient) String() string { 45 | var firstPart, lastPart string 46 | if len(g.ClientSecret) > 4 { // if ClientSecret is not initialized prevent a panic slice out of bounds 47 | firstPart = g.ClientSecret[0:3] 48 | lastPart = g.ClientSecret[len(g.ClientSecret)-3:] 49 | } 50 | return fmt.Sprintf("GraphClient(TenantID: %v, ApplicationID: %v, ClientSecret: %v...%v, Token validity: [%v - %v])", 51 | g.TenantID, g.ApplicationID, firstPart, lastPart, g.token.NotBefore, g.token.ExpiresOn) 52 | } 53 | 54 | // NewGraphClient creates a new GraphClient instance with the given parameters 55 | // and grabs a token. Returns an error if the token cannot be initialized. The 56 | // default ms graph API global endpoint is used. 57 | // 58 | // This method does not have to be used to create a new GraphClient. If not used, the default global ms Graph API endpoint is used. 59 | func NewGraphClient(tenantID, applicationID, clientSecret string) (*GraphClient, error) { 60 | return NewGraphClientWithCustomEndpoint(tenantID, applicationID, clientSecret, AzureADAuthEndpointGlobal, ServiceRootEndpointGlobal) 61 | } 62 | 63 | // NewGraphClientCustomEndpoint creates a new GraphClient instance with the 64 | // given parameters and tries to get a valid token. All available public endpoints 65 | // for azureADAuthEndpoint and serviceRootEndpoint are available via msgraph.azureADAuthEndpoint* and msgraph.ServiceRootEndpoint* 66 | // 67 | // For available endpoints from Microsoft, see documentation: 68 | // * Authentication Endpoints: https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints 69 | // * Service Root Endpoints: https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints 70 | // 71 | // Returns an error if the token cannot be initialized. This func does not have 72 | // to be used to create a new GraphClient. 73 | func NewGraphClientWithCustomEndpoint(tenantID, applicationID, clientSecret string, azureADAuthEndpoint string, serviceRootEndpoint string) (*GraphClient, error) { 74 | g := GraphClient{ 75 | TenantID: tenantID, 76 | ApplicationID: applicationID, 77 | ClientSecret: clientSecret, 78 | azureADAuthEndpoint: azureADAuthEndpoint, 79 | serviceRootEndpoint: serviceRootEndpoint, 80 | } 81 | g.apiCall.Lock() // lock because we will refresh the token 82 | defer g.apiCall.Unlock() // unlock after token refresh 83 | return &g, g.refreshToken() 84 | } 85 | 86 | // makeSureURLsAreSet ensures that the two fields g.azureADAuthEndpoint and g.serviceRootEndpoint 87 | // of the graphClient are set and therefore not empty. If they are currently empty 88 | // they will be set to the constants AzureADAuthEndpointGlobal and ServiceRootEndpointGlobal. 89 | func (g *GraphClient) makeSureURLsAreSet() { 90 | if g.azureADAuthEndpoint == "" { // If AzureADAuthEndpoint is not set, use the global endpoint 91 | g.azureADAuthEndpoint = AzureADAuthEndpointGlobal 92 | } 93 | if g.serviceRootEndpoint == "" { // If ServiceRootEndpoint is not set, use the global endpoint 94 | g.serviceRootEndpoint = ServiceRootEndpointGlobal 95 | } 96 | } 97 | 98 | // refreshToken refreshes the current Token. Grabs a new one and saves it within the GraphClient instance 99 | func (g *GraphClient) refreshToken() error { 100 | g.makeSureURLsAreSet() 101 | if g.TenantID == "" { 102 | return fmt.Errorf("tenant ID is empty") 103 | } 104 | resource := fmt.Sprintf("/%v/oauth2/token", g.TenantID) 105 | data := url.Values{} 106 | data.Add("grant_type", "client_credentials") 107 | data.Add("client_id", g.ApplicationID) 108 | data.Add("client_secret", g.ClientSecret) 109 | data.Add("resource", g.serviceRootEndpoint) 110 | 111 | u, err := url.ParseRequestURI(g.azureADAuthEndpoint) 112 | if err != nil { 113 | return fmt.Errorf("unable to parse URI: %v", err) 114 | } 115 | 116 | u.Path = resource 117 | req, err := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode())) 118 | 119 | if err != nil { 120 | return fmt.Errorf("HTTP Request Error: %v", err) 121 | } 122 | 123 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 124 | req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) 125 | 126 | var newToken Token 127 | err = g.performRequest(req, &newToken) // perform the prepared request 128 | if err != nil { 129 | return fmt.Errorf("error on getting msgraph Token: %v", err) 130 | } 131 | g.token = newToken 132 | return err 133 | } 134 | 135 | // GetToken returns a copy the currently token used by this GraphClient instance. 136 | func (g *GraphClient) GetToken() Token { 137 | return g.token 138 | } 139 | 140 | // makeGETAPICall performs an API-Call to the msgraph API. 141 | func (g *GraphClient) makeGETAPICall(apiCall string, reqParams getRequestParams, v interface{}) error { 142 | return g.makeAPICall(apiCall, http.MethodGet, reqParams, nil, v) 143 | } 144 | 145 | // makePOSTAPICall performs an API-Call to the msgraph API. 146 | func (g *GraphClient) makePOSTAPICall(apiCall string, reqParams getRequestParams, body io.Reader, v interface{}) error { 147 | return g.makeAPICall(apiCall, http.MethodPost, reqParams, body, v) 148 | } 149 | 150 | // makePATCHAPICall performs an API-Call to the msgraph API. 151 | func (g *GraphClient) makePATCHAPICall(apiCall string, reqParams getRequestParams, body io.Reader, v interface{}) error { 152 | return g.makeAPICall(apiCall, http.MethodPatch, reqParams, body, v) 153 | } 154 | 155 | // makeDELETEAPICall performs an API-Call to the msgraph API. 156 | func (g *GraphClient) makeDELETEAPICall(apiCall string, reqParams getRequestParams, v interface{}) error { 157 | return g.makeAPICall(apiCall, http.MethodDelete, reqParams, nil, v) 158 | } 159 | 160 | // makeAPICall performs an API-Call to the msgraph API. This func uses sync.Mutex to synchronize all API-calls. 161 | // 162 | // Parameter httpMethod may be http.MethodGet, http.MethodPost or http.MethodPatch 163 | // 164 | // Parameter body may be nil to not provide any content - e.g. when using a http GET request. 165 | func (g *GraphClient) makeAPICall(apiCall string, httpMethod string, reqParams getRequestParams, body io.Reader, v interface{}) error { 166 | g.makeSureURLsAreSet() 167 | g.apiCall.Lock() 168 | defer g.apiCall.Unlock() // unlock when the func returns 169 | // Check token 170 | if g.token.WantsToBeRefreshed() { // Token not valid anymore? 171 | err := g.refreshToken() 172 | if err != nil { 173 | return err 174 | } 175 | } 176 | 177 | reqURL, err := url.ParseRequestURI(g.serviceRootEndpoint) 178 | if err != nil { 179 | return fmt.Errorf("unable to parse URI %v: %v", g.serviceRootEndpoint, err) 180 | } 181 | 182 | // Add Version to API-Call, the leading slash is always added by the calling func 183 | reqURL.Path = "/" + APIVersion + apiCall 184 | 185 | req, err := http.NewRequestWithContext(reqParams.Context(), httpMethod, reqURL.String(), body) 186 | if err != nil { 187 | return fmt.Errorf("HTTP request error: %v", err) 188 | } 189 | 190 | req.Header.Add("Content-Type", "application/json") 191 | req.Header.Add("Authorization", g.token.GetAccessToken()) 192 | 193 | for key, vals := range reqParams.Headers() { 194 | for idx := range vals { 195 | req.Header.Add(key, vals[idx]) 196 | } 197 | } 198 | 199 | var getParams = reqParams.Values() 200 | 201 | if httpMethod == http.MethodGet { 202 | // TODO: Improve performance with using $skip & paging instead of retrieving all results with $top 203 | // TODO: MaxPageSize is currently 999, if there are any time more than 999 entries this will make the program unpredictable... hence start to use paging (!) 204 | getParams.Add("$top", strconv.Itoa(MaxPageSize)) 205 | } 206 | req.URL.RawQuery = getParams.Encode() // set query parameters 207 | 208 | return g.performRequest(req, v) 209 | } 210 | 211 | // makeSkipTokenAPICall performs an API-Call to the msgraph API. 212 | // 213 | // Gets the results of the page specified by the skip token 214 | func (g *GraphClient) makeSkipTokenApiCall(httpMethod string, v interface{}, skipToken string) error { 215 | 216 | // Check token 217 | if g.token.WantsToBeRefreshed() { // Token not valid anymore? 218 | err := g.refreshToken() 219 | if err != nil { 220 | return err 221 | } 222 | } 223 | 224 | req, err := http.NewRequest(httpMethod, skipToken, nil) 225 | if err != nil { 226 | return fmt.Errorf("HTTP request error: %v", err) 227 | } 228 | 229 | req.Header.Add("Content-Type", "application/json") 230 | req.Header.Add("Authorization", g.token.GetAccessToken()) 231 | 232 | return g.performSkipTokenRequest(req, v) 233 | } 234 | 235 | // performSkipTokenRequest performs a pre-prepared http.Request and does the proper error-handling for it. 236 | // does a json.Unmarshal into the v interface{} and returns the error of it if everything went well so far. 237 | func (g *GraphClient) performSkipTokenRequest(req *http.Request, v interface{}) error { 238 | httpClient := &http.Client{ 239 | Timeout: time.Second * 10, 240 | } 241 | 242 | resp, err := httpClient.Do(req) 243 | if err != nil { 244 | return fmt.Errorf("HTTP response error: %v of http.Request: %v", err, req.URL) 245 | } 246 | defer resp.Body.Close() // close body when func returns 247 | 248 | body, err := ioutil.ReadAll(resp.Body) // read body first to append it to the error (if any) 249 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 250 | // Hint: this will mostly be the case if the tenant ID cannot be found, the Application ID cannot be found or the clientSecret is incorrect. 251 | // The cause will be described in the body, hence we have to return the body too for proper error-analysis 252 | return fmt.Errorf("StatusCode is not OK: %v. Body: %v ", resp.StatusCode, string(body)) 253 | } 254 | 255 | // fmt.Println("Body: ", string(body)) 256 | 257 | if err != nil { 258 | return fmt.Errorf("HTTP response read error: %v of http.Request: %v", err, req.URL) 259 | } 260 | 261 | return json.Unmarshal(body, &v) // return the error of the json unmarshal 262 | } 263 | 264 | // performRequest performs a pre-prepared http.Request and does the proper error-handling for it. 265 | // does a json.Unmarshal into the v interface{} and returns the error of it if everything went well so far. 266 | func (g *GraphClient) performRequest(req *http.Request, v interface{}) error { 267 | httpClient := &http.Client{ 268 | Timeout: time.Second * 10, 269 | } 270 | resp, err := httpClient.Do(req) 271 | if err != nil { 272 | return fmt.Errorf("HTTP response error: %v of http.Request: %v", err, req.URL) 273 | } 274 | defer resp.Body.Close() // close body when func returns 275 | 276 | body, err := ioutil.ReadAll(resp.Body) // read body first to append it to the error (if any) 277 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 278 | // Hint: this will mostly be the case if the tenant ID cannot be found, the Application ID cannot be found or the clientSecret is incorrect. 279 | // The cause will be described in the body, hence we have to return the body too for proper error-analysis 280 | return fmt.Errorf("StatusCode is not OK: %v. Body: %v ", resp.StatusCode, string(body)) 281 | } 282 | 283 | if err != nil { 284 | return fmt.Errorf("HTTP response read error: %v of http.Request: %v", err, req.URL) 285 | } 286 | 287 | // no content returned when http PATCH or DELETE is used, e.g. User.DeleteUser() 288 | if req.Method == http.MethodDelete || req.Method == http.MethodPatch { 289 | return nil 290 | } 291 | type skipTokenCallData struct { 292 | Data []json.RawMessage `json:"value"` 293 | SkipToken string `json:"@odata.nextLink"` 294 | } 295 | res := skipTokenCallData{} 296 | 297 | err = json.Unmarshal(body, &res) 298 | if err != nil { 299 | return err 300 | } 301 | 302 | if res.SkipToken == "" { 303 | return json.Unmarshal(body, &v) // return the error of the json unmarshal 304 | } 305 | 306 | data := res.Data 307 | for res.SkipToken != "" { 308 | skipToken := res.SkipToken 309 | res = skipTokenCallData{} 310 | err := g.makeSkipTokenApiCall(req.Method, &res, skipToken) 311 | if err != nil { 312 | return err 313 | } 314 | data = append(data, res.Data...) 315 | } 316 | 317 | var dataBytes []byte 318 | 319 | //converts json.RawMessage into []bytes and adds a comma at the end 320 | for _, v := range data { 321 | b, _ := v.MarshalJSON() 322 | dataBytes = append(dataBytes, b...) 323 | dataBytes = append(dataBytes, []byte(",")...) 324 | } 325 | 326 | toReturn := []byte(`{"value":[`) //add missing "value" tag 327 | toReturn = append(toReturn, dataBytes[:len(dataBytes)-1]...) //append previous data and skip last comma 328 | toReturn = append(toReturn, []byte("]}")...) 329 | 330 | return json.Unmarshal(toReturn, &v) // return the error of the json unmarshal 331 | } 332 | 333 | // ListUsers returns a list of all users 334 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 335 | // 336 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list 337 | func (g *GraphClient) ListUsers(opts ...ListQueryOption) (Users, error) { 338 | resource := "/users" 339 | var marsh struct { 340 | Users Users `json:"value"` 341 | } 342 | err := g.makeGETAPICall(resource, compileListQueryOptions(opts), &marsh) 343 | marsh.Users.setGraphClient(g) 344 | return marsh.Users, err 345 | } 346 | 347 | // ListGroups returns a list of all groups 348 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 349 | // 350 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/group_list 351 | func (g *GraphClient) ListGroups(opts ...ListQueryOption) (Groups, error) { 352 | resource := "/groups" 353 | 354 | var reqParams = compileListQueryOptions(opts) 355 | 356 | var marsh struct { 357 | Groups Groups `json:"value"` 358 | } 359 | err := g.makeGETAPICall(resource, reqParams, &marsh) 360 | marsh.Groups.setGraphClient(g) 361 | return marsh.Groups, err 362 | } 363 | 364 | // getMemberGroups returns a list of all group IDs the user is a member of. 365 | // You can specify the securityGroupsEnabled parameter to only return security group IDs. 366 | // 367 | // Reference: https://docs.microsoft.com/en-us/graph/api/directoryobject-getmembergroups?view=graph-rest-1.0&tabs=http 368 | func (g *GraphClient) getMemberGroups(identifier string, securityEnabledOnly bool, opts ...GetQueryOption) ([]string, error) { 369 | resource := fmt.Sprintf("/directoryObjects/%v/getMemberGroups", identifier) 370 | var post struct { 371 | SecurityEnabledOnly bool `json:"securityEnabledOnly"` 372 | } 373 | var marsh struct { 374 | Groups []string `json:"value"` 375 | } 376 | post.SecurityEnabledOnly = securityEnabledOnly 377 | bodyBytes, err := json.Marshal(post) 378 | if err != nil { 379 | return marsh.Groups, err 380 | } 381 | body := bytes.NewReader(bodyBytes) 382 | 383 | err = g.makePOSTAPICall(resource, compileGetQueryOptions(opts), body, &marsh) 384 | return marsh.Groups, err 385 | } 386 | 387 | // GetUser returns the user object associated to the given user identified by either 388 | // the given ID or userPrincipalName 389 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 390 | // 391 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get 392 | func (g *GraphClient) GetUser(identifier string, opts ...GetQueryOption) (User, error) { 393 | resource := fmt.Sprintf("/users/%v", identifier) 394 | user := User{graphClient: g} 395 | err := g.makeGETAPICall(resource, compileGetQueryOptions(opts), &user) 396 | return user, err 397 | } 398 | 399 | // GetGroup returns the group object identified by the given groupID. 400 | // Supports optional OData query parameters https://docs.microsoft.com/en-us/graph/query-parameters 401 | // 402 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/group_get 403 | func (g *GraphClient) GetGroup(groupID string, opts ...GetQueryOption) (Group, error) { 404 | resource := fmt.Sprintf("/groups/%v", groupID) 405 | group := Group{graphClient: g} 406 | err := g.makeGETAPICall(resource, compileGetQueryOptions(opts), &group) 407 | return group, err 408 | } 409 | 410 | // CreateUser creates a new user given a user object and returns and updated object 411 | // Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user-post-users 412 | func (g *GraphClient) CreateUser(userInput User, opts ...CreateQueryOption) (User, error) { 413 | user := User{graphClient: g} 414 | bodyBytes, err := json.Marshal(userInput) 415 | if err != nil { 416 | return user, err 417 | } 418 | 419 | reader := bytes.NewReader(bodyBytes) 420 | err = g.makePOSTAPICall("/users", compileCreateQueryOptions(opts), reader, &user) 421 | 422 | return user, err 423 | } 424 | 425 | // UnmarshalJSON implements the json unmarshal to be used by the json-library. 426 | // This method additionally to loading the TenantID, ApplicationID and ClientSecret 427 | // immediately gets a Token from msgraph (hence initialize this GraphAPI instance) 428 | // and returns an error if any of the data provided is incorrect or the token cannot be acquired 429 | func (g *GraphClient) UnmarshalJSON(data []byte) error { 430 | tmp := struct { 431 | TenantID string 432 | ApplicationID string 433 | ClientSecret string 434 | AzureADAuthEndpoint string 435 | ServiceRootEndpoint string 436 | }{} 437 | 438 | err := json.Unmarshal(data, &tmp) 439 | if err != nil { 440 | return err 441 | } 442 | 443 | g.TenantID = tmp.TenantID 444 | if g.TenantID == "" { 445 | return fmt.Errorf("TenantID is empty") 446 | } 447 | g.ApplicationID = tmp.ApplicationID 448 | if g.ApplicationID == "" { 449 | return fmt.Errorf("ApplicationID is empty") 450 | } 451 | g.ClientSecret = tmp.ClientSecret 452 | if g.ClientSecret == "" { 453 | return fmt.Errorf("ClientSecret is empty") 454 | } 455 | g.azureADAuthEndpoint = tmp.AzureADAuthEndpoint 456 | g.serviceRootEndpoint = tmp.ServiceRootEndpoint 457 | g.makeSureURLsAreSet() 458 | 459 | // get a token and return the error (if any) 460 | err = g.refreshToken() 461 | if err != nil { 462 | return fmt.Errorf("can't get Token: %v", err) 463 | } 464 | return nil 465 | } 466 | -------------------------------------------------------------------------------- /GraphClient_test.go: -------------------------------------------------------------------------------- 1 | package msgraph 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math/rand" 8 | "net/url" 9 | "os" 10 | "reflect" 11 | "strconv" 12 | "strings" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | // get graph client config from environment 18 | var ( 19 | // Optional: Azure AD Authentication Endpoint, defaults to msgraph.AzureADAuthEndpointGlobal: https://login.microsoftonline.com 20 | msGraphAzureADAuthEndpoint string 21 | // Optional: Azure AD Authentication Endpoint, defaults to msgraph.ServiceRootEndpointGlobal: https://graph.microsoft.com 22 | msGraphServiceRootEndpoint string 23 | // Microsoft Graph tenant ID 24 | msGraphTenantID string 25 | // Microsoft Graph Application ID 26 | msGraphApplicationID string 27 | // Microsoft Graph Client Secret 28 | msGraphClientSecret string 29 | // a valid groupdisplayname from msgraph, e.g. technicians@contoso.com 30 | msGraphExistingGroupDisplayName string 31 | // a valid userprincipalname in the above group, e.g. felix@contoso.com 32 | msGraphExistingUserPrincipalInGroup string 33 | // valid calendar names that belong to the above user, seperated by a colon (","). e.g.: "Kalender,Feiertage in Österreich,Geburtstage" 34 | msGraphExistingCalendarsOfUser []string 35 | // the number of expected results when searching for the msGraphExistingGroupDisplayName with $search or $filter 36 | msGraphExistingGroupDisplayNameNumRes uint64 37 | // a domain-name for unit tests to create a user or other objects, e.g. contoso.com - omit the @ 38 | msGraphDomainNameForCreateTests string 39 | // the graphclient used to perform all tests 40 | graphClient *GraphClient 41 | // marker if the calendar tests should be skipped - set if msGraphExistingCalendarsOfUser is empty 42 | skipCalendarTests bool 43 | ) 44 | 45 | func getEnvOrPanic(key string) string { 46 | var val = os.Getenv(key) 47 | if val == "" { 48 | panic(fmt.Sprintf("Expected %s to be set, but is empty", key)) 49 | } 50 | return val 51 | } 52 | 53 | func TestMain(m *testing.M) { 54 | msGraphTenantID = getEnvOrPanic("MSGraphTenantID") 55 | msGraphApplicationID = getEnvOrPanic("MSGraphApplicationID") 56 | msGraphClientSecret = getEnvOrPanic("MSGraphClientSecret") 57 | msGraphExistingGroupDisplayName = getEnvOrPanic("MSGraphExistingGroupDisplayName") 58 | msGraphExistingUserPrincipalInGroup = getEnvOrPanic("MSGraphExistingUserPrincipalInGroup") 59 | msGraphDomainNameForCreateTests = getEnvOrPanic("MSGraphDomainNameForCreateTests") 60 | 61 | if msGraphAzureADAuthEndpoint = os.Getenv("MSGraphAzureADAuthEndpoint"); msGraphAzureADAuthEndpoint == "" { 62 | msGraphAzureADAuthEndpoint = AzureADAuthEndpointGlobal 63 | } 64 | if msGraphServiceRootEndpoint = os.Getenv("MSGraphServiceRootEndpoint"); msGraphServiceRootEndpoint == "" { 65 | msGraphServiceRootEndpoint = ServiceRootEndpointGlobal 66 | } 67 | if msGraphExistingCalendarsOfUser = strings.Split(os.Getenv("MSGraphExistingCalendarsOfUser"), ","); msGraphExistingCalendarsOfUser[0] == "" { 68 | fmt.Println("Skipping calendar tests due to missing 'MSGraphExistingCalendarsOfUser' value") 69 | skipCalendarTests = true 70 | } 71 | 72 | var err error 73 | msGraphExistingGroupDisplayNameNumRes, err = strconv.ParseUint(os.Getenv("MSGraphExistingGroupDisplayNameNumRes"), 10, 64) 74 | if err != nil { 75 | panic(fmt.Sprintf("Environment variable \"MSGraphExistingGroupDisplayNameNumRes\" seems to be invalid, cannot be parsed to unsigned integer: %v", err)) 76 | } 77 | 78 | graphClient, err = NewGraphClientWithCustomEndpoint(msGraphTenantID, msGraphApplicationID, msGraphClientSecret, msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint) 79 | if err != nil { 80 | panic(fmt.Sprintf("Cannot initialize a new GraphClient, error: %v", err)) 81 | } 82 | 83 | rand.Seed(time.Now().UnixNano()) 84 | 85 | os.Exit(m.Run()) 86 | } 87 | 88 | func randomString(n int) string { 89 | var runes = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 90 | b := make([]rune, n) 91 | for i := range b { 92 | b[i] = runes[rand.Intn(len(runes))] 93 | } 94 | return string(b) 95 | } 96 | 97 | func createUnitTestUser(t *testing.T) User { 98 | t.Helper() 99 | rndstring := randomString(32) 100 | user, err := graphClient.CreateUser(User{ 101 | AccountEnabled: true, 102 | DisplayName: "go-msgraph unit-test generated user " + time.Now().Format("2006-01-02") + " - random " + rndstring, 103 | MailNickname: "go-msgraph.unit-test.generated." + rndstring, 104 | UserPrincipalName: "go-msgraph.unit-test.generated." + rndstring + "@" + msGraphDomainNameForCreateTests, 105 | PasswordProfile: PasswordProfile{Password: randomString(32)}, 106 | }) 107 | if err != nil { 108 | t.Errorf("Cannot create a new User for unit tests: %v", err) 109 | } 110 | return user 111 | } 112 | 113 | func TestNewGraphClient(t *testing.T) { 114 | if msGraphAzureADAuthEndpoint != AzureADAuthEndpointGlobal || msGraphServiceRootEndpoint != ServiceRootEndpointGlobal { 115 | t.Skip("Skipping TestNewGraphClient because the endpoint is not the default - global - endpoint") 116 | } 117 | type args struct { 118 | tenantID string 119 | applicationID string 120 | clientSecret string 121 | } 122 | tests := []struct { 123 | name string 124 | args args 125 | wantErr bool 126 | }{ 127 | { 128 | name: "GraphClient from Environment-variables", 129 | args: args{tenantID: msGraphTenantID, applicationID: msGraphApplicationID, clientSecret: msGraphClientSecret}, 130 | wantErr: false, 131 | }, { 132 | name: "GraphClient fail - wrong tenant ID", 133 | args: args{tenantID: "wrong tenant id", applicationID: msGraphApplicationID, clientSecret: msGraphClientSecret}, 134 | wantErr: true, 135 | }, { 136 | name: "GraphClient fail - wrong application ID", 137 | args: args{tenantID: msGraphTenantID, applicationID: "wrong application id", clientSecret: msGraphClientSecret}, 138 | wantErr: true, 139 | }, { 140 | name: "GraphClient fail - wrong client secret", 141 | args: args{tenantID: msGraphTenantID, applicationID: msGraphApplicationID, clientSecret: "wrong client secret"}, 142 | wantErr: true, 143 | }, 144 | } 145 | for _, tt := range tests { 146 | t.Run(tt.name, func(t *testing.T) { 147 | _, err := NewGraphClient(tt.args.tenantID, tt.args.applicationID, tt.args.clientSecret) 148 | if (err != nil) != tt.wantErr { 149 | t.Errorf("NewGraphClient() error = %v, wantErr %v", err, tt.wantErr) 150 | return 151 | } 152 | }) 153 | } 154 | } 155 | 156 | func TestNewGraphClientWithCustomEndpoint(t *testing.T) { 157 | type args struct { 158 | tenantID string 159 | applicationID string 160 | clientSecret string 161 | azureADAuthEndpoint string 162 | serviceRootEndpoint string 163 | } 164 | tests := []struct { 165 | name string 166 | args args 167 | wantErr bool 168 | }{ 169 | { 170 | name: "GraphClient from Environment-variables", 171 | args: args{tenantID: msGraphTenantID, applicationID: msGraphApplicationID, clientSecret: msGraphClientSecret, azureADAuthEndpoint: msGraphAzureADAuthEndpoint, serviceRootEndpoint: msGraphServiceRootEndpoint}, 172 | wantErr: false, 173 | }, { 174 | name: "GraphClient fail - wrong tenant ID", 175 | args: args{tenantID: "wrong tenant id", applicationID: msGraphApplicationID, clientSecret: msGraphClientSecret, azureADAuthEndpoint: msGraphAzureADAuthEndpoint, serviceRootEndpoint: msGraphServiceRootEndpoint}, 176 | wantErr: true, 177 | }, { 178 | name: "GraphClient fail - wrong application ID", 179 | args: args{tenantID: msGraphTenantID, applicationID: "wrong application id", clientSecret: msGraphClientSecret, azureADAuthEndpoint: msGraphAzureADAuthEndpoint, serviceRootEndpoint: msGraphServiceRootEndpoint}, 180 | wantErr: true, 181 | }, { 182 | name: "GraphClient fail - wrong client secret", 183 | args: args{tenantID: msGraphTenantID, applicationID: msGraphApplicationID, clientSecret: "wrong client secret", azureADAuthEndpoint: msGraphAzureADAuthEndpoint, serviceRootEndpoint: msGraphServiceRootEndpoint}, 184 | wantErr: true, 185 | }, { 186 | name: "GraphClient fail - wrong Azure AD Authentication Endpoint", 187 | args: args{tenantID: msGraphTenantID, applicationID: msGraphApplicationID, clientSecret: msGraphClientSecret, azureADAuthEndpoint: "completely invalid URL", serviceRootEndpoint: msGraphServiceRootEndpoint}, 188 | wantErr: true, 189 | }, { 190 | name: "GraphClient fail - wrong Azure AD Authentication Endpoint", 191 | args: args{tenantID: msGraphTenantID, applicationID: msGraphApplicationID, clientSecret: msGraphClientSecret, azureADAuthEndpoint: "https://iguess-this-does-not.exist.in.this.dksfowe3834myksweroqiwer.world", serviceRootEndpoint: msGraphServiceRootEndpoint}, 192 | wantErr: true, 193 | }, { 194 | name: "GraphClient fail - wrong Service Root Endpoint", 195 | args: args{tenantID: msGraphTenantID, applicationID: msGraphApplicationID, clientSecret: msGraphClientSecret, azureADAuthEndpoint: msGraphAzureADAuthEndpoint, serviceRootEndpoint: "invalid URL"}, 196 | wantErr: true, 197 | }, 198 | } 199 | for _, tt := range tests { 200 | t.Run(tt.name, func(t *testing.T) { 201 | _, err := NewGraphClientWithCustomEndpoint(tt.args.tenantID, tt.args.applicationID, tt.args.clientSecret, tt.args.azureADAuthEndpoint, tt.args.serviceRootEndpoint) 202 | if (err != nil) != tt.wantErr { 203 | t.Errorf("NewGraphClientWithCustomEndpoint() error = %v, wantErr %v", err, tt.wantErr) 204 | return 205 | } 206 | }) 207 | } 208 | } 209 | 210 | func TestGraphClient_ListUsers(t *testing.T) { 211 | tests := []struct { 212 | name string 213 | g *GraphClient 214 | want User 215 | wantErr bool 216 | }{ 217 | { 218 | name: fmt.Sprintf("List all Users, check for user %v", msGraphExistingUserPrincipalInGroup), 219 | g: graphClient, 220 | want: User{UserPrincipalName: msGraphExistingUserPrincipalInGroup}, 221 | wantErr: false, 222 | }, 223 | } 224 | for _, tt := range tests { 225 | t.Run(tt.name, func(t *testing.T) { 226 | got, err := tt.g.ListUsers() 227 | if (err != nil) != tt.wantErr { 228 | t.Errorf("GraphClient.ListUsers() error = %v, wantErr %v", err, tt.wantErr) 229 | return 230 | } 231 | if len(got) == 0 { 232 | t.Errorf("GraphClient.ListUsers() len = 0, want more than 0: %v", got) 233 | } 234 | isGraphClientInitializd := true 235 | found := false 236 | for _, user := range got { 237 | isGraphClientInitializd = isGraphClientInitializd && user.graphClient != nil 238 | found = found || user.UserPrincipalName == tt.want.UserPrincipalName 239 | } 240 | if !found { 241 | t.Errorf("GraphClient.ListUsers() user %v not found, users: %v", tt.want.UserPrincipalName, got) 242 | } 243 | if !isGraphClientInitializd { 244 | t.Errorf("GraphClient.ListUsers() graphClient is nil, but was initialized from GraphClient") 245 | } 246 | }) 247 | } 248 | } 249 | 250 | func TestGraphClient_ListGroups(t *testing.T) { 251 | tests := []struct { 252 | name string 253 | g *GraphClient 254 | opts []ListQueryOption 255 | want Group 256 | wantErr bool 257 | }{ 258 | { 259 | name: fmt.Sprintf("Test if Group %v is present", msGraphExistingGroupDisplayName), 260 | g: graphClient, 261 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 262 | wantErr: false, 263 | }, 264 | } 265 | for _, tt := range tests { 266 | t.Run(tt.name, func(t *testing.T) { 267 | got, err := tt.g.ListGroups(tt.opts...) 268 | if (err != nil) != tt.wantErr { 269 | t.Errorf("GraphClient.ListGroups() error = %v, wantErr %v", err, tt.wantErr) 270 | return 271 | } 272 | found := false 273 | isGraphClientInitialized := true 274 | for _, checkObj := range got { 275 | found = found || tt.want.DisplayName == checkObj.DisplayName 276 | isGraphClientInitialized = isGraphClientInitialized && checkObj.graphClient != nil 277 | } 278 | if !found { 279 | t.Errorf("GraphClient.ListGroups() = %v, searching for one of %v", got, tt.want) 280 | } 281 | if !isGraphClientInitialized { 282 | t.Errorf("GraphClient.ListGroups() graphClient is nil, but was initialized from GraphClient") 283 | } 284 | }) 285 | } 286 | } 287 | 288 | func TestGraphClient_ListGroupsWithSelect(t *testing.T) { 289 | tests := []struct { 290 | name string 291 | g *GraphClient 292 | opts []ListQueryOption 293 | want Group 294 | wantErr bool 295 | wantZeroFields []string 296 | wantNonZeroFields []string 297 | }{ 298 | { 299 | name: fmt.Sprintf("Test if Group %v is present and contains only specified fields", msGraphExistingGroupDisplayName), 300 | g: graphClient, 301 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 302 | wantErr: false, 303 | opts: []ListQueryOption{ 304 | ListWithSelect("displayName,createdDateTime"), 305 | }, 306 | wantZeroFields: []string{"ID"}, 307 | wantNonZeroFields: []string{"DisplayName", "CreatedDateTime"}, 308 | }, 309 | { 310 | name: fmt.Sprintf("Test if Group %v is present and contains only specified fields with context", msGraphExistingGroupDisplayName), 311 | g: graphClient, 312 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 313 | wantErr: false, 314 | opts: []ListQueryOption{ 315 | ListWithSelect("displayName,createdDateTime"), 316 | ListWithContext(context.Background()), 317 | }, 318 | wantZeroFields: []string{"ID"}, 319 | wantNonZeroFields: []string{"DisplayName", "CreatedDateTime"}, 320 | }, 321 | } 322 | for _, tt := range tests { 323 | t.Run(tt.name, func(t *testing.T) { 324 | got, err := tt.g.ListGroups(tt.opts...) 325 | if (err != nil) != tt.wantErr { 326 | t.Errorf("GraphClient.ListGroups() error = %v, wantErr %v", err, tt.wantErr) 327 | return 328 | } 329 | found := false 330 | isGraphClientInitialized := true 331 | 332 | for _, checkObj := range got { 333 | found = found || tt.want.DisplayName == checkObj.DisplayName 334 | 335 | assertZeroFields(t, checkObj, tt.wantZeroFields, tt.wantNonZeroFields) 336 | isGraphClientInitialized = isGraphClientInitialized && checkObj.graphClient != nil 337 | } 338 | if !found { 339 | t.Errorf("GraphClient.ListGroups() = %v, searching for one of %v", got, tt.want) 340 | } 341 | 342 | if !isGraphClientInitialized { 343 | t.Errorf("GraphClient.ListGroups() graphClient is nil, but was initialized from GraphClient") 344 | } 345 | }) 346 | } 347 | } 348 | 349 | func TestGraphClient_ListGroupsWithSearchAndFilter(t *testing.T) { 350 | tests := []struct { 351 | name string 352 | g *GraphClient 353 | opts []ListQueryOption 354 | want Group 355 | wantErr bool 356 | }{ 357 | { 358 | name: fmt.Sprintf("Test if Group %v is present when using searchQuery", msGraphExistingGroupDisplayName), 359 | g: graphClient, 360 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 361 | wantErr: false, 362 | opts: []ListQueryOption{ 363 | ListWithSearch(fmt.Sprintf(`"displayName:%s"`, msGraphExistingGroupDisplayName)), 364 | ListWithFilter(fmt.Sprintf("displayName eq '%s'", msGraphExistingGroupDisplayName)), 365 | }, 366 | }, 367 | { 368 | name: fmt.Sprintf("Test if Group %v is present when using searchQuery with context", msGraphExistingGroupDisplayName), 369 | g: graphClient, 370 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 371 | wantErr: false, 372 | opts: []ListQueryOption{ 373 | ListWithSearch(fmt.Sprintf(`"displayName:%s"`, msGraphExistingGroupDisplayName)), 374 | ListWithFilter(fmt.Sprintf("displayName eq '%s'", msGraphExistingGroupDisplayName)), 375 | ListWithContext(context.Background()), 376 | }, 377 | }, 378 | } 379 | for _, tt := range tests { 380 | t.Run(tt.name, func(t *testing.T) { 381 | got, err := tt.g.ListGroups(tt.opts...) 382 | if (err != nil) != tt.wantErr { 383 | t.Errorf("GraphClient.ListGroups() error = %v, wantErr %v", err, tt.wantErr) 384 | return 385 | } 386 | found := false 387 | isGraphClientInitialized := true 388 | 389 | if len(got) != int(msGraphExistingGroupDisplayNameNumRes) { 390 | t.Errorf("GraphClient.ListGroups(): Did not find expected number of results. Wanted: %d, got: %d", msGraphExistingGroupDisplayNameNumRes, len(got)) 391 | } 392 | 393 | for _, checkObj := range got { 394 | found = found || tt.want.DisplayName == checkObj.DisplayName 395 | isGraphClientInitialized = isGraphClientInitialized && checkObj.graphClient != nil 396 | } 397 | if !found { 398 | t.Errorf("GraphClient.ListGroups() = %v, searching for one of %v", got, tt.want) 399 | } 400 | 401 | if !isGraphClientInitialized { 402 | t.Errorf("GraphClient.ListGroups() graphClient is nil, but was initialized from GraphClient") 403 | } 404 | }) 405 | } 406 | } 407 | 408 | func TestGraphClient_ListGroupsWithSelectAndFilter(t *testing.T) { 409 | tests := []struct { 410 | name string 411 | g *GraphClient 412 | opts []ListQueryOption 413 | want Group 414 | wantErr bool 415 | wantZeroFields []string 416 | wantNonZeroFields []string 417 | }{ 418 | { 419 | name: fmt.Sprintf("Test if Group %v is present when using searchQuery", msGraphExistingGroupDisplayName), 420 | g: graphClient, 421 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 422 | wantErr: false, 423 | opts: []ListQueryOption{ 424 | ListWithSelect("displayName,createdDateTime"), 425 | ListWithFilter(fmt.Sprintf("displayName eq '%s'", msGraphExistingGroupDisplayName)), 426 | }, 427 | wantZeroFields: []string{"ID"}, 428 | wantNonZeroFields: []string{"DisplayName", "CreatedDateTime"}, 429 | }, 430 | } 431 | for _, tt := range tests { 432 | t.Run(tt.name, func(t *testing.T) { 433 | got, err := tt.g.ListGroups(tt.opts...) 434 | if (err != nil) != tt.wantErr { 435 | t.Errorf("GraphClient.ListGroups() error = %v, wantErr %v", err, tt.wantErr) 436 | return 437 | } 438 | found := false 439 | isGraphClientInitialized := true 440 | 441 | if len(got) != int(msGraphExistingGroupDisplayNameNumRes) { 442 | t.Errorf("GraphClient.ListGroups(): Did not find expected number of results. Wanted: %d, got: %d", msGraphExistingGroupDisplayNameNumRes, len(got)) 443 | } 444 | 445 | for _, checkObj := range got { 446 | found = found || tt.want.DisplayName == checkObj.DisplayName 447 | 448 | assertZeroFields(t, checkObj, tt.wantZeroFields, tt.wantNonZeroFields) 449 | isGraphClientInitialized = isGraphClientInitialized && checkObj.graphClient != nil 450 | } 451 | if !found { 452 | t.Errorf("GraphClient.ListGroups() = %v, searching for one of %v", got, tt.want) 453 | } 454 | 455 | if !isGraphClientInitialized { 456 | t.Errorf("GraphClient.ListGroups() graphClient is nil, but was initialized from GraphClient") 457 | } 458 | }) 459 | } 460 | } 461 | 462 | func assertZeroFields(tb testing.TB, v interface{}, zeroFieldNames []string, nonZeroFieldNames []string) { 463 | tb.Helper() 464 | 465 | var ( 466 | jsonBytes []byte 467 | err error 468 | mappedData = make(map[string]interface{}) 469 | ) 470 | 471 | if jsonBytes, err = json.Marshal(v); err != nil { 472 | tb.Fatalf("json.Marshal() error = %v", err) 473 | } 474 | 475 | if err = json.Unmarshal(jsonBytes, &mappedData); err != nil { 476 | tb.Fatalf("json.Unmarshal() error = %v", err) 477 | } 478 | 479 | for _, fieldName := range nonZeroFieldNames { 480 | if isZeroValue(v, fieldName, mappedData) { 481 | tb.Fatalf("Expected field %s to have non zero value", fieldName) 482 | } 483 | } 484 | 485 | for _, fieldName := range zeroFieldNames { 486 | if !isZeroValue(v, fieldName, mappedData) { 487 | tb.Fatalf("Expected field %s to have zero value but got %v", fieldName, mappedData[fieldName]) 488 | } 489 | } 490 | } 491 | 492 | func isZeroValue(v interface{}, prop string, m map[string]interface{}) bool { 493 | // get value of 'v' if it's a reference 494 | underlying := reflect.Indirect(reflect.ValueOf(v)) 495 | // if v is nil pointer return zero straight away 496 | if underlying.IsZero() { 497 | return true 498 | } 499 | 500 | // check if property has a IsZero() bool func e.g. for time.Time 501 | if zeroable, hasIsZero := underlying.FieldByName(prop).Interface().(interface{ IsZero() bool }); hasIsZero { 502 | return zeroable.IsZero() 503 | } 504 | 505 | return reflect.ValueOf(m[prop]).IsZero() 506 | } 507 | 508 | func TestGraphClient_GetUser(t *testing.T) { 509 | type args struct { 510 | identifier string 511 | } 512 | tests := []struct { 513 | name string 514 | g *GraphClient 515 | args args 516 | want User 517 | wantErr bool 518 | }{ 519 | { 520 | name: fmt.Sprintf("Test if user %v is present", msGraphExistingUserPrincipalInGroup), 521 | g: graphClient, 522 | args: args{identifier: msGraphExistingUserPrincipalInGroup}, 523 | want: User{UserPrincipalName: msGraphExistingUserPrincipalInGroup}, 524 | wantErr: false, 525 | }, { 526 | name: "Test if non-existing user produces err", 527 | g: graphClient, 528 | args: args{identifier: "ThisUserwillNotExistForSure@contoso.com"}, 529 | want: User{}, 530 | wantErr: true, 531 | }, 532 | } 533 | for _, tt := range tests { 534 | t.Run(tt.name, func(t *testing.T) { 535 | got, err := tt.g.GetUser(tt.args.identifier) 536 | if (err != nil) != tt.wantErr { 537 | t.Errorf("GraphClient.GetUser() error = %v, wantErr %v", err, tt.wantErr) 538 | return 539 | } 540 | if got.UserPrincipalName != tt.want.UserPrincipalName { 541 | t.Errorf("GraphClient.GetUser() = %v, want %v", got, tt.want) 542 | } 543 | }) 544 | } 545 | } 546 | 547 | func TestGraphClient_GetGroup(t *testing.T) { 548 | tests := []struct { 549 | name string 550 | g *GraphClient 551 | opts []GetQueryOption 552 | want Group 553 | wantErr bool 554 | }{ 555 | { 556 | name: fmt.Sprintf("Test if Group %v is present and GetGroup-able", msGraphExistingGroupDisplayName), 557 | g: graphClient, 558 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 559 | wantErr: false, 560 | }, 561 | { 562 | name: fmt.Sprintf("Test if Group %v is present and GetGroup-able with context", msGraphExistingGroupDisplayName), 563 | g: graphClient, 564 | opts: []GetQueryOption{GetWithContext(context.Background())}, 565 | want: Group{DisplayName: msGraphExistingGroupDisplayName}, 566 | wantErr: false, 567 | }, 568 | } 569 | for _, tt := range tests { 570 | t.Run(tt.name, func(t *testing.T) { 571 | allGroups, err := tt.g.ListGroups() 572 | if err != nil { // check if groups can be listed 573 | t.Fatalf("GraphClient.ListGroups(): cannot list groups: %v", err) 574 | } 575 | targetGroup, err := allGroups.GetByDisplayName(tt.want.DisplayName) 576 | if err != nil { // check if the group to be tested is in the list 577 | t.Fatalf("Groups.GetByDisplayName(): cannot find group %v in %v, err: %v", tt.want.DisplayName, allGroups, err) 578 | } 579 | got, err := tt.g.GetGroup(targetGroup.ID) // actually execute the test we want to test 580 | if (err != nil) != tt.wantErr { 581 | t.Errorf("GraphClient.GetGroup() error = %v, wantErr %v", err, tt.wantErr) 582 | return 583 | } 584 | if !(got.DisplayName == tt.want.DisplayName) { 585 | t.Errorf("GraphClient.GetGroup() = %v, want %v", got, tt.want) 586 | } 587 | }) 588 | } 589 | } 590 | 591 | func TestGraphClient_CreateAndDeleteUser(t *testing.T) { 592 | var rndstring = randomString(32) 593 | tests := []struct { 594 | name string 595 | g *GraphClient 596 | want User 597 | wantErr bool 598 | }{ 599 | { 600 | name: "Create new User", 601 | g: graphClient, 602 | want: User{ 603 | AccountEnabled: true, 604 | DisplayName: "go-msgraph unit-test generated user " + time.Now().Format("2006-01-02") + " - random " + rndstring, 605 | MailNickname: "go-msgraph.unit-test.generated." + rndstring, 606 | UserPrincipalName: "go-msgraph.unit-test.generated." + rndstring + "@" + msGraphDomainNameForCreateTests, 607 | PasswordProfile: PasswordProfile{Password: randomString(32)}, 608 | }, 609 | wantErr: false, 610 | }, 611 | } 612 | for _, tt := range tests { 613 | t.Run(tt.name, func(t *testing.T) { 614 | // test CreateUser 615 | got, err := tt.g.CreateUser(tt.want) 616 | if (err != nil) != tt.wantErr { 617 | t.Errorf("GraphClient.CreateUser() error = %v, wantErr %v", err, tt.wantErr) 618 | return 619 | } 620 | fmt.Printf("GraphClient.CreateUser() result: %v\n", got) 621 | // test DisableAccount 622 | err = got.DisableAccount() 623 | if (err != nil) != tt.wantErr { 624 | t.Errorf("User.DisableAccount() error = %v, wantErr %v", err, tt.wantErr) 625 | } 626 | // get user again to compare AccountEnabled field 627 | got, err = tt.g.GetUser(got.ID) 628 | if (err != nil) != tt.wantErr { 629 | t.Errorf("GraphClient.GetUser() error = %v, wantErr %v", err, tt.wantErr) 630 | } 631 | if got.AccountEnabled == true { 632 | t.Errorf("User.DisableAccount() did not work, AccountEnabled is still true") 633 | } 634 | // Delete user again 635 | err = got.DeleteUser() 636 | if (err != nil) != tt.wantErr { 637 | t.Errorf("User.DeleteUser() error = %v, wantErr %v", err, tt.wantErr) 638 | } 639 | }) 640 | } 641 | } 642 | 643 | func TestGetQueryOptions_Context(t *testing.T) { 644 | t.Parallel() 645 | tests := []struct { 646 | name string 647 | ctxSetup func(tb testing.TB) context.Context 648 | wantDeadline bool 649 | }{ 650 | { 651 | name: "do not set a context explicitly expect background context", 652 | wantDeadline: false, 653 | }, 654 | { 655 | name: "set background context", 656 | ctxSetup: func(testing.TB) context.Context { 657 | return context.Background() 658 | }, 659 | wantDeadline: false, 660 | }, 661 | { 662 | name: "set context with timeout", 663 | ctxSetup: func(tb testing.TB) context.Context { 664 | t.Helper() 665 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 666 | tb.Cleanup(cancel) 667 | return ctx 668 | }, 669 | wantDeadline: true, 670 | }, 671 | } 672 | for _, tt := range tests { 673 | tt := tt 674 | t.Run(tt.name, func(t *testing.T) { 675 | t.Parallel() 676 | var opts []GetQueryOption 677 | if tt.ctxSetup != nil { 678 | ctx := tt.ctxSetup(t) 679 | opts = append(opts, GetWithContext(ctx)) 680 | } 681 | 682 | var compiledOpts = compileGetQueryOptions(opts) 683 | var effectiveCtx = compiledOpts.Context() 684 | if _, ok := effectiveCtx.Deadline(); ok != tt.wantDeadline { 685 | t.Errorf("wantDeadline = %t but got %t", tt.wantDeadline, ok) 686 | } 687 | }) 688 | } 689 | } 690 | 691 | func TestListQueryOptions_Context(t *testing.T) { 692 | t.Parallel() 693 | tests := []struct { 694 | name string 695 | ctxSetup func(tb testing.TB) context.Context 696 | wantDeadline bool 697 | }{ 698 | { 699 | name: "do not set a context explicitly expect background context", 700 | wantDeadline: false, 701 | }, 702 | { 703 | name: "set background context", 704 | ctxSetup: func(testing.TB) context.Context { 705 | return context.Background() 706 | }, 707 | wantDeadline: false, 708 | }, 709 | { 710 | name: "set context with timeout", 711 | ctxSetup: func(tb testing.TB) context.Context { 712 | t.Helper() 713 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 714 | tb.Cleanup(cancel) 715 | return ctx 716 | }, 717 | wantDeadline: true, 718 | }, 719 | } 720 | for _, tt := range tests { 721 | tt := tt 722 | t.Run(tt.name, func(t *testing.T) { 723 | t.Parallel() 724 | var opts []ListQueryOption 725 | if tt.ctxSetup != nil { 726 | ctx := tt.ctxSetup(t) 727 | opts = append(opts, ListWithContext(ctx)) 728 | } 729 | 730 | var compiledOpts = compileListQueryOptions(opts) 731 | var effectiveCtx = compiledOpts.Context() 732 | if _, ok := effectiveCtx.Deadline(); ok != tt.wantDeadline { 733 | t.Errorf("wantDeadline = %t but got %t", tt.wantDeadline, ok) 734 | } 735 | }) 736 | } 737 | } 738 | 739 | func TestGetQueryOptions_Values(t *testing.T) { 740 | t.Parallel() 741 | tests := []struct { 742 | name string 743 | opts []GetQueryOption 744 | wantValues string 745 | }{ 746 | { 747 | name: "add $select", 748 | opts: []GetQueryOption{GetWithSelect("displayName")}, 749 | wantValues: url.Values{ 750 | "$select": []string{"displayName"}, 751 | }.Encode(), 752 | }, 753 | { 754 | name: "Select multiple values", 755 | opts: []GetQueryOption{GetWithSelect("displayName,createdDateTime")}, 756 | wantValues: url.Values{ 757 | "$select": []string{"displayName,createdDateTime"}, 758 | }.Encode(), 759 | }, 760 | } 761 | for _, tt := range tests { 762 | tt := tt 763 | t.Run(tt.name, func(t *testing.T) { 764 | t.Parallel() 765 | var compiledOpts = compileGetQueryOptions(tt.opts) 766 | if encodedValues := compiledOpts.Values().Encode(); tt.wantValues != encodedValues { 767 | unescapedWant, _ := url.PathUnescape(tt.wantValues) 768 | unescapedGot, _ := url.PathUnescape(encodedValues) 769 | 770 | t.Errorf("Expected values %s but got %s", unescapedWant, unescapedGot) 771 | } 772 | }) 773 | } 774 | } 775 | 776 | func TestListQueryOptions_ValuesAndHeaders(t *testing.T) { 777 | t.Parallel() 778 | tests := []struct { 779 | name string 780 | opts []ListQueryOption 781 | wantValues string 782 | wantHeaders map[string]string 783 | }{ 784 | { 785 | name: "add $select", 786 | opts: []ListQueryOption{ListWithSelect("displayName")}, 787 | wantValues: url.Values{ 788 | "$select": []string{"displayName"}, 789 | }.Encode(), 790 | }, 791 | { 792 | name: "Select multiple values", 793 | opts: []ListQueryOption{ListWithSelect("displayName,createdDateTime")}, 794 | wantValues: url.Values{ 795 | "$select": []string{"displayName,createdDateTime"}, 796 | }.Encode(), 797 | }, 798 | { 799 | name: "Add $filter", 800 | opts: []ListQueryOption{ListWithFilter("displayName eq SomeGroupName")}, 801 | wantValues: url.Values{ 802 | "$filter": []string{"displayName eq SomeGroupName"}, 803 | }.Encode(), 804 | }, 805 | { 806 | name: "Add $search", 807 | opts: []ListQueryOption{ListWithSearch("displayName:hello")}, 808 | wantValues: url.Values{ 809 | "$search": []string{"displayName:hello"}, 810 | }.Encode(), 811 | wantHeaders: map[string]string{ 812 | "ConsistencyLevel": "eventual", 813 | }, 814 | }, 815 | { 816 | name: "Add $search and $filter", 817 | opts: []ListQueryOption{ 818 | ListWithSearch("displayName:hello"), 819 | ListWithFilter("displayName eq 'hello world'"), 820 | }, 821 | wantValues: url.Values{ 822 | "$search": []string{"displayName:hello"}, 823 | "$filter": []string{"displayName eq 'hello world'"}, 824 | }.Encode(), 825 | }, 826 | } 827 | for _, tt := range tests { 828 | tt := tt 829 | t.Run(tt.name, func(t *testing.T) { 830 | t.Parallel() 831 | var compiledOpts = compileListQueryOptions(tt.opts) 832 | if encodedValues := compiledOpts.Values().Encode(); tt.wantValues != encodedValues { 833 | unescapedWant, _ := url.PathUnescape(tt.wantValues) 834 | unescapedGot, _ := url.PathUnescape(encodedValues) 835 | t.Errorf("Expected values %s but got %s", unescapedWant, unescapedGot) 836 | return 837 | } 838 | if tt.wantHeaders != nil { 839 | var actualHeaders = compiledOpts.Headers() 840 | for key, wantValue := range tt.wantHeaders { 841 | if got := actualHeaders.Get(key); got != wantValue { 842 | t.Errorf("Expected %s for header %s but got %s", wantValue, key, got) 843 | } 844 | } 845 | } 846 | }) 847 | } 848 | } 849 | 850 | func TestGraphClient_UnmarshalJSON(t *testing.T) { 851 | 852 | type args struct { 853 | data []byte 854 | } 855 | tests := []struct { 856 | name string 857 | args args 858 | wantErr bool 859 | }{ 860 | { 861 | name: "All correct", 862 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", msGraphTenantID, msGraphApplicationID, msGraphClientSecret, msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 863 | wantErr: false, 864 | }, { 865 | name: "JSON-syntax error", 866 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"", msGraphTenantID, msGraphApplicationID, msGraphClientSecret, msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 867 | wantErr: true, 868 | }, { 869 | name: "TenantID incorrect", 870 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", "wrongtenant", msGraphApplicationID, msGraphClientSecret, msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 871 | wantErr: true, 872 | }, { 873 | name: "TenantID empty", 874 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", "", msGraphApplicationID, msGraphClientSecret, msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 875 | wantErr: true, 876 | }, { 877 | name: "ApplicationID incorrect", 878 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", msGraphTenantID, "wrongapplication", msGraphClientSecret, msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 879 | wantErr: true, 880 | }, { 881 | name: "ApplicationID empty", 882 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", msGraphTenantID, "", msGraphClientSecret, msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 883 | wantErr: true, 884 | }, { 885 | name: "ClientSecret incorrect", 886 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", msGraphTenantID, msGraphApplicationID, "wrongclientsecret", msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 887 | wantErr: true, 888 | }, { 889 | name: "ClientSecret empty", 890 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\", \"AzureADAuthEndpoint\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", msGraphTenantID, msGraphApplicationID, "", msGraphAzureADAuthEndpoint, msGraphServiceRootEndpoint))}, 891 | wantErr: true, 892 | }, 893 | } 894 | 895 | // only test empty endpoints if the endpoint is the default - global - endpoint. Otherwise the default values do not apply. 896 | if msGraphAzureADAuthEndpoint == AzureADAuthEndpointGlobal && msGraphServiceRootEndpoint == ServiceRootEndpointGlobal { 897 | tests = append(tests, struct { 898 | name string 899 | args args 900 | wantErr bool 901 | }{ 902 | name: "Empty AzureADAuthEndpoint", 903 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\",\"ServiceRootEndpoint\": \"%v\"}", msGraphTenantID, msGraphApplicationID, msGraphClientSecret, msGraphServiceRootEndpoint))}, 904 | wantErr: false, 905 | }) 906 | tests = append(tests, struct { 907 | name string 908 | args args 909 | wantErr bool 910 | }{ 911 | name: "Empty ServiceRootEndpoint", 912 | args: args{data: []byte(fmt.Sprintf("{\"TenantID\": \"%v\", \"ApplicationID\": \"%v\",\"ClientSecret\": \"%v\",\"AzureADAuthEndpoint\": \"%v\"}", msGraphTenantID, msGraphApplicationID, msGraphClientSecret, msGraphAzureADAuthEndpoint))}, 913 | wantErr: false, 914 | }) 915 | } 916 | 917 | for _, tt := range tests { 918 | t.Run(tt.name, func(t *testing.T) { 919 | var unmarshalTest GraphClient 920 | if err := unmarshalTest.UnmarshalJSON(tt.args.data); (err != nil) != tt.wantErr { 921 | t.Errorf("GraphClient.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 922 | } 923 | }) 924 | } 925 | } 926 | 927 | func TestGraphClient_String(t *testing.T) { 928 | if fmt.Sprintf("GraphClient(TenantID: %v, ApplicationID: %v, ClientSecret: %v...%v, Token validity: [%v - %v])", 929 | graphClient.TenantID, graphClient.ApplicationID, graphClient.ClientSecret[0:3], graphClient.ClientSecret[len(graphClient.ClientSecret)-3:], graphClient.token.NotBefore, graphClient.token.ExpiresOn) != graphClient.String() { 930 | t.Errorf("GraphClient.String(): String function failed") 931 | } 932 | } 933 | --------------------------------------------------------------------------------