Skip to content

Commit 60cc796

Browse files
Fix retry bug. Add test suite (#19)
* Fix retry bug. Add test suite * trigger CI
1 parent 92fbc8c commit 60cc796

File tree

9 files changed

+3652
-0
lines changed

9 files changed

+3652
-0
lines changed

.github/workflows/tests.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
16+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
17+
with:
18+
node-version: 20
19+
cache: npm
20+
cache-dependency-path: test/package-lock.json
21+
- run: npm ci
22+
working-directory: test
23+
- run: npm test
24+
working-directory: test

action.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ runs:
2626
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
2727
2828
const isRetryableError = (error) => {
29+
const msg = String(error)
2930
// Network / transient errors
3031
if (/TypeError|ECONN|EPIPE|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNABORTED|socket disconnected|Request timeout|AbortError|TimeoutError|TLS/.test(msg)) return true
3132
// HTTP 5xx or 429 (rate limit)

test/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

test/action.test.js

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
const { runAction } = require('./helpers/create-sandbox');
2+
3+
function createCoreMock() {
4+
return {
5+
getIDToken: jest.fn().mockResolvedValue('mock-id-token'),
6+
setOutput: jest.fn(),
7+
setFailed: jest.fn(),
8+
};
9+
}
10+
11+
function okResponse(token) {
12+
return { ok: true, status: 200, statusText: 'OK', body: { token } };
13+
}
14+
15+
function errorResponse(status, statusText) {
16+
return { ok: false, status, statusText };
17+
}
18+
19+
function createFetchMock(responses) {
20+
let callIndex = 0;
21+
return jest.fn(async () => {
22+
if (callIndex >= responses.length) {
23+
throw new Error(`Unexpected fetch call #${callIndex + 1}`);
24+
}
25+
const resp = responses[callIndex++];
26+
if (resp instanceof Error) throw resp;
27+
return {
28+
ok: resp.ok,
29+
status: resp.status,
30+
statusText: resp.statusText,
31+
json: async () => resp.body,
32+
};
33+
});
34+
}
35+
36+
const DEFAULT_ENV = {
37+
API_BASE_URL: 'https://api.example.com/api/v1',
38+
PIPELINES_TOKEN_PATH: 'org/repo',
39+
FALLBACK_TOKEN: 'fallback-pat',
40+
};
41+
42+
const LOGIN_URL = 'https://api.example.com/api/v1/tokens/auth/login';
43+
const TOKEN_URL = 'https://api.example.com/api/v1/tokens/pat/org/repo';
44+
45+
describe('pipelines-credentials action', () => {
46+
describe('happy path', () => {
47+
test('OIDC login and token fetch', async () => {
48+
const core = createCoreMock();
49+
const fetch = createFetchMock([
50+
okResponse('provider-token'),
51+
okResponse('pipelines-pat'),
52+
]);
53+
54+
await runAction({ coreMock: core, fetchMock: fetch, env: DEFAULT_ENV });
55+
56+
expect(fetch).toHaveBeenCalledTimes(2);
57+
expect(fetch.mock.calls[0][0]).toBe(LOGIN_URL);
58+
expect(fetch.mock.calls[1][0]).toBe(TOKEN_URL);
59+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'pipelines-pat');
60+
expect(core.setFailed).not.toHaveBeenCalled();
61+
});
62+
});
63+
64+
describe('fallback behavior', () => {
65+
test('uses FALLBACK_TOKEN when OIDC fails', async () => {
66+
const core = createCoreMock();
67+
core.getIDToken.mockRejectedValue(new Error('OIDC unavailable'));
68+
69+
await runAction({ coreMock: core, fetchMock: createFetchMock([]), env: DEFAULT_ENV });
70+
71+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
72+
expect(core.setFailed).not.toHaveBeenCalled();
73+
});
74+
75+
test('trims whitespace from FALLBACK_TOKEN', async () => {
76+
const core = createCoreMock();
77+
core.getIDToken.mockRejectedValue(new Error('OIDC unavailable'));
78+
79+
await runAction({
80+
coreMock: core,
81+
fetchMock: createFetchMock([]),
82+
env: { ...DEFAULT_ENV, FALLBACK_TOKEN: ' padded-token ' },
83+
});
84+
85+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'padded-token');
86+
});
87+
88+
test('calls setFailed when API fails and no FALLBACK_TOKEN', async () => {
89+
const core = createCoreMock();
90+
core.getIDToken.mockRejectedValue(new Error('OIDC unavailable'));
91+
92+
await runAction({
93+
coreMock: core,
94+
fetchMock: createFetchMock([]),
95+
env: { ...DEFAULT_ENV, FALLBACK_TOKEN: '' },
96+
});
97+
98+
expect(core.setFailed).toHaveBeenCalled();
99+
});
100+
101+
test('uses FALLBACK_TOKEN when login returns non-retryable error', async () => {
102+
const core = createCoreMock();
103+
const fetch = createFetchMock([
104+
errorResponse(403, 'Forbidden'),
105+
]);
106+
107+
await runAction({ coreMock: core, fetchMock: fetch, env: DEFAULT_ENV });
108+
109+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
110+
expect(fetch).toHaveBeenCalledTimes(1);
111+
});
112+
});
113+
114+
describe('retry behavior', () => {
115+
test.each([
116+
{
117+
name: 'HTTP 500',
118+
failResponse: errorResponse(500, 'Internal Server Error'),
119+
},
120+
{
121+
name: 'HTTP 502',
122+
failResponse: errorResponse(502, 'Bad Gateway'),
123+
},
124+
{
125+
name: 'HTTP 429 rate limit',
126+
failResponse: errorResponse(429, 'Too Many Requests'),
127+
},
128+
{
129+
name: 'ECONNREFUSED network error',
130+
failResponse: new Error('ECONNREFUSED'),
131+
},
132+
{
133+
name: 'ETIMEDOUT network error',
134+
failResponse: new Error('ETIMEDOUT'),
135+
},
136+
{
137+
name: 'TypeError fetch failed',
138+
failResponse: new TypeError('fetch failed'),
139+
},
140+
])('retries on $name then succeeds', async ({ failResponse }) => {
141+
const core = createCoreMock();
142+
const fetch = createFetchMock([
143+
failResponse,
144+
okResponse('provider-token'),
145+
okResponse('pipelines-pat'),
146+
]);
147+
148+
await runAction({ coreMock: core, fetchMock: fetch, env: DEFAULT_ENV });
149+
150+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'pipelines-pat');
151+
expect(fetch).toHaveBeenCalledTimes(3);
152+
});
153+
154+
test('retries multiple times before succeeding', async () => {
155+
const core = createCoreMock();
156+
const fetch = createFetchMock([
157+
errorResponse(500, 'Internal Server Error'),
158+
errorResponse(500, 'Internal Server Error'),
159+
okResponse('provider-token'),
160+
okResponse('pipelines-pat'),
161+
]);
162+
163+
await runAction({ coreMock: core, fetchMock: fetch, env: DEFAULT_ENV });
164+
165+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'pipelines-pat');
166+
expect(fetch).toHaveBeenCalledTimes(4);
167+
});
168+
});
169+
});

