Added tests and refactored provider

Co-authored-by: blam <ben@blam.sh>
This commit is contained in:
Johan Haals
2021-01-13 16:19:01 +01:00
parent 04ad29ca4e
commit e64acdad76
2 changed files with 295 additions and 24 deletions
@@ -0,0 +1,265 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const octokit = {
apps: {
listInstallations: jest.fn(),
createInstallationAccessToken: jest.fn(),
},
};
jest.doMock('@octokit/rest', () => {
class Octokit {
constructor() {
return octokit;
}
}
return { Octokit };
});
import { GithubCredentialsProvider } from './GithubCredentialsProvider';
import { RestEndpointMethodTypes } from '@octokit/rest';
import { DateTime } from 'luxon';
const github = GithubCredentialsProvider.create({
host: 'github.com',
apps: [
{
appId: 1,
privateKey: 'privateKey',
webhookSecret: '123',
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
},
],
token: 'hardcoded_token',
});
describe('GithubCredentialsProvider tests', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('create repository specific tokens', async () => {
octokit.apps.listInstallations.mockResolvedValueOnce({
headers: {
etag: '123',
},
data: [
{
id: 1,
repository_selection: 'selected',
account: null,
},
{
id: 2,
repository_selection: 'selected',
account: {
login: 'backstage',
},
},
],
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
data: {
expires_at: DateTime.local().plus({ hour: 1 }).toString(),
token: 'secret_token',
},
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
const { token, headers } = await github.getCredentials({
url: 'https://github.com/backstage/foobar',
});
const { token: accessToken2 } = await github.getCredentials({
url: 'https://github.com/backstage/foobar',
});
expect(token).toEqual('secret_token');
expect(token).toEqual(accessToken2);
expect(headers).toEqual({ Authorization: 'Bearer secret_token' });
// fallback to the configured token if no applicatin is matching
await expect(
github.getCredentials({
url: 'https://github.com/404/foobar',
}),
).resolves.toEqual({
headers: {
Authorization: 'Bearer hardcoded_token',
},
token: 'hardcoded_token',
});
});
it('creates tokens for an organization', async () => {
octokit.apps.listInstallations.mockResolvedValueOnce({
headers: {
etag: '123',
},
data: [
{
id: 1,
repository_selection: 'all',
account: {
login: 'backstage',
},
},
],
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
data: {
expires_at: DateTime.local().plus({ hour: 1 }).toString(),
token: 'secret_token',
},
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
const { token, headers } = await github.getCredentials({
url: 'https://github.com/backstage',
});
const { token: accessToken2 } = await github.getCredentials({
url: 'https://github.com/backstage',
});
expect(headers).toEqual({ Authorization: 'Bearer secret_token' });
expect(token).toEqual('secret_token');
expect(token).toEqual(accessToken2);
});
it('should fail to issue tokens for an organization when the app is installed for a single repo', async () => {
octokit.apps.listInstallations.mockResolvedValueOnce({
headers: {
etag: '123',
},
data: [
{
id: 1,
repository_selection: 'selected',
account: {
login: 'backstage',
},
},
],
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
data: {
expires_at: DateTime.local().plus({ hour: 1 }).toString(),
token: 'secret_token',
},
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
await expect(
github.getCredentials({
url: 'https://github.com/backstage',
}),
).rejects.toThrow(
'Application must be installed for the entire organization',
);
});
it('should throw if the app is suspended', async () => {
octokit.apps.listInstallations.mockResolvedValueOnce({
headers: {
etag: '123',
},
data: [
{
id: 1,
suspended_by: {
login: 'admin',
},
repository_selection: 'all',
account: {
login: 'backstage',
},
},
],
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
await expect(
github.getCredentials({
url: 'https://github.com/backstage',
}),
).rejects.toThrow('The app for backstage is suspended');
});
it('should return the default token when the call to github return a status that is not recognized', async () => {
octokit.apps.listInstallations.mockRejectedValue({
status: 404,
message: 'NotFound',
});
await expect(
github.getCredentials({
url: 'https://github.com/backstage',
}),
).rejects.toEqual({ status: 404, message: 'NotFound' });
});
it('should return the default token if no app is configured', async () => {
const github = GithubCredentialsProvider.create({
host: 'github.com',
apps: [],
token: 'fallback_token',
});
await expect(
github.getCredentials({
url: 'https://github.com/404/foobar',
}),
).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' }));
});
it('should return the configured token if listing installations throws', async () => {
const github = GithubCredentialsProvider.create({
host: 'github.com',
apps: [
{
appId: 1,
privateKey: 'privateKey',
webhookSecret: '123',
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
},
],
token: 'hardcoded_token',
});
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
await expect(
github.getCredentials({
url: 'https://github.com/backstage',
}),
).resolves.toEqual(expect.objectContaining({ token: 'hardcoded_token' }));
});
it('should return undefined if no token or apps are configured', async () => {
const github = GithubCredentialsProvider.create({
host: 'github.com',
});
await expect(
github.getCredentials({
url: 'https://github.com/backstage',
}),
).resolves.toEqual({ headers: undefined, token: undefined });
});
});
@@ -19,7 +19,6 @@ import { GithubAppConfig, GitHubIntegrationConfig } from './config';
import { createAppAuth } from '@octokit/auth-app';
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
import { DateTime } from 'luxon';
import { InstallationAccessTokenAuthentication } from '@octokit/auth-app/dist-types/types';
type InstallationData = {
installationId: number;
@@ -72,7 +71,7 @@ class GithubAppManager {
async getInstallationCredentials(
owner: string,
repo: string,
repo?: string,
): Promise<{ accessToken: string }> {
const {
installationId,
@@ -80,31 +79,27 @@ class GithubAppManager {
repositorySelection,
} = await this.getInstallationData(owner);
if (suspended) {
throw new Error(`The app for ${owner}/${repo} is suspended`);
throw new Error(
`The app for ${[owner, repo].filter(Boolean).join('/')} is suspended`,
);
}
// App is installed in the entire org
if (repositorySelection === 'all') {
return this.cache.getOrCreateToken(owner, async () => {
const auth = createAppAuth({
...this.baseAuthConfig,
installationId,
});
const result = await auth({ type: 'installation' });
const {
token,
expiresAt,
} = result as InstallationAccessTokenAuthentication;
return { token, expiresAt: DateTime.fromISO(expiresAt) };
});
if (repositorySelection !== 'all' && !repo) {
throw new Error(
'Application must be installed for the entire organization',
);
}
// App is not installed org wide which requires a specific app token.
return this.cache.getOrCreateToken(`${owner}/${repo}`, async () => {
const cacheKey = !repo ? owner : `${owner}/${repo}`;
const repositories = repositorySelection !== 'all' ? [repo!] : undefined;
// Go and grab an access token for the app scoped to a repository if provided, if not use the organisation installation.
return this.cache.getOrCreateToken(cacheKey, async () => {
const result = await this.appClient.apps.createInstallationAccessToken({
installation_id: installationId,
repositories: [repo],
repositories,
});
return {
token: result.data.token,
expiresAt: DateTime.fromISO(result.data.expires_at),
@@ -158,7 +153,7 @@ export class GithubAppCredentialsMux {
this.apps = config.apps?.map(ac => new GithubAppManager(ac)) ?? [];
}
async getAppToken(owner: string, repo: string): Promise<string | undefined> {
async getAppToken(owner: string, repo?: string): Promise<string | undefined> {
if (this.apps.length === 0) {
return undefined;
}
@@ -171,6 +166,7 @@ export class GithubAppCredentialsMux {
),
),
);
const result = results.find(result => result.credentials);
if (result) {
return result.credentials!.accessToken;
@@ -186,6 +182,11 @@ export class GithubAppCredentialsMux {
}
}
export type GithubCredentials = {
headers?: { [name: string]: string };
token?: string;
};
// TODO: Possibly move this to a backend only package so that it's not used in the frontend by mistake
export class GithubCredentialsProvider {
static create(config: GitHubIntegrationConfig): GithubCredentialsProvider {
@@ -200,10 +201,15 @@ export class GithubCredentialsProvider {
private readonly token?: string,
) {}
async getCredentials(opts: { url: string }) {
/**
* @returns GithubCredentials.
* @param opts
*/
async getCredentials(opts: { url: string }): Promise<GithubCredentials> {
const parsed = gitUrlParse(opts.url);
const owner = parsed.owner;
const repo = parsed.name;
const owner = parsed.owner || parsed.name;
const repo = parsed.owner ? parsed.name : undefined;
let token = await this.githubAppCredentialsMux.getAppToken(owner, repo);
if (!token) {