Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: add unit tests for the client and utils #73

Merged
merged 7 commits into from
Nov 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/on-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ jobs:
name: Check code quality
uses: ./.github/workflows/lint.yaml

test:
name: Run tests
uses: ./.github/workflows/test.yaml

conventional-commits:
name: Check for conventional commits compliance
uses: ./.github/workflows/conventional-commits.yaml
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/on-push-main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
branches: [main]

jobs:
test:
name: Run tests
uses: ./.github/workflows/test.yaml

snyk:
name: Scan dependencies
uses: ./.github/workflows/snyk.yaml
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/on-tag.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
go-version-file: go.mod
check-latest: true

- name: Cache pre-commit
- name: Cache go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Run tests
on: [workflow_call]

concurrency:
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}-test

jobs:
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout source code
uses: actions/checkout@v4

- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true

- name: Run go tests
run: go test -v ./...
5 changes: 3 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ repos:
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
stages: [commit]

- repo: https://github.com/golangci/golangci-lint
rev: v1.59.1
rev: v1.61.0
hooks:
- id: golangci-lint
- id: golangci-lint-full

- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.2.0
rev: v3.6.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Azure PIM CLI
*Azure Privileged Identity Management Command Line Interface*

[![Go Reference](https://pkg.go.dev/badge/github.com/netr0m/az-pim-cli.svg)](https://pkg.go.dev/github.com/netr0m/az-pim-cli)
[![Go Reference](https://pkg.go.dev/badge/github.com/netr0m/az-pim-cli.svg)](https://pkg.go.dev/github.com/netr0m/az-pim-cli) [![Go Report Card](https://goreportcard.com/badge/github.com/netr0m/az-pim-cli)](https://goreportcard.com/report/github.com/netr0m/az-pim-cli)

`az-pim-cli` eases the process of listing and activating Azure PIM roles by allowing activation via the command line. Authentication is handled with the `azure.identity` library by utilizing the `AzureCLICredential` method.
It currently supports ['azure resources'](#azure-resources), ['groups'](#groups), and ['entra roles'](#entra-roles)
Expand Down Expand Up @@ -335,6 +335,14 @@ To ease the process of troubleshooting, you can add the flag `--debug` to enable
$ az-pim-cli activate role --name my-entra-id-role --duration 5 --debug
```

## Testing

To run the unit tests, run the following command from the project root:

```bash
$ go test -v ./...
```

## Contributing

Want to contribute to the project? There are a few things you need to know.
Expand Down
9 changes: 8 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module github.com/netr0m/az-pim-cli

go 1.22.4
go 1.23

toolchain go1.23.0

require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0
Expand All @@ -10,24 +12,29 @@ require (
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
)

require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
Expand Down
5 changes: 3 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
Expand Down Expand Up @@ -64,6 +64,7 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
Expand Down
1 change: 1 addition & 0 deletions golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ linters:
- revive
- staticcheck
- stylecheck
- testifylint
- unconvert
- unparam
- unused
Expand Down
230 changes: 230 additions & 0 deletions pkg/pim/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/*
Copyright © 2024 netr0m <netr0m@pm.me>
*/
package pim

import (
"fmt"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type mockClient struct{ mock.Mock }

func newMockClient() *mockClient { return &mockClient{} }

func (m *mockClient) GetAccessToken(scope string) string {
args := m.Called(scope)
return args.String(0)
}

func TestGetAccessToken(t *testing.T) {
m := newMockClient()

m.On("GetAccessToken", AZ_PIM_SCOPE).Return(TEST_DUMMY_JWT)

token := GetAccessToken(AZ_PIM_SCOPE, m)

if !strings.HasPrefix(token, "ey") {
t.Errorf("expected token to start with 'ey', got %s", token)
}

m.AssertCalled(t, "GetAccessToken", AZ_PIM_SCOPE)
}

func TestGetUserInfo(t *testing.T) {
m := newMockClient()

m.On("GetAccessToken", AZ_PIM_SCOPE).Return(TEST_DUMMY_JWT)

token := GetAccessToken(AZ_PIM_SCOPE, m)
userInfo := GetUserInfo(token)

if userInfo.Email != TEST_DUMMY_PRINCIPAL_EMAIL {
t.Errorf("unexpected value for userInfo.Email, got %s", userInfo.Email)
}
}

func (m *mockClient) GetEligibleResourceAssignments(token string) *ResourceAssignmentResponse {
args := m.Called(token)
return args.Get(0).(*ResourceAssignmentResponse)
}

func TestGetEligibleResourceAssignments(t *testing.T) {
m := newMockClient()

m.On("GetEligibleResourceAssignments", TEST_DUMMY_JWT).Return(EligibleResourceAssignmentsDummyData)

eligibleResourceAssignments := GetEligibleResourceAssignments(TEST_DUMMY_JWT, m)

if len(eligibleResourceAssignments.Value) != 4 {
t.Errorf("expected 4 eligible resource assignments, got %v", len(eligibleResourceAssignments.Value))
}
for _, governanceRole := range eligibleResourceAssignments.Value {
_principalId := governanceRole.Properties.ExpandedProperties.Principal.Id
if _principalId != TEST_DUMMY_PRINCIPAL_ID {
t.Errorf("expected resource Properties.ExpandedProperties.Principal.Id to be %s, got %s", TEST_DUMMY_PRINCIPAL_ID, _principalId)
}
}
// Check resource name
_resourceName := eligibleResourceAssignments.Value[1].Properties.ExpandedProperties.Scope.DisplayName
if _resourceName != TEST_DUMMY_SUBSCRIPTION_1_NAME {
t.Errorf("expected resource Properties.ExpandedProperties.Scope.DisplayName to be %s, got %s", TEST_DUMMY_SUBSCRIPTION_1_NAME, _resourceName)
}
// Check role name
_roleName := eligibleResourceAssignments.Value[2].Properties.ExpandedProperties.RoleDefinition.DisplayName
if _roleName != TEST_DUMMY_ROLE_1_NAME {
t.Errorf("expected resource Properties.ExpandedProperties.RoleDefinition.DisplayName to be %s, got %s", TEST_DUMMY_ROLE_1_NAME, _roleName)
}
}

func (m *mockClient) GetEligibleGovernanceRoleAssignments(roleType string, subjectId string, token string) *GovernanceRoleAssignmentResponse {
args := m.Called(roleType, subjectId, token)
return args.Get(0).(*GovernanceRoleAssignmentResponse)
}

func TestGetEligibleGovernanceRoleAssignmentsAADGroup(t *testing.T) {
m := newMockClient()

m.On("GetEligibleGovernanceRoleAssignments", ROLE_TYPE_AAD_GROUPS, TEST_DUMMY_PRINCIPAL_ID, TEST_DUMMY_JWT).Return(EligibleGovernanceRoleAssignmentsDummyData)

eligibleGovernanceRoleAssignments := GetEligibleGovernanceRoleAssignments(ROLE_TYPE_AAD_GROUPS, TEST_DUMMY_PRINCIPAL_ID, TEST_DUMMY_JWT, m)

if len(eligibleGovernanceRoleAssignments.Value) != 3 {
t.Errorf("expected 3 eligible governance role assignments, got %v", len(eligibleGovernanceRoleAssignments.Value))
}
for _, governanceRole := range eligibleGovernanceRoleAssignments.Value {
if governanceRole.SubjectId != TEST_DUMMY_PRINCIPAL_ID {
t.Errorf("expected governance role SubjectId to be %s, got %s", TEST_DUMMY_PRINCIPAL_ID, governanceRole.SubjectId)
}
}
// Check group name
_groupName := eligibleGovernanceRoleAssignments.Value[1].RoleDefinition.Resource.DisplayName
if _groupName != TEST_DUMMY_GROUP_1_NAME {
t.Errorf("expected governance role RoleDefinition.Resource.DisplayName to be %s, got %s", TEST_DUMMY_GROUP_1_NAME, _groupName)
}
// Check role name
_roleName := eligibleGovernanceRoleAssignments.Value[2].RoleDefinition.DisplayName
if _roleName != TEST_DUMMY_ROLE_1_NAME {
t.Errorf("expected governance role RoleDefinition.DisplayName to be %s, got %s", TEST_DUMMY_ROLE_1_NAME, _roleName)
}
}

func (m *mockClient) ValidateResourceAssignmentRequest(scope string, resourceAssignmentRequest *ResourceAssignmentRequestRequest, token string) bool {
args := m.Called(scope, resourceAssignmentRequest, token)
return args.Bool(0)
}

func TestValidateResourceAssignmentRequest(t *testing.T) {
m := newMockClient()

resourceAssignment := &EligibleResourceAssignmentsDummyData.Value[0]
scope, resourceAssignmentRequest := CreateResourceAssignmentRequest(TEST_DUMMY_PRINCIPAL_ID, resourceAssignment, 30, "test", "Test", "1337")

m.On("ValidateResourceAssignmentRequest", scope, resourceAssignmentRequest, TEST_DUMMY_JWT).Return(true)

isValid := ValidateResourceAssignmentRequest(scope, resourceAssignmentRequest, TEST_DUMMY_JWT, m)

if !isValid {
t.Errorf("expected resource assignment request validation to be successful, got %v", isValid)
}
}

func (m *mockClient) ValidateGovernanceRoleAssignmentRequest(roleType string, roleAssignmentRequest *GovernanceRoleAssignmentRequest, token string) bool {
args := m.Called(roleType, roleAssignmentRequest, token)
return args.Bool(0)
}

func TestValidateGovernanceRoleAssignmentRequest(t *testing.T) {
m := newMockClient()

governanceRoleAssignment := &EligibleGovernanceRoleAssignmentsDummyData.Value[0]
roleType, governanceRoleAssignmentRequest := CreateGovernanceRoleAssignmentRequest(TEST_DUMMY_PRINCIPAL_ID, ROLE_TYPE_AAD_GROUPS, governanceRoleAssignment, 30, "test", "Test", "1337")

m.On("ValidateGovernanceRoleAssignmentRequest", roleType, governanceRoleAssignmentRequest, TEST_DUMMY_JWT).Return(true)

isValid := ValidateGovernanceRoleAssignmentRequest(roleType, governanceRoleAssignmentRequest, TEST_DUMMY_JWT, m)

if !isValid {
t.Errorf("expected governance role assignment request validation to be successful, got %v", isValid)
}
}

func (m *mockClient) RequestResourceAssignment(scope string, resourceAssignmentRequest *ResourceAssignmentRequestRequest, token string) *ResourceAssignmentRequestResponse {
args := m.Called(scope, resourceAssignmentRequest, token)
return args.Get(0).(*ResourceAssignmentRequestResponse)
}

func TestRequestResourceAssignment(t *testing.T) {
m := newMockClient()

resourceAssignment := &EligibleResourceAssignmentsDummyData.Value[0]
scope, resourceAssignmentRequest := CreateResourceAssignmentRequest(TEST_DUMMY_PRINCIPAL_ID, resourceAssignment, DEFAULT_DURATION_MINUTES, DEFAULT_REASON, "Test", "1337")
resourceAssignmentRequestResponse := &ResourceAssignmentRequestResponse{
Id: resourceAssignment.Id,
Name: resourceAssignment.Name,
Type: resourceAssignment.Type,
Properties: &ResourceAssignmentValidationProperties{
Scope: scope,
PrincipalId: resourceAssignmentRequest.Properties.PrincipalId,
Status: "Active",
ScheduleInfo: resourceAssignmentRequest.Properties.ScheduleInfo,
Justification: DEFAULT_REASON,
TicketInfo: resourceAssignmentRequest.Properties.TicketInfo,
RoleDefinitionId: resourceAssignmentRequest.Properties.RoleDefinitionId,
ExpandedProperties: resourceAssignment.Properties.ExpandedProperties,
},
}

m.On("RequestResourceAssignment", scope, resourceAssignmentRequest, TEST_DUMMY_JWT).Return(resourceAssignmentRequestResponse)

requestResponse := RequestResourceAssignment(scope, resourceAssignmentRequest, TEST_DUMMY_JWT, m)
expectedDuration := fmt.Sprintf("PT%dM", DEFAULT_DURATION_MINUTES)

assert.Equal(t, requestResponse.Properties.Justification, DEFAULT_REASON, "expected resource assignment request justification to be %s, got %s", DEFAULT_REASON, requestResponse.Properties.Justification)
assert.Equal(t, requestResponse.Properties.PrincipalId, TEST_DUMMY_PRINCIPAL_ID, "expected resource assignment request principal ID to be %s, got %s", TEST_DUMMY_PRINCIPAL_ID, requestResponse.Properties.PrincipalId)
assert.Equal(t, requestResponse.Properties.Status, "Active", "expected resource assignment request status to be %s, got %s", "Active", requestResponse.Properties.Status)
assert.Equal(t, requestResponse.Properties.ScheduleInfo.Expiration.Duration, expectedDuration, "expected resource assignment request expiration duration to be %s, got %s", expectedDuration, requestResponse.Properties.Status)
}

func (m *mockClient) RequestGovernanceRoleAssignment(roleType string, governanceRoleAssignmentRequest *GovernanceRoleAssignmentRequest, token string) *GovernanceRoleAssignmentRequestResponse {
args := m.Called(roleType, governanceRoleAssignmentRequest, token)
return args.Get(0).(*GovernanceRoleAssignmentRequestResponse)
}

func TestRequestGovernanceRoleAssignmentAADGroup(t *testing.T) {
m := newMockClient()

governanceRoleAssignment := &EligibleGovernanceRoleAssignmentsDummyData.Value[0]
roleType, governanceRoleAssignmentRequest := CreateGovernanceRoleAssignmentRequest(TEST_DUMMY_PRINCIPAL_ID, ROLE_TYPE_AAD_GROUPS, governanceRoleAssignment, DEFAULT_DURATION_MINUTES, DEFAULT_REASON, "Test", "1337")
governanceRoleAssignmentRequestResponse := &GovernanceRoleAssignmentRequestResponse{
Id: governanceRoleAssignment.Id,
ResourceId: governanceRoleAssignmentRequest.ResourceId,
RoleDefinitionId: governanceRoleAssignmentRequest.RoleDefinitionId,
SubjectId: governanceRoleAssignment.SubjectId,
AssignmentState: governanceRoleAssignmentRequest.AssignmentState,
Status: &GovernanceRoleAssignmentRequestStatus{
Status: "Active",
SubStatus: "Active",
},
TicketSystem: "Test",
TicketNumber: "1337",
Reason: DEFAULT_REASON,
Schedule: governanceRoleAssignmentRequest.Schedule,
LinkedEligibleRoleAssignmentId: governanceRoleAssignmentRequest.LinkedEligibleRoleAssignmentId,
ScopedResourceId: governanceRoleAssignmentRequest.ScopedResourceId,
}

m.On("RequestGovernanceRoleAssignment", ROLE_TYPE_AAD_GROUPS, governanceRoleAssignmentRequest, TEST_DUMMY_JWT).Return(governanceRoleAssignmentRequestResponse)

requestResponse := RequestGovernanceRoleAssignment(roleType, governanceRoleAssignmentRequest, TEST_DUMMY_JWT, m)
expectedDuration := fmt.Sprintf("PT%dM", DEFAULT_DURATION_MINUTES)

assert.Equal(t, requestResponse.Reason, DEFAULT_REASON, "expected governance role assignment request reason to be %s, got %s", DEFAULT_REASON, requestResponse.Reason)
assert.Equal(t, requestResponse.SubjectId, TEST_DUMMY_PRINCIPAL_ID, "expected governance role assignment request subject ID to be %s, got %s", TEST_DUMMY_PRINCIPAL_ID, requestResponse.SubjectId)
assert.Equal(t, requestResponse.Status.Status, "Active", "expected governance role assignment request status to be %s, got %s", "Active", requestResponse.Status.Status)
assert.Equal(t, requestResponse.Schedule.Duration, expectedDuration, "expected governance role assignment request expiration duration to be %s, got %s", expectedDuration, requestResponse.Schedule.Duration)
}
Loading