Skip to content
Closed
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 src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ type RuntimeEnvKey =
// would claim it as an unknown CLI option and strict-reject the run.
| 'WIZARD_CI_FLAG_OVERRIDES'
| 'WIZARD_CI_EXCLUDE_TASKS'
// CI identity opt-in and the runner's identity-request pair (lib/ci-identity.ts).
| 'WIZARD_CI_IDENTITY'
| 'ACTIONS_ID_TOKEN_REQUEST_URL'
| 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'
// Wizard CLI configuration (yargs POSTHOG_WIZARD_ prefix)
| 'POSTHOG_WIZARD_BENCHMARK_CONFIG'
| 'POSTHOG_WIZARD_BENCHMARK_FILE'
Expand Down
167 changes: 167 additions & 0 deletions src/lib/__tests__/ci-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import {
CiIdentityUnavailable,
captureCiIdentityRequest,
ciIdentityMode,
requestCiIdentityToken,
resetCiIdentity,
usesCiIdentity,
} from '@lib/ci-identity';

const REQUEST_URL =
'https://run-actions-1-azure-eastus.actions.githubusercontent.com/abc/idtoken?api-version=2.0';

describe('CI identity', () => {
const fetchMock = vi.fn();
const issued = (value: unknown = 'header.payload.signature') => ({
ok: true,
status: 200,
json: () => Promise.resolve({ value }),
});

beforeEach(() => {
resetCiIdentity();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
vi.stubEnv('WIZARD_CI_IDENTITY', 'github-actions');
vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_URL', REQUEST_URL);
vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', 'runner-request-token');
});

afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

it('is off unless the run opts in with the exact value', () => {
vi.stubEnv('WIZARD_CI_IDENTITY', 'true');
expect(usesCiIdentity()).toBe(false);
});

it('reports an unknown opt-in value as unknown, and an empty one as off', () => {
vi.stubEnv('WIZARD_CI_IDENTITY', 'github');
expect(ciIdentityMode()).toBe('unknown');
vi.stubEnv('WIZARD_CI_IDENTITY', '');
expect(ciIdentityMode()).toBe('off');
});

it('takes the request pair out of the environment when the module loads', async () => {
vi.resetModules();
await import('@lib/ci-identity');
expect(process.env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined();
expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined();
});

it('takes the request pair out of the environment at capture', () => {
captureCiIdentityRequest();
expect(process.env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined();
expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined();
});

it('leaves the environment alone when the run does not opt in', () => {
vi.stubEnv('WIZARD_CI_IDENTITY', '');
captureCiIdentityRequest();
expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe(
'runner-request-token',
);
});

it('asks GitHub for the mint audience with the request token', async () => {
fetchMock.mockResolvedValue(issued());
await expect(requestCiIdentityToken()).resolves.toBe(
'header.payload.signature',
);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe(`${REQUEST_URL}&audience=posthog-wizard-ci`);
expect(init).toMatchObject({
headers: { Authorization: 'bearer runner-request-token' },
redirect: 'error',
});
});

it('asks again for every mint, after the pair has left the environment', async () => {
fetchMock.mockResolvedValue(issued());
await requestCiIdentityToken();
await requestCiIdentityToken();
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it.each([
['another host', 'https://evil.example/idtoken?api-version=2.0'],
[
'a lookalike host',
'https://actions.githubusercontent.com.evil.example/idtoken',
],
[
'a lookalike host with a label before GitHub',
'https://run.actions.githubusercontent.com.evil.example/idtoken',
],
[
'plain http',
'http://run-actions-1-azure-eastus.actions.githubusercontent.com/idtoken',
],
['something that is not a URL', 'not a url'],
])('never sends the request token to %s', async (_, url) => {
vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_URL', url);
await expect(requestCiIdentityToken()).rejects.toBeInstanceOf(
CiIdentityUnavailable,
);
expect(fetchMock).not.toHaveBeenCalled();
});

it('fails when the job was not granted id-token: write', async () => {
vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', '');
await expect(requestCiIdentityToken()).rejects.toBeInstanceOf(
CiIdentityUnavailable,
);
expect(fetchMock).not.toHaveBeenCalled();
});

it.each([
['a refusal', { ok: false, status: 403, json: () => Promise.resolve({}) }],
['a response with no token', issued(null)],
[
'a body that is not JSON',
{
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('bad')),
},
],
])('fails on %s', async (_, response) => {
fetchMock.mockResolvedValue(response);
await expect(requestCiIdentityToken()).rejects.toBeInstanceOf(
CiIdentityUnavailable,
);
});

it('bounds the identity request with a ten second timeout', async () => {
const timeout = vi.spyOn(AbortSignal, 'timeout');
try {
fetchMock.mockResolvedValue(issued());
await requestCiIdentityToken();
expect(timeout).toHaveBeenCalledWith(10_000);
expect(fetchMock.mock.calls[0][1]).toMatchObject({
signal: timeout.mock.results[0].value,
});
} finally {
timeout.mockRestore();
}
});

it('fails when GitHub does not answer', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'));
await expect(requestCiIdentityToken()).rejects.toBeInstanceOf(
CiIdentityUnavailable,
);
});

it('never puts the request token in an error', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({}),
});
const error = await requestCiIdentityToken().catch((e: unknown) => e);
expect((error as Error).message).not.toContain('runner-request-token');
});
});
Loading
Loading