Skip to content

Commit b4b2805

Browse files
fix: improve error message when PAT is expired or unauthorized (#168)
* fix: improve error message when PAT is expired or unauthorized When the Azure DevOps PAT is expired, revoked, or lacks required scopes, the action now fails with a clear authentication error message instead of misleadingly reporting that work items do not exist. Detects auth errors by checking HTTP status codes (401/403) and error message patterns (e.g. 'Access Denied', 'Personal Access Token expired'). Fixes #167 * chore: bumping version * fix: propagate auth error from PR validation to prevent false success When the PR check path encounters a PAT auth error, return a structured object with authError flag instead of an empty array. The run() function now detects this and early-returns, preventing the success path from incorrectly updating comments or reporting work items as valid. Addresses review feedback from PR #168.
1 parent e4e1fed commit b4b2805

7 files changed

Lines changed: 193 additions & 31 deletions

File tree

__tests__/index.test.js

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe('Azure DevOps Commit Validator', () => {
128128
mockContext.payload.pull_request = { number: 42 };
129129

130130
// Default mock for validateWorkItemExists (returns true by default)
131-
mockValidateWorkItemExists.mockResolvedValue(true);
131+
mockValidateWorkItemExists.mockResolvedValue({ exists: true });
132132

133133
// Default mock for getWorkItemTitle
134134
mockGetWorkItemTitle.mockResolvedValue({ title: 'Test Work Item', type: 'User Story' });
@@ -1007,7 +1007,7 @@ describe('Azure DevOps Commit Validator', () => {
10071007
}
10081008
});
10091009

1010-
mockValidateWorkItemExists.mockResolvedValue(true);
1010+
mockValidateWorkItemExists.mockResolvedValue({ exists: true });
10111011
mockGetWorkItemTitle.mockResolvedValue({ title: 'Fix login bug', type: 'Bug' });
10121012

10131013
await run();
@@ -1795,7 +1795,7 @@ describe('Azure DevOps Commit Validator', () => {
17951795
});
17961796

17971797
// Mock work item validation to return false (work item doesn't exist)
1798-
mockValidateWorkItemExists.mockResolvedValue(false);
1798+
mockValidateWorkItemExists.mockResolvedValue({ exists: false });
17991799

18001800
await run();
18011801

@@ -1828,14 +1828,80 @@ describe('Azure DevOps Commit Validator', () => {
18281828
});
18291829

18301830
// Mock work item validation to return true (work item exists)
1831-
mockValidateWorkItemExists.mockResolvedValue(true);
1831+
mockValidateWorkItemExists.mockResolvedValue({ exists: true });
18321832

18331833
await run();
18341834

18351835
expect(mockSetFailed).not.toHaveBeenCalled();
18361836
expect(mockValidateWorkItemExists).toHaveBeenCalledWith('test-org', 'azdo-token', '12345');
18371837
});
18381838

