Skip to content

Commit 7a6e7b2

Browse files
fix(azure): filter pull request queries by Renovate author (#45359)
* implement plan * add tests * fix initialization order * cast contexts to WebApi for type safety in authHandler checks * fix(azure): scope PR filtering to effective credentials (#1) * fix(azure): scope PR filtering to effective credentials * fix(azure): address authentication review feedback * fix: reorder import statements * Address review comments * fetch renovate user id in initPlatform --------- Co-authored-by: Sergei Zharinov <zharinov@users.noreply.github.com>
1 parent 210bf42 commit 7a6e7b2

5 files changed

Lines changed: 323 additions & 13 deletions

File tree

lib/modules/platform/azure/azure-got-wrapper.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import type { WebApi } from 'azure-devops-node-api';
12
import type { DeploymentFlags } from 'azure-devops-node-api/interfaces/common/VSSInterfaces.js';
23
import { buildTestJwt } from '~test/jwt-util.ts';
4+
import { logger } from '~test/util.ts';
35
import type * as _hostRules from '../../../util/host-rules.ts';
46

57
describe('modules/platform/azure/azure-got-wrapper', () => {
@@ -199,4 +201,84 @@ describe('modules/platform/azure/azure-got-wrapper', () => {
199201
expect(await azure.isHosted()).toBe(false);
200202
});
201203
});
204+
205+
describe('getAuthenticatedUserId', () => {
206+
let sdk: typeof import('azure-devops-node-api');
207+
208+
beforeEach(async () => {
209+
sdk = await vi.importActual('azure-devops-node-api');
210+
azure.setEndpoint('https://dev.azure.com/renovate8');
211+
});
212+
213+
it('returns the authenticated user ID using PAT credentials', async () => {
214+
const connect = vi
215+
.spyOn(sdk.WebApi.prototype, 'connect')
216+
.mockResolvedValue({ authenticatedUser: { id: 'user-id' } });
217+
218+
expect(await azure.getAuthenticatedUserId({ token: '123test' })).toBe(
219+
'user-id',
220+
);
221+
const context = connect.mock.contexts[0] as WebApi;
222+
expect(context.authHandler.constructor.name).toBe(
223+
'PersonalAccessTokenCredentialHandler',
224+
);
225+
});
226+
227+
it('returns the authenticated user ID using JWT credentials', async () => {
228+
const token = buildTestJwt(
229+
{ typ: 'JWT', alg: 'RS256' },
230+
{ aud: '499b84ac', sub: 'test', exp: 9999999999 },
231+
'fake-sig',
232+
);
233+
const connect = vi
234+
.spyOn(sdk.WebApi.prototype, 'connect')
235+
.mockResolvedValue({ authenticatedUser: { id: 'user-id' } });
236+
237+
expect(await azure.getAuthenticatedUserId({ token })).toBe('user-id');
238+
const context = connect.mock.contexts[0] as WebApi;
239+
expect(context.authHandler.constructor.name).toBe(
240+
'BearerCredentialHandler',
241+
);
242+
});
243+
244+
it('returns the authenticated user ID using username and password', async () => {
245+
const connect = vi
246+
.spyOn(sdk.WebApi.prototype, 'connect')
247+
.mockResolvedValue({ authenticatedUser: { id: 'user-id' } });
248+
249+
expect(
250+
await azure.getAuthenticatedUserId({
251+
username: 'user',
252+
password: 'pass',
253+
}),
254+
).toBe('user-id');
255+
const context = connect.mock.contexts[0] as WebApi;
256+
expect(context.authHandler).toMatchObject({
257+
username: 'user',
258+
password: 'pass',
259+
});
260+
});
261+
262+
it('returns undefined when the authenticated user ID is unavailable', async () => {
263+
vi.spyOn(sdk.WebApi.prototype, 'connect').mockResolvedValue({});
264+
265+
expect(
266+
await azure.getAuthenticatedUserId({ token: '123test' }),
267+
).toBeUndefined();
268+
});
269+
270+
it('returns undefined when connection data cannot be read', async () => {
271+
vi.spyOn(sdk.WebApi.prototype, 'connect').mockRejectedValue(
272+
new Error('boom'),
273+
);
274+
275+
expect(
276+
await azure.getAuthenticatedUserId({ token: '123test' }),
277+
).toBeUndefined();
278+
expect(logger.logger.debug).toHaveBeenCalledWith(
279+
{ err: new Error('boom') },
280+
'Azure: could not determine authenticated user ID',
281+
);
282+
});
283+
});
202284
});

lib/modules/platform/azure/azure-got-wrapper.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ function getAuthenticationHandler(config: HostRule): IRequestHandler {
3131
return getPersonalAccessTokenHandler(config.token!, true);
3232
}
3333

34-
export function azureObj(): azure.WebApi {
35-
const config = hostRules.find({ hostType, url: endpoint });
34+
export function azureObj(credentials?: HostRule): azure.WebApi {
35+
const config = credentials ?? hostRules.find({ hostType, url: endpoint });
3636
if (!config.token && !(config.username && config.password)) {
3737
throw new Error(`No config found for azure`);
3838
}
@@ -43,8 +43,8 @@ export function azureObj(): azure.WebApi {
4343
});
4444
}
4545