test/helpers/create-sandbox.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const { extractScript } = require('./extract-script');
2+
3+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
4+
let cachedFn = null;
5+
6+
function getSandboxedFn() {
7+
if (!cachedFn) {
8+
const script = extractScript();
9+
cachedFn = new AsyncFunction(
10+
'core', 'fetch', 'process', 'console', 'setTimeout', 'Math',
11+
script
12+
);
13+
}
14+
return cachedFn;
15+
}
16+
17+
async function runAction({ coreMock, fetchMock, env = {} }) {
18+
const fn = getSandboxedFn();
19+
20+
const processShim = { env: { ...env } };
21+
const consoleShim = { log: jest.fn() };
22+
const setTimeoutShim = (fn) => fn();
23+
const mathShim = { ...Math, random: () => 0.5, pow: Math.pow, round: Math.round };
24+
25+
await fn(coreMock, fetchMock, processShim, consoleShim, setTimeoutShim, mathShim);
26+
}
27+
28+
module.exports = { runAction };

test/helpers/extract-script.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const yaml = require('js-yaml');
4+
5+
function extractScript() {
6+
const actionPath = path.resolve(__dirname, '..', '..', 'action.yml');
7+
const content = fs.readFileSync(actionPath, 'utf8');
8+
const parsed = yaml.load(content);
9+
return parsed.runs.steps[0].with.script;
10+
}
11+
12+
module.exports = { extractScript };

test/jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
testMatch: ['**/*.test.js'],
3+
};

0 commit comments

Comments
 (0)