1839+
it('should fail with auth error message when PAT is expired', async () => {
1840+
mockGetInput.mockImplementation(name => {
1841+
if (name === 'check-commits') return 'true';
1842+
if (name === 'check-pull-request') return 'false';
1843+
if (name === 'validate-work-item-exists') return 'true';
1844+
if (name === 'azure-devops-token') return 'azdo-token';
1845+
if (name === 'azure-devops-organization') return 'test-org';
1846+
if (name === 'github-token') return 'github-token';
1847+
if (name === 'comment-on-failure') return 'false';
1848+
return 'false';
1849+
});
1850+
1851+
mockOctokit.rest.pulls.listCommits.mockResolvedValue({
1852+
data: [
1853+
{
1854+
sha: 'abc123',
1855+
commit: {
1856+
message: 'feat: add feature AB#12345'
1857+
}
1858+
}
1859+
]
1860+
});
1861+
1862+
mockValidateWorkItemExists.mockResolvedValue({
1863+
exists: false,
1864+
authError: true,
1865+
errorMessage: 'Access Denied: The Personal Access Token used has expired.'
1866+
});
1867+
1868+
await run();
1869+
1870+
expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Personal Access Token (PAT) may be expired'));
1871+
});
1872+
1873+
it('should fail with auth error message when PAT is expired during PR check', async () => {
1874+
mockGetInput.mockImplementation(name => {
1875+
if (name === 'check-commits') return 'false';
1876+
if (name === 'check-pull-request') return 'true';
1877+
if (name === 'validate-work-item-exists') return 'true';
1878+
if (name === 'azure-devops-token') return 'azdo-token';
1879+
if (name === 'azure-devops-organization') return 'test-org';
1880+
if (name === 'github-token') return 'github-token';
1881+
if (name === 'comment-on-failure') return 'false';
1882+
return 'false';
1883+
});
1884+
1885+
mockOctokit.rest.pulls.get.mockResolvedValue({
1886+
data: {
1887+
title: 'feat: new feature AB#12345',
1888+
body: ''
1889+
}
1890+
});
1891+
1892+
mockValidateWorkItemExists.mockResolvedValue({
1893+
exists: false,
1894+
authError: true,
1895+
errorMessage: 'Access Denied: The Personal Access Token used has expired.'
1896+
});
1897+
1898+
await run();
1899+
1900+
expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Personal Access Token (PAT) may be expired'));
1901+
// Should NOT report invalid work items - auth error takes precedence
1902+
expect(mockSetFailed).not.toHaveBeenCalledWith(expect.stringContaining('do not exist'));
1903+
});
1904+
18391905
it('should update existing invalid work item comment to success when work items are fixed', async () => {
18401906
mockGetInput.mockImplementation(name => {
18411907
if (name === 'check-commits') return 'true';
@@ -1870,7 +1936,7 @@ describe('Azure DevOps Commit Validator', () => {
18701936
});
18711937

18721938
// Mock work item validation to return true (work item now exists)
1873-
mockValidateWorkItemExists.mockResolvedValue(true);
1939+
mockValidateWorkItemExists.mockResolvedValue({ exists: true });
18741940

18751941
await run();
18761942

@@ -1944,7 +2010,7 @@ describe('Azure DevOps Commit Validator', () => {
19442010
});
19452011

19462012
// Mock both work items as invalid
1947-
mockValidateWorkItemExists.mockResolvedValue(false);
2013+
mockValidateWorkItemExists.mockResolvedValue({ exists: false });
19482014

19492015
await run();
19502016

@@ -1978,7 +2044,7 @@ describe('Azure DevOps Commit Validator', () => {
19782044
});
19792045

19802046
// Mock both work items as invalid
1981-
mockValidateWorkItemExists.mockResolvedValue(false);
2047+
mockValidateWorkItemExists.mockResolvedValue({ exists: false });
19822048

19832049
await run();
19842050

@@ -2024,7 +2090,7 @@ describe('Azure DevOps Commit Validator', () => {
20242090
});
20252091

20262092
// Mock both work items as invalid
2027-
mockValidateWorkItemExists.mockResolvedValue(false);
2093+
mockValidateWorkItemExists.mockResolvedValue({ exists: false });
20282094

20292095
await run();
20302096

@@ -2076,7 +2142,7 @@ describe('Azure DevOps Commit Validator', () => {
20762142
});
20772143

20782144
// Mock both work items as invalid
2079-
mockValidateWorkItemExists.mockResolvedValue(false);
2145+
mockValidateWorkItemExists.mockResolvedValue({ exists: false });
20802146

20812147
await run();
20822148

@@ -2133,7 +2199,7 @@ describe('Azure DevOps Commit Validator', () => {
21332199
});
21342200

21352201
// Mock both work items as invalid
2136-
mockValidateWorkItemExists.mockResolvedValue(false);
2202+
mockValidateWorkItemExists.mockResolvedValue({ exists: false });
21372203

21382204
await run();
21392205

__tests__/link-work-item.test.js

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ describe('Azure DevOps Work Item Linker', () => {
244244
});
245245

246246
describe('validateWorkItemExists', () => {
247-
it('should return true when work item exists', async () => {
247+
it('should return { exists: true } when work item exists', async () => {
248248
// Mock getWorkItem to return a valid work item
249249
mockGetWorkItem.mockResolvedValue({
250250
id: 12345,
@@ -256,11 +256,11 @@ describe('Azure DevOps Work Item Linker', () => {
256256
const { validateWorkItemExists } = await import('../src/link-work-item.js');
257257
const result = await validateWorkItemExists('test-org', 'azdo-token', '12345');
258258

259-
expect(result).toBe(true);
259+
expect(result).toEqual({ exists: true });
260260
expect(mockGetWorkItem).toHaveBeenCalledWith(12345);
261261
});
262262

263-
it('should return false when work item does not exist (404)', async () => {
263+
it('should return { exists: false } when work item does not exist (404)', async () => {
264264
// Mock getWorkItem to throw a 404 error
265265
const error = new Error('Work item not found');
266266
error.statusCode = 404;
@@ -269,20 +269,56 @@ describe('Azure DevOps Work Item Linker', () => {
269269
const { validateWorkItemExists } = await import('../src/link-work-item.js');
270270
const result = await validateWorkItemExists('test-org', 'azdo-token', '99999');
271271

272-
expect(result).toBe(false);
272+
expect(result).toEqual({ exists: false });
273273
expect(mockGetWorkItem).toHaveBeenCalledWith(99999);
274274
});
275275

276-
it('should return false when work item API call fails', async () => {
276+
it('should return { exists: false } when work item API call fails', async () => {
277277
// Mock getWorkItem to throw a network error
278278
mockGetWorkItem.mockRejectedValue(new Error('Network error'));
279279

280280
const { validateWorkItemExists } = await import('../src/link-work-item.js');
281281
const result = await validateWorkItemExists('test-org', 'azdo-token', '12345');
282282

283-
expect(result).toBe(false);
283+
expect(result).toEqual({ exists: false });
284284
expect(mockGetWorkItem).toHaveBeenCalledWith(12345);
285285
});
286+
287+
it('should return authError when PAT has expired (status 401)', async () => {
288+
const error = new Error('Unauthorized');
289+
error.statusCode = 401;
290+
mockGetWorkItem.mockRejectedValue(error);
291+
292+
const { validateWorkItemExists } = await import('../src/link-work-item.js');
293+
const result = await validateWorkItemExists('test-org', 'azdo-token', '12345');
294+
295+
expect(result).toEqual({ exists: false, authError: true, errorMessage: 'Unauthorized' });
296+
});
297+
298+
it('should return authError when PAT has expired (status 403)', async () => {
299+
const error = new Error('Forbidden');
300+
error.statusCode = 403;
301+
mockGetWorkItem.mockRejectedValue(error);
302+
303+
const { validateWorkItemExists } = await import('../src/link-work-item.js');
304+
const result = await validateWorkItemExists('test-org', 'azdo-token', '12345');
305+
306+
expect(result).toEqual({ exists: false, authError: true, errorMessage: 'Forbidden' });
307+
});
308+
309+
it('should return authError when error message indicates expired PAT', async () => {
310+
const error = new Error('Access Denied: The Personal Access Token used has expired.');
311+
mockGetWorkItem.mockRejectedValue(error);
312+
313+
const { validateWorkItemExists } = await import('../src/link-work-item.js');
314+
const result = await validateWorkItemExists('test-org', 'azdo-token', '12345');
315+
316+
expect(result).toEqual({
317+
exists: false,
318+
authError: true,
319+
errorMessage: 'Access Denied: The Personal Access Token used has expired.'
320+
});
321+
});
286322
});
287323

288324
describe('getWorkItemTitle', () => {

badges/coverage.svg

Lines changed: 1 addition & 1 deletion
Loading

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "azure-devops-work-item-link-enforcer-and-linker",
3-
"version": "4.0.0",
3+
"version": "4.0.1",
44
"private": true,
55
"type": "module",
66
"description": "GitHub Action to enforce that each commit in a pull request be linked to an Azure DevOps work item and automatically link the pull request to each work item ",

src/index.js

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,17 @@ export async function run() {
109109
);
110110
workItemToCommitMap = commitResults.workItemToCommitMap;
111111
invalidWorkItemsFromCommits = commitResults.invalidWorkItems;
112+
113+
// If auth error was detected, stop processing - setFailed was already called
114+
if (commitResults.authError) {
115+
return;
116+
}
112117
}
113118

114119
// Check pull request
115120
let invalidWorkItemsFromPR = [];
116121
if (checkPullRequest) {
117-
invalidWorkItemsFromPR = await checkPullRequestForWorkItems(
122+
const prResult = await checkPullRequestForWorkItems(
118123
octokit,
119124
context,
120125
pullNumber,
@@ -126,6 +131,13 @@ export async function run() {
126131
addWorkItemTable,
127132
pullRequestCheckScope
128133
);
134+
135+
// If auth error was detected, stop processing - setFailed was already called
136+
if (prResult && prResult.authError) {
137+
return;
138+
}
139+
140+
invalidWorkItemsFromPR = Array.isArray(prResult) ? prResult : [];
129141
}
130142

131143
// Combine all invalid work items and create ONE comment
@@ -356,9 +368,15 @@ async function checkCommitsForWorkItems(
356368

357369
for (const match of uniqueWorkItems) {
358370
const workItemId = match.substring(3); // Remove "AB#" prefix
359-
const exists = await validateWorkItemExists(azureDevopsOrganization, azureDevopsToken, workItemId);
371+
const result = await validateWorkItemExists(azureDevopsOrganization, azureDevopsToken, workItemId);
372+
373+
if (result.authError) {
374+
const authMessage = `Azure DevOps authentication failed while validating work items. Your Personal Access Token (PAT) may be expired, revoked, or lack the required scopes. Details: ${result.errorMessage}`;
375+
core.setFailed(authMessage);
376+
return { workItemToCommitMap, invalidWorkItems: [], hasCommitFailures: false, authError: true };
377+
}
360378

361-
if (!exists) {
379+
if (!result.exists) {
362380
invalidWorkItems.push(workItemId);
363381
}
364382
}
@@ -548,9 +566,15 @@ async function checkPullRequestForWorkItems(
548566
workItemToCommitMap.set(workItemNumber, null); // null indicates it's from PR title/body
549567
}
550568

551-
const exists = await validateWorkItemExists(azureDevopsOrganization, azureDevopsToken, workItemNumber);
569+
const result = await validateWorkItemExists(azureDevopsOrganization, azureDevopsToken, workItemNumber);
570+
571+
if (result.authError) {
572+
const authMessage = `Azure DevOps authentication failed while validating work items. Your Personal Access Token (PAT) may be expired, revoked, or lack the required scopes. Details: ${result.errorMessage}`;
573+
core.setFailed(authMessage);
574+
return { authError: true };
575+
}
552576

553-
if (!exists) {
577+
if (!result.exists) {
554578
invalidWorkItems.push(workItemNumber);
555579
}
556580
}

0 commit comments

Comments
 (0)