Merge branch 'master' into add-access-support
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"git-url-parse": "^11.1.2",
|
||||
"globby": "^11.0.0",
|
||||
"helmet": "^4.0.0",
|
||||
"jsonschema": "^1.2.6",
|
||||
"morgan": "^1.10.0",
|
||||
"nodegit": "0.26.5",
|
||||
"uuid": "^8.2.0",
|
||||
|
||||
@@ -54,50 +54,83 @@ const {
|
||||
};
|
||||
|
||||
describe('GitHub Publisher', () => {
|
||||
const publisher = new GithubPublisher({ client: new Octokit() });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInGithub', () => {
|
||||
it('should use octokit to create a repo in an organisation if the organisation property is set', async () => {
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
describe('with public repo visibility', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'public',
|
||||
});
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam/team',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
describe('publish: createRemoteInGithub', () => {
|
||||
it('should use octokit to create a repo in an organisation if the organisation property is set', async () => {
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam/team',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
name: 'test',
|
||||
private: false,
|
||||
visibility: 'public',
|
||||
});
|
||||
expect(
|
||||
mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg,
|
||||
).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
team_slug: 'team',
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
permission: 'admin',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
name: 'test',
|
||||
});
|
||||
expect(
|
||||
mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg,
|
||||
).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
team_slug: 'team',
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
permission: 'admin',
|
||||
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'User',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
private: false,
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
|
||||
it('should invite other user in the authed user', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
@@ -113,7 +146,7 @@ describe('GitHub Publisher', () => {
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam',
|
||||
access: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
@@ -122,151 +155,194 @@ describe('GitHub Publisher', () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
private: false,
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
username: 'bob',
|
||||
permission: 'admin',
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should invite other user in the authed user', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'User',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
describe('publish: createGitDirectory', () => {
|
||||
const values = {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
owner: 'lols',
|
||||
access: 'lols',
|
||||
};
|
||||
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
username: 'bob',
|
||||
permission: 'admin',
|
||||
const mockDir = '/tmp/test/dir';
|
||||
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
it('should call init on the repo with the directory', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Repository.init).toHaveBeenCalledWith(mockDir, 0);
|
||||
});
|
||||
|
||||
it('should call refresh index on the index and write the new files', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockRepo.refreshIndex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call add all files and write', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockIndex.addAll).toHaveBeenCalled();
|
||||
expect(mockIndex.write).toHaveBeenCalled();
|
||||
expect(mockIndex.writeTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a commit with on head with the right name and commiter', async () => {
|
||||
const mockSignature = { mockSignature: 'bloblly' };
|
||||
Signature.now.mockReturnValue(mockSignature);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Signature.now).toHaveBeenCalledTimes(2);
|
||||
expect(Signature.now).toHaveBeenCalledWith(
|
||||
'Scaffolder',
|
||||
'scaffolder@backstage.io',
|
||||
);
|
||||
|
||||
expect(mockRepo.createCommit).toHaveBeenCalledWith(
|
||||
'HEAD',
|
||||
mockSignature,
|
||||
mockSignature,
|
||||
'initial commit',
|
||||
'mockoid',
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a remote with the repo and remote', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Remote.create).toHaveBeenCalledWith(
|
||||
mockRepo,
|
||||
'origin',
|
||||
'mockclone',
|
||||
);
|
||||
});
|
||||
|
||||
it('shoud push to the remote repo', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
const [remotes, { callbacks }] = mockRemote.push.mock
|
||||
.calls[0] as NodeGit.PushOptions[];
|
||||
|
||||
expect(remotes).toEqual(['refs/heads/master:refs/heads/master']);
|
||||
|
||||
callbacks?.credentials?.();
|
||||
|
||||
expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
'x-oauth-basic',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish: createGitDirectory', () => {
|
||||
const values = {
|
||||
storePath: 'blam/test',
|
||||
owner: 'lols',
|
||||
access: 'lols',
|
||||
};
|
||||
|
||||
const mockDir = '/tmp/test/dir';
|
||||
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
it('should call init on the repo with the directory', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Repository.init).toHaveBeenCalledWith(mockDir, 0);
|
||||
describe('with internal repo visibility', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'internal',
|
||||
});
|
||||
|
||||
it('should call refresh index on the index and write the new files', async () => {
|
||||
it('creates a private repository in the organization with visibility set to internal', async () => {
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
values: {
|
||||
isOrg: true,
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(mockRepo.refreshIndex).toHaveBeenCalled();
|
||||
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
name: 'test',
|
||||
private: true,
|
||||
visibility: 'internal',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private visibility in a user account', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'private',
|
||||
});
|
||||
|
||||
it('should call add all files and write', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockIndex.addAll).toHaveBeenCalled();
|
||||
expect(mockIndex.write).toHaveBeenCalled();
|
||||
expect(mockIndex.writeTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a commit with on head with the right name and commiter', async () => {
|
||||
const mockSignature = { mockSignature: 'bloblly' };
|
||||
Signature.now.mockReturnValue(mockSignature);
|
||||
it('creates a private repository', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'User',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(Signature.now).toHaveBeenCalledTimes(2);
|
||||
expect(Signature.now).toHaveBeenCalledWith(
|
||||
'Scaffolder',
|
||||
'scaffolder@backstage.io',
|
||||
);
|
||||
|
||||
expect(mockRepo.createCommit).toHaveBeenCalledWith(
|
||||
'HEAD',
|
||||
mockSignature,
|
||||
mockSignature,
|
||||
'initial commit',
|
||||
'mockoid',
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a remote with the repo and remote', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
private: true,
|
||||
});
|
||||
|
||||
expect(Remote.create).toHaveBeenCalledWith(
|
||||
mockRepo,
|
||||
'origin',
|
||||
'mockclone',
|
||||
);
|
||||
});
|
||||
|
||||
it('shoud push to the remote repo', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
const [remotes, { callbacks }] = mockRemote.push.mock
|
||||
.calls[0] as NodeGit.PushOptions[];
|
||||
|
||||
expect(remotes).toEqual(['refs/heads/master:refs/heads/master']);
|
||||
|
||||
process.env.GITHUb_ACCESS_TOKEN = 'blob';
|
||||
|
||||
callbacks?.credentials?.();
|
||||
|
||||
expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith(
|
||||
process.env.GITHUB_ACCESS_TOKEN,
|
||||
'x-oauth-basic',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,10 +21,27 @@ import { JsonValue } from '@backstage/config';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { Repository, Remote, Signature, Cred } from 'nodegit';
|
||||
|
||||
export type RepoVisilityOptions = 'private' | 'internal' | 'public';
|
||||
|
||||
interface GithubPublisherParams {
|
||||
client: Octokit;
|
||||
token: string;
|
||||
repoVisibility: RepoVisilityOptions;
|
||||
}
|
||||
|
||||
export class GithubPublisher implements PublisherBase {
|
||||
private client: Octokit;
|
||||
constructor({ client }: { client: Octokit }) {
|
||||
private token: string;
|
||||
private repoVisibility: RepoVisilityOptions;
|
||||
|
||||
constructor({
|
||||
client,
|
||||
token,
|
||||
repoVisibility = 'public',
|
||||
}: GithubPublisherParams) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
this.repoVisibility = repoVisibility;
|
||||
}
|
||||
|
||||
async publish({
|
||||
@@ -50,8 +67,16 @@ export class GithubPublisher implements PublisherBase {
|
||||
|
||||
const repoCreationPromise =
|
||||
user.data.type === 'Organization'
|
||||
? this.client.repos.createInOrg({ name, org: owner })
|
||||
: this.client.repos.createForAuthenticatedUser({ name });
|
||||
? this.client.repos.createInOrg({
|
||||
name,
|
||||
org: owner,
|
||||
private: this.repoVisibility !== 'public',
|
||||
visibility: this.repoVisibility,
|
||||
})
|
||||
: this.client.repos.createForAuthenticatedUser({
|
||||
name,
|
||||
private: this.repoVisibility === 'private',
|
||||
});
|
||||
|
||||
const { data } = await repoCreationPromise;
|
||||
|
||||
@@ -96,10 +121,7 @@ export class GithubPublisher implements PublisherBase {
|
||||
await remoteRepo.push(['refs/heads/master:refs/heads/master'], {
|
||||
callbacks: {
|
||||
credentials: () => {
|
||||
return Cred.userpassPlaintextNew(
|
||||
process.env.GITHUB_ACCESS_TOKEN as string,
|
||||
'x-oauth-basic',
|
||||
);
|
||||
return Cred.userpassPlaintextNew(this.token, 'x-oauth-basic');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createRouter } from './router';
|
||||
import { Templaters, Preparers, PublisherBase } from '../scaffolder';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
jest.mock('dockerode');
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
const publisher: jest.Mocked<PublisherBase> = { publish: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
preparers: new Preparers(),
|
||||
templaters: new Templaters(),
|
||||
publisher: publisher,
|
||||
dockerClient: new Docker(),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /v1/jobs', () => {
|
||||
const template = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
description: 'Create a new CRA website project',
|
||||
name: 'create-react-app-template',
|
||||
tags: ['experimental', 'react', 'cra'],
|
||||
title: 'Create React App Template',
|
||||
},
|
||||
spec: {
|
||||
owner: 'web@example.com',
|
||||
path: '.',
|
||||
schema: {
|
||||
properties: {
|
||||
component_id: {
|
||||
description: 'Unique name of the component',
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
},
|
||||
description: {
|
||||
description: 'Description of the component',
|
||||
title: 'Description',
|
||||
type: 'string',
|
||||
},
|
||||
use_typescript: {
|
||||
default: true,
|
||||
description: 'Include typescript',
|
||||
title: 'Use Typescript',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['component_id', 'use_typescript'],
|
||||
},
|
||||
templater: 'cra',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
it('rejects template values which do not match the template schema definition', async () => {
|
||||
const response = await request(app).post('/v1/jobs').send({
|
||||
template,
|
||||
values: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TemplaterBuilder,
|
||||
PublisherBase,
|
||||
} from '../scaffolder';
|
||||
import { validate, ValidatorResult } from 'jsonschema';
|
||||
|
||||
export interface RouterOptions {
|
||||
preparers: PreparerBuilder;
|
||||
@@ -84,6 +85,15 @@ export async function createRouter(
|
||||
const values: RequiredTemplateValues & Record<string, JsonValue> =
|
||||
req.body.values;
|
||||
|
||||
const validationResult: ValidatorResult = validate(
|
||||
values,
|
||||
template.spec.schema,
|
||||
);
|
||||
if (!validationResult.valid) {
|
||||
res.status(400).json({ errors: validationResult.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobProcessor.create({
|
||||
entity: template,
|
||||
values,
|
||||
|
||||
Reference in New Issue
Block a user