Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions plugins/backstage-plugin-coder/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
WorkspaceAgentStatus,
} from './typesConstants';
import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider';
import { IdentityApi } from '@backstage/core-plugin-api';

export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin';

Expand All @@ -19,9 +20,31 @@ export const ASSETS_ROUTE_PREFIX = PROXY_ROUTE_PREFIX;
export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token';
export const REQUEST_TIMEOUT_MS = 20_000;

function getCoderApiRequestInit(authToken: string): RequestInit {
async function getCoderApiRequestInit(
authToken: string,
identity: IdentityApi,
): Promise<RequestInit> {
const headers: HeadersInit = {
[CODER_AUTH_HEADER_KEY]: authToken,
};

try {
const credentials = await identity.getCredentials();
if (credentials.token) {
headers.Authorization = `Bearer ${credentials.token}`;
}
} catch (err) {
if (err instanceof Error) {
throw err;
}

throw new Error(
"Unable to parse user information for Coder requests. Please ensure that your Backstage deployment is integrated to use Backstage's Identity API",
);
}

return {
headers: { [CODER_AUTH_HEADER_KEY]: authToken },
headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
};
}
Expand Down Expand Up @@ -53,6 +76,7 @@ export class BackstageHttpError extends Error {
type FetchInputs = Readonly<{
auth: CoderAuth;
baseUrl: string;
identity: IdentityApi;
}>;

type WorkspacesFetchInputs = Readonly<
Expand All @@ -64,17 +88,18 @@ type WorkspacesFetchInputs = Readonly<
async function getWorkspaces(
fetchInputs: WorkspacesFetchInputs,
): Promise<readonly Workspace[]> {
const { baseUrl, coderQuery, auth } = fetchInputs;
const { baseUrl, coderQuery, auth, identity } = fetchInputs;
assertValidCoderAuth(auth);

const urlParams = new URLSearchParams({
q: coderQuery,
limit: '0',
});

const requestInit = await getCoderApiRequestInit(auth.token, identity);
const response = await fetch(
`${baseUrl}${API_ROUTE_PREFIX}/workspaces?${urlParams.toString()}`,
getCoderApiRequestInit(auth.token),
requestInit,
);

if (!response.ok) {
Expand Down Expand Up @@ -116,12 +141,13 @@ type BuildParamsFetchInputs = Readonly<
>;

async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) {
const { baseUrl, auth, workspaceBuildId } = inputs;
const { baseUrl, auth, workspaceBuildId, identity } = inputs;
assertValidCoderAuth(auth);

const requestInit = await getCoderApiRequestInit(auth.token, identity);
const res = await fetch(
`${baseUrl}${API_ROUTE_PREFIX}/workspacebuilds/${workspaceBuildId}/parameters`,
getCoderApiRequestInit(auth.token),
requestInit,
);

if (!res.ok) {
Expand Down Expand Up @@ -256,16 +282,18 @@ export function workspacesByRepo(
type AuthValidationInputs = Readonly<{
baseUrl: string;
authToken: string;
identity: IdentityApi;
}>;

async function isAuthValid(inputs: AuthValidationInputs): Promise<boolean> {
const { baseUrl, authToken } = inputs;
const { baseUrl, authToken, identity } = inputs;

// In this case, the request doesn't actually matter. Just need to make any
// kind of dummy request to validate the auth
const requestInit = await getCoderApiRequestInit(authToken, identity);
const response = await fetch(
`${baseUrl}${API_ROUTE_PREFIX}/users/me`,
getCoderApiRequestInit(authToken),
requestInit,
);

if (response.status >= 400 && response.status !== 401) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
authValidation,
} from '../../api';
import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';

const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token';

Expand Down Expand Up @@ -98,6 +99,7 @@ export function useCoderAuth(): CoderAuth {
type CoderAuthProviderProps = Readonly<PropsWithChildren<unknown>>;

export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => {
const identity = useApi(identityApiRef);
const { baseUrl } = useBackstageEndpoints();
const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true);

Expand All @@ -108,7 +110,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => {
const [readonlyInitialAuthToken] = useState(authToken);

const authValidityQuery = useQuery({
...authValidation({ baseUrl, authToken }),
...authValidation({ baseUrl, authToken, identity }),
refetchOnWindowFocus: query => query.state.data !== false,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { renderHook } from '@testing-library/react';
import { act, waitFor } from '@testing-library/react';

import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
import { configApiRef, errorApiRef } from '@backstage/core-plugin-api';
import {
configApiRef,
errorApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';

import { CoderProvider } from './CoderProvider';
import { useCoderAppConfig } from './CoderAppConfigProvider';
Expand All @@ -12,6 +16,7 @@ import { type CoderAuth, useCoderAuth } from './CoderAuthProvider';
import {
getMockConfigApi,
getMockErrorApi,
getMockIdentityApi,
mockAppConfig,
mockCoderAuthToken,
} from '../../testHelpers/mockBackstageData';
Expand Down Expand Up @@ -87,6 +92,7 @@ describe(`${CoderProvider.name}`, () => {
<TestApiProvider
apis={[
[errorApiRef, getMockErrorApi()],
[identityApiRef, getMockIdentityApi()],
[configApiRef, getMockConfigApi()],
]}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { workspaces, workspacesByRepo } from '../api';
import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider';
import { useBackstageEndpoints } from './useBackstageEndpoints';
import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';

type QueryInput = Readonly<{
coderQuery: string;
Expand All @@ -15,12 +16,19 @@ export function useCoderWorkspacesQuery({
workspacesConfig,
}: QueryInput) {
const auth = useCoderAuth();
const identity = useApi(identityApiRef);
const { baseUrl } = useBackstageEndpoints();
const hasRepoData = workspacesConfig && workspacesConfig.repoUrl;

const queryOptions = hasRepoData
? workspacesByRepo({ coderQuery, auth, baseUrl, workspacesConfig })
: workspaces({ coderQuery, auth, baseUrl });
? workspacesByRepo({
coderQuery,
identity,
auth,
baseUrl,
workspacesConfig,
})
: workspaces({ coderQuery, identity, auth, baseUrl });

return useQuery(queryOptions);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { ScmIntegrationsApi } from '@backstage/integration-react';

import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api';
import { IdentityApi } from '@backstage/core-plugin-api';

/**
* This is the key that Backstage checks from the entity data to determine the
Expand Down Expand Up @@ -57,6 +58,7 @@ export const mockBackstageProxyEndpoint = `${mockBackstageUrlRoot}${API_ROUTE_PR

export const mockBackstageAssetsEndpoint = `${mockBackstageUrlRoot}${ASSETS_ROUTE_PREFIX}`;

export const mockBearerToken = 'This-is-an-opaque-value-by-design';
export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X';

export const mockYamlConfig = {
Expand Down Expand Up @@ -207,6 +209,33 @@ export function getMockErrorApi() {
return errorApi;
}

export function getMockIdentityApi(): IdentityApi {
return {
signOut: async () => {
return void 'Not going to implement this';
},
getProfileInfo: async () => {
return {
displayName: 'Dobah',
email: '[email protected]',
picture: undefined,
};
},
getBackstageIdentity: async () => {
return {
type: 'user',
userEntityRef: 'User:default/Dobah',
ownershipEntityRefs: [],
};
},
getCredentials: async () => {
return {
token: mockBearerToken,
};
},
};
}

/**
* Exposes a mock ScmIntegrationRegistry to be used with scmIntegrationsApiRef
* for mocking out code that relies on source code data.
Expand Down
65 changes: 59 additions & 6 deletions plugins/backstage-plugin-coder/src/testHelpers/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */
import { RestHandler, rest } from 'msw';
import {
type DefaultBodyType,
type ResponseResolver,
type RestContext,
type RestHandler,
type RestRequest,
rest,
} from 'msw';
import { setupServer } from 'msw/node';
/* eslint-enable @backstage/no-undeclared-imports */

Expand All @@ -8,14 +15,60 @@ import {
mockWorkspaceBuildParameters,
} from './mockCoderAppData';
import {
mockBearerToken,
mockCoderAuthToken,
mockBackstageProxyEndpoint as root,
} from './mockBackstageData';
import type { Workspace, WorkspacesResponse } from '../typesConstants';
import { CODER_AUTH_HEADER_KEY } from '../api';

const handlers: readonly RestHandler[] = [
rest.get(`${root}/workspaces`, (req, res, ctx) => {
type RestResolver<TBody extends DefaultBodyType = any> = ResponseResolver<
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge fan of needing to bust out the any type for these MSW helper types, but it's what MSW itself is using, and trying to make things more specific got nasty

RestRequest<TBody>,
RestContext,
TBody
>;

export type RestResolverMiddleware<TBody extends DefaultBodyType = any> = (
resolver: RestResolver<TBody>,
) => RestResolver<TBody>;

const defaultMiddleware = [
function validateBearerToken(handler) {
return (req, res, ctx) => {
const tokenRe = /^Bearer (.+)$/;
const authHeader = req.headers.get('Authorization') ?? '';
const [, bearerToken] = tokenRe.exec(authHeader) ?? [];

if (bearerToken === mockBearerToken) {
return handler(req, res, ctx);
}

return res(ctx.status(401));
};
},
] as const satisfies readonly RestResolverMiddleware[];

export function wrapInDefaultMiddleware<TBody extends DefaultBodyType = any>(
resolver: RestResolver<TBody>,
): RestResolver<TBody> {
return defaultMiddleware.reduceRight((currentResolver, middleware) => {
const recastMiddleware =
middleware as unknown as RestResolverMiddleware<TBody>;

return recastMiddleware(currentResolver);
}, resolver);
}

function wrappedGet<TBody extends DefaultBodyType = any>(
path: string,
resolver: RestResolver<TBody>,
): RestHandler {
const wrapped = wrapInDefaultMiddleware(resolver);
return rest.get(path, wrapped);
}

const mainTestHandlers: readonly RestHandler[] = [
wrappedGet(`${root}/workspaces`, (req, res, ctx) => {
const queryText = String(req.url.searchParams.get('q'));

let returnedWorkspaces: Workspace[];
Expand All @@ -36,7 +89,7 @@ const handlers: readonly RestHandler[] = [
);
}),

rest.get(
wrappedGet(
`${root}/workspacebuilds/:workspaceBuildId/parameters`,
(req, res, ctx) => {
const buildId = String(req.params.workspaceBuildId);
Expand All @@ -51,7 +104,7 @@ const handlers: readonly RestHandler[] = [
),

// This is the dummy request used to verify a user's auth status
rest.get(`${root}/users/me`, (req, res, ctx) => {
wrappedGet(`${root}/users/me`, (req, res, ctx) => {
const token = req.headers.get(CODER_AUTH_HEADER_KEY);
if (token === mockCoderAuthToken) {
return res(ctx.status(200));
Expand All @@ -61,4 +114,4 @@ const handlers: readonly RestHandler[] = [
}),
];

export const server = setupServer(...handlers);
export const server = setupServer(...mainTestHandlers);
11 changes: 10 additions & 1 deletion plugins/backstage-plugin-coder/src/testHelpers/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { configApiRef, errorApiRef } from '@backstage/core-plugin-api';
import {
configApiRef,
errorApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import {
type CoderAuth,
Expand All @@ -30,6 +34,7 @@ import {
getMockConfigApi,
mockAuthStates,
BackstageEntity,
getMockIdentityApi,
} from './mockBackstageData';
import { CoderErrorBoundary } from '../plugin';

Expand Down Expand Up @@ -159,6 +164,7 @@ export const renderHookAsCoderEntity = async <
const mockErrorApi = getMockErrorApi();
const mockSourceControl = getMockSourceControl();
const mockConfigApi = getMockConfigApi();
const mockIdentityApi = getMockIdentityApi();
const mockQueryClient = getMockQueryClient();

const renderHookValue = renderHook(hook, {
Expand All @@ -168,6 +174,7 @@ export const renderHookAsCoderEntity = async <
<TestApiProvider
apis={[
[errorApiRef, mockErrorApi],
[identityApiRef, mockIdentityApi],
[scmIntegrationsApiRef, mockSourceControl],
[configApiRef, mockConfigApi],
]}
Expand Down Expand Up @@ -215,11 +222,13 @@ export async function renderInCoderEnvironment({
const mockErrorApi = getMockErrorApi();
const mockSourceControl = getMockSourceControl();
const mockConfigApi = getMockConfigApi();
const mockIdentityApi = getMockIdentityApi();

const mainMarkup = (
<TestApiProvider
apis={[
[errorApiRef, mockErrorApi],
[identityApiRef, mockIdentityApi],
[scmIntegrationsApiRef, mockSourceControl],
[configApiRef, mockConfigApi],
]}
Expand Down