Merge pull request #22521 from backstage/blam/support-tokens-fetch

Support `token` in `URLReaders` for `fetch:*` actions in Scaffolder
This commit is contained in:
Ben Lambert
2024-01-30 11:00:11 +01:00
committed by GitHub
16 changed files with 305 additions and 21 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-plugin-api': patch
'@backstage/backend-common': patch
---
Support `token` in `readTree`, `readUrl` and `search`
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': minor
'@backstage/plugin-scaffolder-node': minor
---
Support providing an overriding token for `fetch:template`, `fetch:plain` and `fetch:file` when interacting with upstream integrations
@@ -46,7 +46,7 @@ const treeResponseFactory = DefaultReadTreeResponseFactory.create({
const mockCredentialsProvider = {
getCredentials: jest.fn().mockResolvedValue({ headers: {} }),
} as unknown as GithubCredentialsProvider;
} satisfies GithubCredentialsProvider;
const githubProcessor = new GithubUrlReader(
new GithubIntegration(
@@ -105,7 +105,7 @@ describe('GithubUrlReader', () => {
otherheader: 'something',
};
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
mockCredentialsProvider.getCredentials.mockResolvedValue({
headers: mockHeaders,
});
@@ -146,7 +146,7 @@ describe('GithubUrlReader', () => {
otherheader: 'something',
};
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
mockCredentialsProvider.getCredentials.mockResolvedValue({
headers: mockHeaders,
});
@@ -182,7 +182,7 @@ describe('GithubUrlReader', () => {
otherheader: 'something',
};
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
mockCredentialsProvider.getCredentials.mockResolvedValue({
headers: mockHeaders,
});
@@ -241,7 +241,7 @@ describe('GithubUrlReader', () => {
});
it('should return etag and last-modified from the response', async () => {
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
mockCredentialsProvider.getCredentials.mockResolvedValue({
headers: {
Authorization: 'bearer blah',
},
@@ -271,6 +271,33 @@ describe('GithubUrlReader', () => {
expect(response.etag).toBe('foo');
expect(response.lastModifiedAt).toEqual(new Date('2021-01-01T00:00:00Z'));
});
it('should override the token if its provided', async () => {
expect.assertions(1);
mockCredentialsProvider.getCredentials.mockResolvedValue({
headers: {
Authorization: 'bearer blah',
},
});
worker.use(
rest.get(
'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
'Bearer overridentoken',
);
return res(ctx.status(200));
},
),
);
await gheProcessor.readUrl(
'https://github.com/backstage/mock/tree/blob/main',
{ token: 'overridentoken' },
);
});
});
/*
@@ -431,7 +458,7 @@ describe('GithubUrlReader', () => {
otherheader: 'something',
};
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
mockCredentialsProvider.getCredentials.mockResolvedValue({
headers: mockHeaders,
});
@@ -463,6 +490,44 @@ describe('GithubUrlReader', () => {
);
});
it('should override the token when provided', async () => {
expect.assertions(1);
const mockHeaders = {
Authorization: 'bearer blah',
};
mockCredentialsProvider.getCredentials.mockResolvedValue({
headers: mockHeaders,
});
worker.use(
rest.get(
'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
'Bearer overridentoken',
);
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/x-gzip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-etag123.tar.gz',
),
ctx.body(repoBuffer),
);
},
),
);
await gheProcessor.readTree(
'https://ghe.github.com/backstage/mock/tree/main',
{ token: 'overridentoken' },
);
});
it('includes the subdomain in the github url', async () => {
const response = await gheProcessor.readTree(
'https://ghe.github.com/backstage/mock/tree/main/docs',
@@ -905,6 +970,34 @@ describe('GithubUrlReader', () => {
await runTests(gheProcessor, 'https://ghe.github.com');
});
it('passes through a token for the search request', async () => {
expect.assertions(1);
worker.use(
rest.get(
'https://ghe.github.com/api/v3/repos/backstage/mock/git/trees/etag123abc',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
'Bearer overridentoken',
);
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
truncated: true,
tree: [],
} as Partial<GhTreeResponse>),
);
},
),
);
await gheProcessor.search(
`https://ghe.github.com/backstage/mock/tree/main/**/*`,
{ token: 'overridentoken' },
);
});
// eslint-disable-next-line jest/expect-expect
it('succeeds on ghe when going via readTree', async () => {
worker.use(
@@ -92,13 +92,31 @@ export class GithubUrlReader implements UrlReader {
return response.buffer();
}
private getCredentials = async (
url: string,
options?: { token?: string },
): Promise<GithubCredentials> => {
if (options?.token) {
return {
headers: {
Authorization: `Bearer ${options.token}`,
},
type: 'token',
token: options.token,
};
}
return await this.deps.credentialsProvider.getCredentials({
url,
});
};
async readUrl(
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
const credentials = await this.deps.credentialsProvider.getCredentials({
url,
});
const credentials = await this.getCredentials(url, options);
const ghUrl = getGithubFileFetchUrl(
url,
this.integration.config,
@@ -141,9 +159,7 @@ export class GithubUrlReader implements UrlReader {
}
const { filepath } = parseGitUrl(url);
const { headers } = await this.deps.credentialsProvider.getCredentials({
url,
});
const { headers } = await this.getCredentials(url, options);
return this.doReadTree(
repoDetails.repo.archive_url,
@@ -169,9 +185,7 @@ export class GithubUrlReader implements UrlReader {
}
const { filepath } = parseGitUrl(url);
const { headers } = await this.deps.credentialsProvider.getCredentials({
url,
});
const { headers } = await this.getCredentials(url, options);
const files = await this.doSearch(
url,
@@ -316,6 +316,7 @@ export type ReadTreeOptions = {
): boolean;
etag?: string;
signal?: AbortSignal;
token?: string;
};
// @public
@@ -343,6 +344,7 @@ export type ReadUrlOptions = {
etag?: string;
lastModifiedAfter?: Date;
signal?: AbortSignal;
token?: string;
};
// @public
@@ -390,6 +392,7 @@ export interface SchedulerService extends PluginTaskScheduler {}
export type SearchOptions = {
etag?: string;
signal?: AbortSignal;
token?: string;
};
// @public
@@ -92,6 +92,18 @@ export type ReadUrlOptions = {
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
/**
* An optional token to use for authentication when reading the resources.
*
* @remarks
*
* By default all URL Readers will use the integrations config which is supplied
* when creating the Readers. Sometimes it might be desireable to use the already
* created URLReaders but with a different token, maybe that's supplied by the user
* at runtime.
*/
token?: string;
};
/**
@@ -179,6 +191,18 @@ export type ReadTreeOptions = {
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
/**
* An optional token to use for authentication when reading the resources.
*
* @remarks
*
* By default all URL Readers will use the integrations config which is supplied
* when creating the Readers. Sometimes it might be desireable to use the already
* created URLReaders but with a different token, maybe that's supplied by the user
* at runtime.
*/
token?: string;
};
/**
@@ -281,6 +305,18 @@ export type SearchOptions = {
* Not all reader implementations may take this field into account.
*/
signal?: AbortSignal;
/**
* An optional token to use for authentication when reading the resources.
*
* @remarks
*
* By default all URL Readers will use the integrations config which is supplied
* when creating the Readers. Sometimes it might be desireable to use the already
* created URLReaders but with a different token, maybe that's supplied by the user
* at runtime.
*/
token?: string;
};
/**
+3
View File
@@ -145,6 +145,7 @@ export function createFetchPlainAction(options: {
{
url: string;
targetPath?: string | undefined;
token?: string | undefined;
},
JsonObject
>;
@@ -157,6 +158,7 @@ export function createFetchPlainFileAction(options: {
{
url: string;
targetPath: string;
token?: string | undefined;
},
JsonObject
>;
@@ -179,6 +181,7 @@ export function createFetchTemplateAction(options: {
replace?: boolean | undefined;
trimBlocks?: boolean | undefined;
lstripBlocks?: boolean | undefined;
token?: string | undefined;
},
JsonObject
>;
@@ -77,6 +77,26 @@ describe('fetch:plain', () => {
targetPath: 'lol',
},
});
expect(fetchContents).toHaveBeenCalledWith(
expect.objectContaining({
outputPath: resolvePath(mockContext.workspacePath, 'lol'),
fetchUrl:
'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets',
}),
);
});
it('should fetch plain with token', async () => {
await action.handler({
...mockContext,
input: {
url: 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets',
targetPath: 'lol',
token: 'mockToken',
},
});
expect(fetchContents).toHaveBeenCalledWith(
expect.objectContaining({
outputPath: resolvePath(mockContext.workspacePath, 'lol'),
@@ -36,7 +36,11 @@ export function createFetchPlainAction(options: {
}) {
const { reader, integrations } = options;
return createTemplateAction<{ url: string; targetPath?: string }>({
return createTemplateAction<{
url: string;
targetPath?: string;
token?: string;
}>({
id: ACTION_ID,
examples,
description:
@@ -58,6 +62,12 @@ export function createFetchPlainAction(options: {
'Target path within the working directory to download the contents to.',
type: 'string',
},
token: {
title: 'Token',
description:
'An optional token to use for authentication when reading the resources.',
type: 'string',
},
},
},
},
@@ -75,6 +85,7 @@ export function createFetchPlainAction(options: {
baseUrl: ctx.templateInfo?.baseUrl,
fetchUrl: ctx.input.url,
outputPath,
token: ctx.input.token,
});
},
});
@@ -69,6 +69,21 @@ describe('fetch:plain:file', () => {
);
});
it('passed through the token to fetchFile', async () => {
await action.handler({
...mockContext,
input: {
url: 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets/Backstage%20Community%20Sessions.png',
token: 'mockToken',
targetPath: 'lol',
},
});
expect(fetchFile).toHaveBeenCalledWith(
expect.objectContaining({ token: 'mockToken' }),
);
});
it('should fetch plain', async () => {
await action.handler({
...mockContext,
@@ -33,7 +33,11 @@ export function createFetchPlainFileAction(options: {
}) {
const { reader, integrations } = options;
return createTemplateAction<{ url: string; targetPath: string }>({
return createTemplateAction<{
url: string;
targetPath: string;
token?: string;
}>({
id: 'fetch:plain:file',
description: 'Downloads single file and places it in the workspace.',
examples,
@@ -54,6 +58,12 @@ export function createFetchPlainFileAction(options: {
'Target path within the working directory to download the file as.',
type: 'string',
},
token: {
title: 'Token',
description:
'An optional token to use for authentication when reading the resources.',
type: 'string',
},
},
},
},
@@ -73,6 +83,7 @@ export function createFetchPlainFileAction(options: {
baseUrl: ctx.templateInfo?.baseUrl,
fetchUrl: ctx.input.url,
outputPath,
token: ctx.input.token,
});
},
});
@@ -371,6 +371,18 @@ describe('fetch:template', () => {
fs.readlink(`${workspacePath}/target/brokenSymlink`),
).resolves.toEqual(`.${pathSep}not-a-real-file.txt`);
});
it('passed through the token to the fetchContents call', async () => {
await action.handler(
mockContext({
token: 'mockToken',
}),
);
expect(mockFetchContents).toHaveBeenCalledWith(
expect.objectContaining({ token: 'mockToken' }),
);
});
});
});
@@ -71,6 +71,7 @@ export function createFetchTemplateAction(options: {
replace?: boolean;
trimBlocks?: boolean;
lstripBlocks?: boolean;
token?: string;
}>({
id: 'fetch:template',
description:
@@ -134,6 +135,12 @@ export function createFetchTemplateAction(options: {
'If set, replace files in targetPath instead of skipping existing ones.',
type: 'boolean',
},
token: {
title: 'Token',
description:
'An optional token to use for authentication when reading the resources.',
type: 'string',
},
},
},
},
@@ -197,6 +204,7 @@ export function createFetchTemplateAction(options: {
baseUrl: ctx.templateInfo?.baseUrl,
fetchUrl: ctx.input.url,
outputPath: templateDir,
token: ctx.input.token,
});
ctx.logger.info('Listing files and directories in template');
+2
View File
@@ -195,6 +195,7 @@ export function fetchContents(options: {
baseUrl?: string;
fetchUrl?: string;
outputPath: string;
token?: string;
}): Promise<void>;
// @public
@@ -204,6 +205,7 @@ export function fetchFile(options: {
baseUrl?: string;
fetchUrl?: string;
outputPath: string;
token?: string;
}): Promise<void>;
// @public (undocumented)
@@ -124,6 +124,20 @@ describe('fetchContents helper', () => {
expect(fs.ensureDir).toHaveBeenCalledWith('foo');
expect(dirFunction).toHaveBeenCalledWith({ targetDir: 'foo' });
});
it('should pass through the token provided through to the URL reader', async () => {
await fetchContents({
...options,
outputPath: 'mydir/foo',
fetchUrl: 'https://github.com/backstage/foo',
token: 'mockToken',
});
expect(readTree).toHaveBeenCalledWith(
'https://github.com/backstage/foo',
expect.objectContaining({ token: 'mockToken' }),
);
});
});
describe('fetch file', () => {
@@ -211,5 +225,19 @@ describe('fetchContents helper', () => {
expect(fs.ensureDir).toHaveBeenCalledWith('mydir');
expect(fs.outputFile).toHaveBeenCalledWith('mydir/foo', 'test');
});
it('should pass through the token provided through to the URL reader', async () => {
await fetchFile({
...options,
outputPath: 'mydir/foo',
fetchUrl: 'https://github.com/backstage/foo',
token: 'mockToken',
});
expect(readUrl).toHaveBeenCalledWith(
'https://github.com/backstage/foo',
expect.objectContaining({ token: 'mockToken' }),
);
});
});
});
+20 -4
View File
@@ -32,8 +32,16 @@ export async function fetchContents(options: {
baseUrl?: string;
fetchUrl?: string;
outputPath: string;
token?: string;
}) {
const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options;
const {
reader,
integrations,
baseUrl,
fetchUrl = '.',
outputPath,
token,
} = options;
const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
@@ -45,7 +53,7 @@ export async function fetchContents(options: {
} else {
const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
const res = await reader.readTree(readUrl);
const res = await reader.readTree(readUrl, { token });
await fs.ensureDir(outputPath);
await res.dir({ targetDir: outputPath });
}
@@ -63,8 +71,16 @@ export async function fetchFile(options: {
baseUrl?: string;
fetchUrl?: string;
outputPath: string;
token?: string;
}) {
const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options;
const {
reader,
integrations,
baseUrl,
fetchUrl = '.',
outputPath,
token,
} = options;
const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
@@ -76,7 +92,7 @@ export async function fetchFile(options: {
} else {
const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
const res = await reader.readUrl(readUrl);
const res = await reader.readUrl(readUrl, { token });
await fs.ensureDir(path.dirname(outputPath));
const buffer = await res.buffer();
await fs.outputFile(outputPath, buffer.toString());