diff --git a/azure-pipelines.release.web-components.yml b/azure-pipelines.release.web-components.yml index 7e7fd8d0a0fae..8c6e01b5c9d91 100644 --- a/azure-pipelines.release.web-components.yml +++ b/azure-pipelines.release.web-components.yml @@ -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 diff --git a/scripts/beachball/src/validateReleaseCredentials.test.ts b/scripts/beachball/src/validateReleaseCredentials.test.ts new file mode 100644 index 0000000000000..78cc3cc9091a9 --- /dev/null +++ b/scripts/beachball/src/validateReleaseCredentials.test.ts @@ -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; + + 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\.$/); + }); +}); diff --git a/scripts/beachball/src/validateReleaseCredentials.ts b/scripts/beachball/src/validateReleaseCredentials.ts new file mode 100644 index 0000000000000..2ed8f8bd47732 --- /dev/null +++ b/scripts/beachball/src/validateReleaseCredentials.ts @@ -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; +}; + +export type FetchLike = ( + url: string, + init: { + method: 'GET'; + headers: Record; + redirect: 'error'; + signal: AbortSignal; + }, +) => Promise; + +export type ValidateReleaseCredentialsOptions = { + env?: NodeJS.ProcessEnv; + fetchImpl?: FetchLike; +}; + +type CredentialRequest = { + credentialName: 'NPM_TOKEN' | 'GITHUB_PAT'; + url: string; + token: string; + headers: Record; + accessDeniedStatuses: number[]; +}; + +function hasNonEmptyStringProperty(value: unknown, propertyName: string): boolean { + return ( + typeof value === 'object' && + value !== null && + propertyName in value && + typeof (value as Record)[propertyName] === 'string' && + ((value as Record)[propertyName] as string).length > 0 + ); +} + +function hasStringPropertyValue(value: unknown, propertyName: string, expectedValue: string): boolean { + return ( + hasNonEmptyStringProperty(value, propertyName) && (value as Record)[propertyName] === expectedValue + ); +} + +async function requestCredentialValidation(request: CredentialRequest, fetchImpl: FetchLike): Promise { + 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 { + 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 { + 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 { + 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 { + 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(); +}