46-
export function gitApi(): Promise<IGitApi> {
47-
return azureObj().getGitApi();
46+
export function gitApi(credentials?: HostRule): Promise<IGitApi> {
47+
return azureObj(credentials).getGitApi();
4848
}
4949

5050
export function coreApi(): Promise<ICoreApi> {
@@ -59,6 +59,21 @@ export function workItemTrackingApi(): Promise<IWorkItemTrackingApi> {
5959
return azureObj().getWorkItemTrackingApi();
6060
}
6161

62+
export async function getAuthenticatedUserId(
63+
credentials: HostRule,
64+
): Promise<string | undefined> {
65+
try {
66+
const { authenticatedUser } = await azureObj(credentials).connect();
67+
if (!authenticatedUser?.id) {
68+
logger.debug('Azure: authenticated user ID is unavailable');
69+
}
70+
return authenticatedUser?.id;
71+
} catch (err) {
72+
logger.debug({ err }, 'Azure: could not determine authenticated user ID');
73+
return undefined;
74+
}
75+
}
76+
6277
/**
6378
* Whether the endpoint is Azure DevOps Services (cloud) rather than Azure
6479
* DevOps Server (on-premises). Read from the location service's

lib/modules/platform/azure/index.spec.ts

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { Mocked, MockedObject } from 'vitest';
2323
import { vi } from 'vitest';
2424
import { mockDeep } from 'vitest-mock-extended';
2525
import { partial } from '~test/util.ts';
26+
import { GlobalConfig } from '../../../config/global.ts';
2627
import {
2728
REPOSITORY_ARCHIVED,
2829
REPOSITORY_NOT_FOUND,
@@ -33,6 +34,9 @@ import type * as _hostRules from '../../../util/host-rules.ts';
3334
import type { Platform, RepoParams } from '../types.ts';
3435
import { AzurePrVote } from './types.ts';
3536

37+
vi.mock('../../../config/global.ts', async (importOriginal) =>
38+
importOriginal<typeof import('../../../config/global.ts')>(),
39+
);
3640
vi.mock('./azure-got-wrapper.ts', () => mockDeep());
3741
vi.mock('./azure-helper.ts', () => mockDeep());
3842
vi.mock('../../../util/sanitize.ts', () =>
@@ -51,6 +55,7 @@ describe('modules/platform/azure/index', () => {
5155
beforeEach(async () => {
5256
// reset module
5357
vi.resetModules();
58+
GlobalConfig.reset();
5459
hostRules = await vi.importActual('../../../util/host-rules.ts');
5560
azure = await vi.importActual('./index.ts');
5661
azureApi = await vi.importMock('./azure-got-wrapper.ts');
@@ -66,6 +71,7 @@ describe('modules/platform/azure/index', () => {
6671
hostRules.clear();
6772
hostRules.add({ token: 'token' });
6873
azureHelper.getPolicyEvaluations.mockResolvedValue([]);
74+
azureApi.getAuthenticatedUserId.mockResolvedValue('renovate-user-id');
6975
// Default to the hosted (cloud) endpoint used across these tests.
7076
azureApi.isHosted.mockResolvedValue(true);
7177
await azure.initPlatform({
@@ -156,6 +162,22 @@ describe('modules/platform/azure/index', () => {
156162
token: 'token',
157163
}),
158164
).toMatchSnapshot();
165+
expect(azureApi.getAuthenticatedUserId).toHaveBeenLastCalledWith({
166+
token: 'token',
167+
});
168+
});
169+
170+
it('should discover the authenticated user with basic credentials', async () => {
171+
await azure.initPlatform({
172+
endpoint: 'https://dev.azure.com/renovate12345',
173+
username: 'user',
174+
password: 'pass',
175+
});
176+
177+
expect(azureApi.getAuthenticatedUserId).toHaveBeenLastCalledWith({
178+
username: 'user',
179+
password: 'pass',
180+
});
159181
});
160182
});
161183

@@ -499,6 +521,81 @@ describe('modules/platform/azure/index', () => {
499521
});
500522
});
501523

524+
it('queries the exact branch when including other authors', async () => {
525+
await initRepo();
526+
const getPullRequests = vi.fn().mockResolvedValue([
527+
{
528+
pullRequestId: 1,
529+
sourceRefName: 'refs/heads/branch-a',
530+
targetRefName: 'refs/heads/branch-b',
531+
title: 'branch a pr',
532+
status: 1,
533+
},
534+
]);
535+
azureApi.gitApi.mockResolvedValueOnce(
536+
partial<IGitApi>({
537+
getPullRequests,
538+
}),
539+
);
540+
541+
const res = await azure.findPr({
542+
branchName: 'branch-a',
543+
state: 'open',
544+
targetBranch: 'branch-b',
545+
includeOtherAuthors: true,
546+
});
547+
548+
expect(res).toMatchObject({
549+
number: 1,
550+
sourceBranch: 'branch-a',
551+
state: 'open',
552+
targetBranch: 'branch-b',
553+
});
554+
expect(getPullRequests).toHaveBeenCalledExactlyOnceWith(
555+
'1',
556+
{
557+
sourceRefName: 'refs/heads/branch-a',
558+
sourceRepositoryId: '1',
559+
status: 1,
560+
targetRefName: 'refs/heads/branch-b',
561+
},
562+
'some',
563+
0,
564+
0,
565+
1,
566+
);
567+
});
568+
569+
it('returns null when no PR from another author matches', async () => {
570+
await initRepo();
571+
const getPullRequests = vi.fn().mockResolvedValue([]);
572+
azureApi.gitApi.mockResolvedValueOnce(
573+
partial<IGitApi>({
574+
getPullRequests,
575+
}),
576+
);
577+
578+
const res = await azure.findPr({
579+
branchName: 'branch-a',
580+
state: 'open',
581+
includeOtherAuthors: true,
582+
});
583+
584+
expect(res).toBeNull();
585+
expect(getPullRequests).toHaveBeenCalledExactlyOnceWith(
586+
'1',
587+
{
588+
sourceRefName: 'refs/heads/branch-a',
589+
sourceRepositoryId: '1',
590+
status: 1,
591+
},
592+
'some',
593+
0,
594+
0,
595+
1,
596+
);
597+
});
598+
502599
it('catches errors', async () => {
503600
azureApi.gitApi.mockResolvedValueOnce(
504601
partial<IGitApi>({
@@ -514,13 +611,96 @@ describe('modules/platform/azure/index', () => {
514611
});
515612

516613
describe('getPrList()', () => {
517-
it('returns empty array', async () => {
614+
it('filters PRs by repository and authenticated user', async () => {
615+
await initRepo();
616+
const getPullRequests = vi.fn().mockResolvedValue([]);
518617
azureApi.gitApi.mockResolvedValueOnce(
519618
partial<IGitApi>({
520-
getPullRequests: vi.fn().mockResolvedValue([]),
619+
getPullRequests,
620+
}),
621+
);
622+
expect(await azure.getPrList()).toEqual([]);
623+
expect(azureApi.getAuthenticatedUserId).toHaveBeenCalledExactlyOnceWith({
624+
token: 'token',
625+
});
626+
expect(azureApi.gitApi).toHaveBeenLastCalledWith();
627+
expect(getPullRequests).toHaveBeenCalledExactlyOnceWith(
628+
'1',
629+
{
630+
creatorId: 'renovate-user-id',
631+
sourceRepositoryId: '1',
632+
status: 4,
633+
},
634+
'some',
635+
0,
636+
0,
637+
100,
638+
);
639+
});
640+
641+
it('does not filter by authenticated user when ignorePrAuthor is enabled', async () => {
642+
GlobalConfig.set({ ignorePrAuthor: true });
643+
await initRepo();
644+
const getPullRequests = vi.fn().mockResolvedValue([]);
645+
azureApi.gitApi.mockResolvedValueOnce(
646+
partial<IGitApi>({
647+
getPullRequests,
648+
}),
649+
);
650+
651+
expect(await azure.getPrList()).toEqual([]);
652+
expect(getPullRequests).toHaveBeenCalledExactlyOnceWith(
653+
'1',
654+
{
655+
sourceRepositoryId: '1',
656+
status: 4,
657+
},
658+
'some',
659+
0,
660+
0,
661+
100,
662+
);
663+
});
664+
665+
it('does not filter by authenticated user when the ID is unavailable', async () => {
666+
azureApi.getAuthenticatedUserId.mockResolvedValueOnce(undefined);
667+
await azure.initPlatform({
668+
endpoint: 'https://dev.azure.com/renovate12345',
669+
token: 'token',
670+
});
671+
await initRepo();
672+
const getPullRequests = vi.fn().mockResolvedValue([]);
673+
azureApi.gitApi.mockResolvedValueOnce(
674+
partial<IGitApi>({
675+
getPullRequests,
521676
}),
522677
);
678+
523679
expect(await azure.getPrList()).toEqual([]);
680+
expect(getPullRequests).toHaveBeenCalledExactlyOnceWith(
681+
'1',
682+
{
683+
sourceRepositoryId: '1',
684+
status: 4,
685+
},
686+
'some',
687+
0,
688+
0,
689+
100,
690+
);
691+
});
692+
693+
it('reuses the cached PR list', async () => {
694+
await initRepo();
695+
const getPullRequests = vi.fn().mockResolvedValue([]);
696+
azureApi.gitApi.mockResolvedValueOnce(
697+
partial<IGitApi>({ getPullRequests }),
698+
);
699+
700+
await azure.getPrList();
701+
await azure.getPrList();
702+
703+
expect(getPullRequests).toHaveBeenCalledOnce();
524704
});
525705
});
526706

0 commit comments

Comments
 (0)