Skip to content

Commit

Permalink
Add support for reading files from Bitbucket Server.
Browse files Browse the repository at this point in the history
  • Loading branch information
goober committed Oct 23, 2020
1 parent 628f782 commit 6579769
Show file tree
Hide file tree
Showing 5 changed files with 478 additions and 218 deletions.
5 changes: 5 additions & 0 deletions .changeset/3030.md
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': minor
---

Add the ability to import components from Bitbucket Server to the service catalog
2 changes: 1 addition & 1 deletion packages/backend-common/package.json
Expand Up @@ -41,7 +41,7 @@
"express": "^4.17.1",
"express-prom-bundle": "^6.1.0",
"express-promise-router": "^3.0.3",
"git-url-parse": "^11.3.0",
"git-url-parse": "^11.4.0",
"helmet": "^4.0.0",
"knex": "^0.21.1",
"lodash": "^4.17.15",
Expand Down
356 changes: 235 additions & 121 deletions packages/backend-common/src/reading/BitbucketUrlReader.test.ts
Expand Up @@ -14,139 +14,253 @@
* limitations under the License.
*/

import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '../logging';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { msw } from '@backstage/test-utils';

const logger = getVoidLogger();
import {
BitbucketUrlReader,
getApiRequestOptions,
getApiUrl,
getRawRequestOptions,
getRawUrl,
ProviderConfig,
readConfig,
} from './BitbucketUrlReader';

describe('BitbucketUrlReader', () => {
const worker = setupServer();
describe('getApiRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
const withoutToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getApiRequestOptions(withToken).headers as any).Authorization,
).toEqual('Bearer A');
expect(
(getApiRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});

it('insert basic auth when needed', () => {
const withUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
username: 'some-user',
appPassword: 'my-secret',
};
const withoutUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getApiRequestOptions(withUsernameAndPassword).headers as any)
.Authorization,
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
expect(
(getApiRequestOptions(withoutUsernameAndPassword).headers as any)
.Authorization,
).toBeUndefined();
});
});

msw.setupDefaultHandlers(worker);
describe('getRawRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
const withoutToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getRawRequestOptions(withToken).headers as any).Authorization,
).toEqual('Bearer A');
expect(
(getRawRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});

beforeEach(() => {
worker.use(
rest.get('*', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
url: req.url.toString(),
headers: req.headers.getAllHeaders(),
}),
it('insert basic auth when needed', () => {
const withUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
username: 'some-user',
appPassword: 'my-secret',
};
const withoutUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getRawRequestOptions(withUsernameAndPassword).headers as any)
.Authorization,
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
expect(
(getRawRequestOptions(withoutUsernameAndPassword).headers as any)
.Authorization,
).toBeUndefined();
});
});

describe('getApiUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for Bitbucket Cloud', () => {
const config: ProviderConfig = {
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getApiUrl(
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config,
),
).toEqual(
new URL(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
),
);
});
it('happy path for Bitbucket Server', () => {
const config: ProviderConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
};
expect(
getApiUrl(
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
),
),
);
);
});
});

const createConfig = (username?: string, appPassword?: string) =>
new ConfigReader(
{
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
username: username,
appPassword: appPassword,
},
],
},
},
'test-config',
);
describe('getRawUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});

it.each([
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig(),
response: expect.objectContaining({
url:
it('happy path for Bitbucket Cloud', () => {
const config: ProviderConfig = {
host: 'bitbucket.org',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getRawUrl(
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config,
),
).toEqual(
new URL(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
}),
},
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig('some-user', 'my-secret'),
response: expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==',
}),
}),
},
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig(),
response: expect.objectContaining({
headers: expect.not.objectContaining({
authorization: expect.anything(),
}),
}),
},
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig(undefined, 'only-password-provided'),
response: expect.objectContaining({
headers: expect.not.objectContaining({
authorization: expect.anything(),
}),
}),
},
])('should handle happy path %#', async ({ url, config, response }) => {
const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
),
);
});

it('happy path for Bitbucket Server', () => {
const config: ProviderConfig = {
host: 'bitbucket.mycompany.net',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getRawUrl(
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
),
);
});
});

describe('readConfig', () => {
function config(
providers: {
host: string;
apiBaseUrl?: string;
token?: string;
username?: string;
password?: string;
}[],
) {
return ConfigReader.fromConfigs([
{
context: '',
data: {
integrations: { bitbucket: providers },
},
},
]);
}

it('adds a default Bitbucket Cloud entry when missing', () => {
const output = readConfig(config([]));
expect(output).toEqual([
{
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
},
]);
});

it('injects the correct Bitbucket Cloud API base URL when missing', () => {
const output = readConfig(config([{ host: 'bitbucket.org' }]));
expect(output).toEqual([
{
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
},
]);
});

it('rejects custom targets with no base URLs', () => {
expect(() =>
readConfig(config([{ host: 'bitbucket.mycompany.net' }])),
).toThrow(
"Bitbucket integration for 'bitbucket.mycompany.net' must configure an explicit apiBaseUrl and rawBaseUrl",
);
});

const data = await reader.read(url);
const res = await JSON.parse(data.toString('utf-8'));
expect(res).toEqual(response);
it('rejects funky configs', () => {
expect(() => readConfig(config([{ host: 7 } as any]))).toThrow(/host/);
expect(() => readConfig(config([{ token: 7 } as any]))).toThrow(/token/);
expect(() =>
readConfig(config([{ host: 'bitbucket.org', apiBaseUrl: 7 } as any])),
).toThrow(/apiBaseUrl/);
expect(() =>
readConfig(config([{ host: 'bitbucket.org', token: 7 } as any])),
).toThrow(/token/);
});
});

it.each([
{
url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
config: createConfig(),
error:
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Bitbucket URL or Invalid file path',
},
{
url: 'com/a/b/blob/master/path/to/c.yaml',
config: createConfig(),
error:
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
},
{
url: '',
config: createConfig('', ''),
error:
"Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
},
{
url: '',
config: createConfig('only-user-provided', ''),
error:
"Invalid type in config for key 'integrations.bitbucket[0].appPassword' in 'test-config', got empty-string, wanted string",
},
{
url: '',
config: createConfig('', 'only-password-provided'),
error:
"Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
},
{
url: '',
config: createConfig('only-user-provided', undefined),
error:
"Missing required config value at 'integrations.bitbucket[0].appPassword'",
},
])('should handle error path %#', async ({ url, config, error }) => {
await expect(async () => {
const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
await reader.read(url);
}).rejects.toThrow(error);
describe('implementation', () => {
it('rejects unknown targets', async () => {
const processor = new BitbucketUrlReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
});
await expect(
processor.read('https://not.bitbucket.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
);
});
});
});

0 comments on commit 6579769

Please sign in to comment.