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
8 changes: 8 additions & 0 deletions azure-pipelines.release.web-components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ extends:
filePath: yarn-ci.sh
displayName: yarn

- script: |
node -r ./scripts/ts-node/src/register ./scripts/beachball/src/validateReleaseCredentials.ts
env:
GITHUB_PAT: $(githubPAT)
NPM_TOKEN: $(npmToken)
displayName: Validate release credentials
condition: not(${{ parameters.dryRun }})

- script: |
yarn nx run-many -t format:check lint test build -p tag:web-components --exclude vr-tests-web-components --nxBail
displayName: Build, Test, Lint
Expand Down
132 changes: 132 additions & 0 deletions scripts/beachball/src/validateReleaseCredentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { FetchLike, validateReleaseCredentials } from './validateReleaseCredentials';

const npmToken = 'npm-test-token';
const githubToken = 'github-test-token';

function createResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: jest.fn().mockResolvedValue(body),
};
}

describe('validateReleaseCredentials', () => {
let fetchMock: jest.MockedFunction<FetchLike>;

beforeEach(() => {
fetchMock = jest.fn();
});

it('fails before making requests when required credentials are missing', async () => {
await expect(validateReleaseCredentials({ env: {}, fetchImpl: fetchMock })).rejects.toThrow(
'Missing required release credentials: NPM_TOKEN, GITHUB_PAT.',
);
expect(fetchMock).not.toHaveBeenCalled();
});

it('validates npm authentication, GitHub authentication, and repository access', async () => {
fetchMock
.mockResolvedValueOnce(createResponse({ username: 'npm-user' }))
.mockResolvedValueOnce(createResponse({ login: 'github-user' }))
.mockResolvedValueOnce(createResponse({ ['full_name']: 'microsoft/fluentui' }));

await validateReleaseCredentials({
env: { NPM_TOKEN: npmToken, GITHUB_PAT: githubToken },
fetchImpl: fetchMock,
});

expect(fetchMock).toHaveBeenNthCalledWith(
1,
'https://registry.npmjs.org/-/whoami',
expect.objectContaining({
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${npmToken}`,
},
redirect: 'error',
signal: expect.any(AbortSignal),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://api.github.com/user',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Bearer ${githubToken}`,
'User-Agent': 'fluentui-release-credential-validator',
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'https://api.github.com/repos/microsoft/fluentui',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Bearer ${githubToken}`,
}),
}),
);
});

it('fails explicitly when npm rejects the token without exposing it', async () => {
fetchMock.mockResolvedValueOnce(createResponse({}, 401));

await expect(
validateReleaseCredentials({
env: { NPM_TOKEN: npmToken, GITHUB_PAT: githubToken },
fetchImpl: fetchMock,
}),
).rejects.toThrow(/^NPM_TOKEN is invalid or does not have required access \(HTTP 401\)\.$/);
});

it('fails explicitly when GitHub rejects the token without exposing it', async () => {
fetchMock
.mockResolvedValueOnce(createResponse({ username: 'npm-user' }))
.mockResolvedValueOnce(createResponse({}, 403));

await expect(
validateReleaseCredentials({
env: { NPM_TOKEN: npmToken, GITHUB_PAT: githubToken },
fetchImpl: fetchMock,
}),
).rejects.toThrow(/^GITHUB_PAT is invalid or does not have required access \(HTTP 403\)\.$/);
});

it('fails when the GitHub token cannot access the Fluent UI repository', async () => {
fetchMock
.mockResolvedValueOnce(createResponse({ username: 'npm-user' }))
.mockResolvedValueOnce(createResponse({ login: 'github-user' }))
.mockResolvedValueOnce(createResponse({}, 404));

await expect(
validateReleaseCredentials({
env: { NPM_TOKEN: npmToken, GITHUB_PAT: githubToken },
fetchImpl: fetchMock,
}),
).rejects.toThrow(/^GITHUB_PAT is invalid or does not have required access \(HTTP 404\)\.$/);
});

it('fails closed on other HTTP errors', async () => {
fetchMock.mockResolvedValueOnce(createResponse({}, 503));

await expect(
validateReleaseCredentials({
env: { NPM_TOKEN: npmToken, GITHUB_PAT: githubToken },
fetchImpl: fetchMock,
}),
).rejects.toThrow(/^Unable to validate NPM_TOKEN: endpoint returned HTTP 503\.$/);
});

it('sanitizes network errors that could contain credential values', async () => {
fetchMock.mockRejectedValueOnce(new Error(`request failed for ${npmToken}`));

await expect(
validateReleaseCredentials({
env: { NPM_TOKEN: npmToken, GITHUB_PAT: githubToken },
fetchImpl: fetchMock,
}),
).rejects.toThrow(/^Unable to validate NPM_TOKEN: request failed or timed out\.$/);
});
});
168 changes: 168 additions & 0 deletions scripts/beachball/src/validateReleaseCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
const npmWhoAmIEndpoint = 'https://registry.npmjs.org/-/whoami';
const githubUserEndpoint = 'https://api.github.com/user';
const githubRepositoryEndpoint = 'https://api.github.com/repos/microsoft/fluentui';
const requestTimeoutMs = 15_000;

type FetchResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};

export type FetchLike = (
url: string,
init: {
method: 'GET';
headers: Record<string, string>;
redirect: 'error';
signal: AbortSignal;
},
) => Promise<FetchResponse>;

export type ValidateReleaseCredentialsOptions = {
env?: NodeJS.ProcessEnv;
fetchImpl?: FetchLike;
};

type CredentialRequest = {
credentialName: 'NPM_TOKEN' | 'GITHUB_PAT';
url: string;
token: string;
headers: Record<string, string>;
accessDeniedStatuses: number[];
};

function hasNonEmptyStringProperty(value: unknown, propertyName: string): boolean {
return (
typeof value === 'object' &&
value !== null &&
propertyName in value &&
typeof (value as Record<string, unknown>)[propertyName] === 'string' &&
((value as Record<string, string>)[propertyName] as string).length > 0
);
}

function hasStringPropertyValue(value: unknown, propertyName: string, expectedValue: string): boolean {
return (
hasNonEmptyStringProperty(value, propertyName) && (value as Record<string, unknown>)[propertyName] === expectedValue
);
}

async function requestCredentialValidation(request: CredentialRequest, fetchImpl: FetchLike): Promise<unknown> {
const { credentialName, url, token, headers, accessDeniedStatuses } = request;
let response: FetchResponse;

try {
response = await fetchImpl(url, {
method: 'GET',
headers: {
...headers,
Authorization: `Bearer ${token}`,
},
redirect: 'error',
signal: AbortSignal.timeout(requestTimeoutMs),
});
} catch {
throw new Error(`Unable to validate ${credentialName}: request failed or timed out.`);
}

if (accessDeniedStatuses.includes(response.status)) {
throw new Error(`${credentialName} is invalid or does not have required access (HTTP ${response.status}).`);
}

if (!response.ok) {
throw new Error(`Unable to validate ${credentialName}: endpoint returned HTTP ${response.status}.`);
}

try {
return await response.json();
} catch {
throw new Error(`Unable to validate ${credentialName}: endpoint returned an unexpected response.`);
}
}

async function validateNpmToken(token: string, fetchImpl: FetchLike): Promise<void> {
const response = await requestCredentialValidation(
{
credentialName: 'NPM_TOKEN',
url: npmWhoAmIEndpoint,
token,
headers: {
Accept: 'application/json',
},
accessDeniedStatuses: [401, 403],
},
fetchImpl,
);

if (!hasNonEmptyStringProperty(response, 'username')) {
throw new Error('Unable to validate NPM_TOKEN: endpoint returned an unexpected response.');
}
}

async function validateGitHubToken(token: string, fetchImpl: FetchLike): Promise<void> {
const headers = {
Accept: 'application/vnd.github+json',
'User-Agent': 'fluentui-release-credential-validator',
'X-GitHub-Api-Version': '2022-11-28',
};

const userResponse = await requestCredentialValidation(
{
credentialName: 'GITHUB_PAT',
url: githubUserEndpoint,
token,
headers,
accessDeniedStatuses: [401, 403],
},
fetchImpl,
);

if (!hasNonEmptyStringProperty(userResponse, 'login')) {
throw new Error('Unable to validate GITHUB_PAT: endpoint returned an unexpected response.');
}

const repositoryResponse = await requestCredentialValidation(
{
credentialName: 'GITHUB_PAT',
url: githubRepositoryEndpoint,
token,
headers,
accessDeniedStatuses: [401, 403, 404],
},
fetchImpl,
);

if (!hasStringPropertyValue(repositoryResponse, 'full_name', 'microsoft/fluentui')) {
throw new Error('Unable to validate GITHUB_PAT: endpoint returned an unexpected response.');
}
}

export async function validateReleaseCredentials(options: ValidateReleaseCredentialsOptions = {}): Promise<void> {
const env = options.env ?? process.env;
const missingCredentials = (['NPM_TOKEN', 'GITHUB_PAT'] as const).filter(
credentialName => !env[credentialName]?.trim(),
);

if (missingCredentials.length > 0) {
throw new Error(`Missing required release credentials: ${missingCredentials.join(', ')}.`);
}

const fetchImpl = options.fetchImpl ?? (globalThis.fetch as FetchLike);
await validateNpmToken(env.NPM_TOKEN!.trim(), fetchImpl);
await validateGitHubToken(env.GITHUB_PAT!.trim(), fetchImpl);
}

async function main(): Promise<void> {
try {
await validateReleaseCredentials();
console.log('Release credentials validated successfully.');
} catch (error) {
console.error(error instanceof Error ? error.message : 'Release credential validation failed.');
process.exitCode = 1;
}
}

if (require.main === module) {
main();
}
Loading