Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: latest
node-version: 24

- name: Install Dependencies
run: yarn
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: latest
node-version: 24
- name: Install dependencies
run: yarn
- name: Build
Expand Down
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

yarn qa
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
},
"version": "6.2.0",
"volta": {
"node": "20.18.1",
"node": "24.12.0",
"yarn": "1.22.22"
}
}
7 changes: 6 additions & 1 deletion src/lib/graph-api/microsoft-graph-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export class MicrosoftGraphApi extends MicrosoftApiBase {
licenses!: Licenses

constructor(config: GraphApiConfig) {
super(config, 'https://graph.microsoft.com/v1.0/', 'https://graph.microsoft.com/.default')
super(
config,
'https://graph.microsoft.com/v1.0/',
'https://graph.microsoft.com/.default',
'v2',
)
this.domains = new Domains(this.httpAgent)
this.gdap = new Gdap(this.httpAgent)
this.users = new Users(this.httpAgent)
Expand Down
37 changes: 34 additions & 3 deletions src/lib/graph-api/users/user.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export interface GraphUser {
aboutMe: string
accountEnabled: true
accountEnabled: boolean
ageGroup: string
assignedLicenses: { '@odata.type': 'microsoft.graph.assignedLicense' }[]
assignedPlans: { '@odata.type': 'microsoft.graph.assignedPlan' }[]
Expand Down Expand Up @@ -49,7 +49,7 @@ export interface GraphUser {
onPremisesProvisioningErrors: { '@odata.type': 'microsoft.graph.onPremisesProvisioningError' }[]
onPremisesSamAccountName: string
onPremisesSecurityIdentifier: string
onPremisesSyncEnabled: true
onPremisesSyncEnabled: boolean
onPremisesUserPrincipalName: string
otherMails: string[]
passwordPolicies: string
Expand All @@ -65,7 +65,7 @@ export interface GraphUser {
schools: string[]
securityIdentifier: string
serviceProvisioningErrors: { '@odata.type': 'microsoft.graph.serviceProvisioningXmlError' }[]
showInAddressList: true
showInAddressList: boolean
signInActivity: { '@odata.type': 'microsoft.graph.signInActivity' }
signInSessionsValidFromDateTime: Date
skills: string[]
Expand Down Expand Up @@ -98,3 +98,34 @@ export interface GraphUser {
photos: { '@odata.type': 'microsoft.graph.profilePhoto' }[]
registeredDevices: { '@odata.type': 'microsoft.graph.directoryObject' }[]
}

export interface CreateOrUpdateGraphUser
extends GraphUser,
Omit<
GraphUser,
| 'id'
| 'createdDateTime'
| 'lastPasswordChangeDateTime'
| 'passwordPolicies'
| 'passwordProfile'
| 'passwordLastSetDateTime'
| 'passwordResetOptions'
| 'passwordResetRequired'
| 'passwordChangeRequired'
| 'passwordChangeNotRequired'
| 'manager'
> {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type definition fails to omit intended properties

Medium Severity

The CreateOrUpdateGraphUser interface extends both GraphUser and Omit<GraphUser, ...>. Since GraphUser is extended first, all its properties (including id, createdDateTime, etc.) are inherited. The subsequent Omit<GraphUser, ...> extension doesn't remove those properties—it just adds properties that are already present. The interface ends up identical to GraphUser, defeating the purpose of the omit. The correct pattern is to extend only Omit<GraphUser, ...> without also extending GraphUser.

Fix in Cursor Fix in Web


export const GraphUserDefaultProperties = [
'businessPhones',
'displayName',
'givenName',
'jobTitle',
'mail',
'mobilePhone',
'officeLocation',
'preferredLanguage',
'surname',
'userPrincipalName',
'id',
]
66 changes: 64 additions & 2 deletions src/lib/graph-api/users/users.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Users } from './users'
import mockAxios from 'jest-mock-axios'
import { AxiosInstance } from 'axios'
import { GraphUserDefaultProperties } from './user.types'

describe('Users', () => {
let users: Users
Expand All @@ -12,11 +13,45 @@ describe('Users', () => {
it('creates an instance of Users', () => expect(users).toBeTruthy())

describe('get', () => {
it('gets a user by id or userPrincipalName', async () => {
it('gets a user by id or userPrincipalName (default)', async () => {
const data = { id: 'id', userPrincipalName: 'userPrincipalName' }
jest.spyOn(mockAxios, 'get').mockResolvedValue({ data })
await expect(users.get('id')).resolves.toEqual(data)
expect(mockAxios.get).toHaveBeenCalledWith('users/id')
expect(mockAxios.get).toHaveBeenCalledWith(
`users/id?$select=${GraphUserDefaultProperties.join(',')}`,
)
})

it('gets a user by id or userPrincipalName (additional)', async () => {
const data = {
id: 'id',
userPrincipalName: 'userPrincipalName',
showInAddressList: true,
}
jest.spyOn(mockAxios, 'get').mockResolvedValue({ data })
await expect(users.get('id', ['showInAddressList'])).resolves.toEqual(data)
expect(mockAxios.get).toHaveBeenCalledWith(
`users/id?$select=${GraphUserDefaultProperties.join(',')},showInAddressList`,
)
})

it('gets a user by id or userPrincipalName (expand)', async () => {
const data = {
id: 'id',
userPrincipalName: 'userPrincipalName',
showInAddressList: true,
manager: {
id: 'managerId',
userPrincipalName: 'managerUPN',
},
}
jest.spyOn(mockAxios, 'get').mockResolvedValue({ data })
await expect(
users.get('id', ['showInAddressList'], ['manager($select=userPrincipalName)']),
).resolves.toEqual(data)
expect(mockAxios.get).toHaveBeenCalledWith(
`users/id?$select=${GraphUserDefaultProperties.join(',')},showInAddressList&$expand=manager($select=userPrincipalName)`,
)
})
})

Expand Down Expand Up @@ -92,4 +127,31 @@ describe('Users', () => {
expect(mockAxios.delete).toHaveBeenCalledWith('users/userId/manager/$ref')
})
})

describe('create', () => {
it('creates a user', async () => {
const data = { displayName: 'John Doe' } as any
const createdUser = { id: 'userId', ...data }
jest.spyOn(mockAxios, 'post').mockResolvedValue({ data: createdUser })

const result = await users.create(data)

expect(result).toEqual(createdUser)
expect(mockAxios.post).toHaveBeenCalledWith('/users', { displayName: 'John Doe' })
})
})

describe('update', () => {
it('updates a user', async () => {
const data = { displayName: 'John Doe Updated' } as any
const updatedUser = { id: 'userId', ...data }
jest.spyOn(mockAxios, 'patch').mockResolvedValue({ data: updatedUser })

const result = await users.update('userId', data)

expect(result).toEqual(updatedUser)
expect(mockAxios.patch).toHaveBeenCalledWith('/users/userId', data)
expect(mockAxios.get).not.toHaveBeenCalled()
})
})
})
49 changes: 46 additions & 3 deletions src/lib/graph-api/users/users.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AxiosInstance, AxiosResponse } from 'axios'
import { GraphUser } from './user.types'
import { GraphUser, GraphUserDefaultProperties } from './user.types'

export class Users {
constructor(private readonly http: AxiosInstance) {}
Expand All @@ -8,9 +8,52 @@ export class Users {
* Gets a user by id or userPrincipalName
* https://learn.microsoft.com/en-us/graph/api/user-get
* @param id {id | userPrincipalName}
* @param additionalProperties (keyof GraphUser)[]
* @param expandProperties this can be either a string (keyof GraphUser) or a function format,
* ($select/$expand), e.g., 'manager($select=id,userPrincipalName)'
*/
async get(id: string): Promise<GraphUser> {
const { data: user } = await this.http.get(`users/${id}`)
async get(
id: string,
additionalProperties: (keyof GraphUser)[] = [],
expandProperties: (keyof GraphUser | string)[] = [],
): Promise<GraphUser> {
let url = `users/${id}?$select=${GraphUserDefaultProperties.join(',')}`
if (additionalProperties.length) {
// The leading comma continues the defaultProperties string
url += `,${additionalProperties.join(',')}`
}

if (expandProperties.length) {
url += `&$expand=${expandProperties.join(',')}`
}

const { data: user } = await this.http.get(url)
return user
}

/**
* Creates a new user in the system and optionally assigns a manager to the user.
*
* @param {CreateOrUpdateGraphUser} data - The user data required for creation. If a manager is included, it will be validated and assigned to the user.
* @return {Promise<GraphUser>} A promise resolving to the created user object.
* @throws {Error} If a manager's userPrincipalName is provided but does not exist in the system.
*/
async create(data: Omit<GraphUser, 'id'>): Promise<GraphUser> {
const { data: user } = await this.http.post('/users', data)
return user
}

/**
* Updates a GraphUser with the provided data. If a manager is specified in the data,
* it will handle assigning or removing the manager as appropriate.
*
* @param {string} id - The unique identifier of the user to update.
* @param {CreateOrUpdateGraphUser} data - The data to update the user with. Contains attributes
* to modify, including the manager information if applicable.
* @return {Promise<GraphUser>} A promise that resolves to the updated GraphUser object.
*/
async update(id: string, data: Partial<GraphUser>): Promise<GraphUser> {
const { data: user } = await this.http.patch(`/users/${id}`, data)
return user
}

Expand Down
14 changes: 12 additions & 2 deletions src/lib/microsoft-api-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@ export abstract class MicrosoftApiBase {
protected readonly httpAgent: AxiosInstance
protected readonly tokenManager: TokenManager

constructor(config: IPartnerCenterConfig | GraphApiConfig, baseURL: string, scope: string) {
const { agent, tokenManager } = initializeHttpAndTokenManager(config, baseURL, scope)
constructor(
config: IPartnerCenterConfig | GraphApiConfig,
baseURL: string,
scope: string,
private readonly oAuthVersion?: 'v1' | 'v2',
) {
const { agent, tokenManager } = initializeHttpAndTokenManager(
config,
baseURL,
scope,
oAuthVersion,
)
this.httpAgent = agent
this.tokenManager = tokenManager
}
Expand Down
17 changes: 12 additions & 5 deletions src/lib/utils/http-token-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ export class TokenManager {
private _refreshToken = ''
private reAuthed = false
private retry = 0
private scope: string
private readonly scope: string

constructor(
private config: IPartnerCenterConfig | GraphApiConfig,
scope: string,
private readonly oAuthVersion?: 'v1' | 'v2',
) {
this.scope = scope
}
Expand Down Expand Up @@ -82,8 +83,9 @@ export class TokenManager {

try {
const tenantId = this.getTenantId()
const versionPath = this?.oAuthVersion === 'v2' ? '/v2.0' : ''
const { data }: { data: IOAuthResponse } = await axios.post(
`https://login.microsoftonline.com/${tenantId}/oauth2/token`,
`https://login.microsoftonline.com/${tenantId}/oauth2${versionPath}/token`,
authData,
{
headers: {
Expand All @@ -93,8 +95,12 @@ export class TokenManager {
)

return data
} catch (error) {
throw new Error('Failed to authenticate with the Microsoft Partner Center.')
} catch (error: any) {
const message =
error?.response?.data?.error_description ||
error?.message ||
'Failed to authenticate with the Microsoft Partner Center.'
throw new Error(message)
}
}

Expand Down Expand Up @@ -157,8 +163,9 @@ export function initializeHttpAndTokenManager(
config: IPartnerCenterConfig | GraphApiConfig,
baseURL: string,
scope: string,
oAuthVersion?: 'v1' | 'v2',
) {
const tokenManager = new TokenManager(config, scope)
const tokenManager = new TokenManager(config, scope, oAuthVersion)
const agent = axios.create({ baseURL, timeout: config.timeoutMs })

agent.interceptors.request.use(async (req) => {
Expand Down
Loading