diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index e48c8f21..e91fe5be 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -41,6 +41,8 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", + "axios": "^1.6.8", + "use-sync-external-store": "^1.2.0", "valibot": "^0.28.1" }, "peerDependencies": { diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx b/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx new file mode 100644 index 00000000..8f80dc51 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx @@ -0,0 +1,211 @@ +import React, { useEffect } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { act, render, waitFor } from '@testing-library/react'; +import { + getMockDiscoveryApi, + getMockIdentityApi, + mockBackstageUrlRoot, + mockCoderAuthToken, + setupCoderClient, +} from '../testHelpers/mockBackstageData'; +import { + CoderClient, + CoderClientSnapshot, + defaultCoderClientConfigOptions, +} from './CoderClient'; +import { CoderTokenAuth } from './CoderTokenAuth'; +import type { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { CoderAuthApi } from './Auth'; +import { server, wrappedGet } from '../testHelpers/server'; + +type SetupClientInput = Readonly<{ + authApi?: CoderAuthApi; + discoveryApi?: DiscoveryApi; +}>; + +type SetupClientOutput = Readonly<{ + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + coderClientApi: CoderClient; +}>; + +function setupClient(options?: SetupClientInput): SetupClientOutput { + const { + authApi = new CoderTokenAuth(), + discoveryApi = getMockDiscoveryApi(), + } = options ?? {}; + + const identityApi = getMockIdentityApi(); + const { coderClientApi } = setupCoderClient({ + discoveryApi, + identityApi, + authApi, + }); + + return { discoveryApi, identityApi, coderClientApi }; +} + +/** + * @todo Decide if we want to test the SDK-like functionality (even as a + * stopgap). Once we can import the methods from Coder, it might be safe for the + * plugin to assume the methods will always work. + * + * Plus, the other test files making requests to the SDK to get specific data + * should kick up any other issues. + */ +describe(`${CoderClient.name}`, () => { + /** + * Once the OAuth implementation is done, it probably makes sense to have test + * cases specifically for that. + */ + describe('With token auth', () => { + describe('validateAuth method', () => { + it('Will update the underlying auth instance when a query succeeds', async () => { + const authApi = new CoderTokenAuth(); + const { coderClientApi } = setupClient({ authApi }); + + authApi.registerNewToken(mockCoderAuthToken); + const validationResult = await coderClientApi.validateAuth(); + + expect(validationResult).toBe(true); + expect(authApi.isTokenValid).toBe(true); + + const clientSnapshot = coderClientApi.getStateSnapshot(); + expect(clientSnapshot).toEqual( + expect.objectContaining<Partial<CoderClientSnapshot>>({ + isAuthValid: true, + }), + ); + }); + + it('Will update the underlying auth instance when a query fails', async () => { + const authApi = new CoderTokenAuth(); + const { coderClientApi } = setupClient({ authApi }); + + authApi.registerNewToken('Definitely not a valid token'); + const validationResult = await coderClientApi.validateAuth(); + + expect(validationResult).toBe(false); + expect(authApi.isTokenValid).toBe(false); + + const clientSnapshot = coderClientApi.getStateSnapshot(); + expect(clientSnapshot).toEqual( + expect.objectContaining<Partial<CoderClientSnapshot>>({ + isAuthValid: false, + }), + ); + }); + }); + }); + + describe('State snapshot subscriptions', () => { + it('Lets external systems subscribe to state changes', async () => { + const { coderClientApi } = setupClient(); + const onChange = jest.fn(); + coderClientApi.subscribe(onChange); + + await coderClientApi.validateAuth(); + expect(onChange).toHaveBeenCalled(); + }); + + it('Lets external systems UN-subscribe to state changes', async () => { + const authApi = new CoderTokenAuth(); + const { coderClientApi } = setupClient({ authApi }); + + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + + /** + * Doing something a little sneaky to try accounting for something that + * could happen in the real world. The setup is: + * + * 1. External system subscribes to client + * 2. Client calls validateAuth, which is async and goes through the + * microtask queue + * 3. During that brief window where we're waiting for the response to + * come back, the external system unsubscribes + * 4. Promise resolves, and the auth state changes, but the old subscriber + * should *NOT* get notified because it's unsubscribed now + */ + coderClientApi.subscribe(subscriber1); + coderClientApi.subscribe(subscriber2); + + // Important that there's no await here. Do not want to pause the thread + // of execution until after subscriber2 unsubscribes. + void coderClientApi.validateAuth(); + coderClientApi.unsubscribe(subscriber2); + + await waitFor(() => expect(subscriber1).toHaveBeenCalled()); + expect(subscriber2).not.toHaveBeenCalled(); + }); + + it('Provides tools to let React components bind re-renders to state changes', async () => { + const { coderClientApi } = setupClient(); + const onStateChange = jest.fn(); + + const DummyReactComponent = () => { + const reactiveStateSnapshot = useSyncExternalStore( + coderClientApi.subscribe, + coderClientApi.getStateSnapshot, + ); + + useEffect(() => { + onStateChange(); + }, [reactiveStateSnapshot]); + + return null; + }; + + const { rerender } = render(<DummyReactComponent />); + expect(onStateChange).toHaveBeenCalledTimes(1); + + await act(() => coderClientApi.validateAuth()); + expect(onStateChange).toHaveBeenCalledTimes(2); + + // Make sure that if the component re-renders from the top down (like a + // parent state change), that does not cause the snapshot to lose its + // stable reference + rerender(<DummyReactComponent />); + expect(onStateChange).toHaveBeenCalledTimes(2); + }); + + it('Will notify external systems when the DiscoveryApi base URL has changed between requests', async () => { + // The Backstage docs say that the values returned by the Discovery API + // can change over time, which is why they want you to call it fresh + // before every request, but none of their public interfaces allow you to + // test that super well + let currentBaseUrl = mockBackstageUrlRoot; + const mockDiscoveryApi: DiscoveryApi = { + getBaseUrl: async () => currentBaseUrl, + }; + + const authApi = new CoderTokenAuth(); + const { coderClientApi } = setupClient({ + discoveryApi: mockDiscoveryApi, + }); + + const onChange = jest.fn(); + coderClientApi.subscribe(onChange); + authApi.registerNewToken(mockCoderAuthToken); + + const newBaseUrl = 'https://www.zombo.com/api/you-can-do-anything'; + const serverRouteUrl = `${newBaseUrl}${defaultCoderClientConfigOptions.proxyPrefix}${defaultCoderClientConfigOptions.apiRoutePrefix}/users/me/login-type`; + + server.use( + wrappedGet(serverRouteUrl, (_, res, ctx) => { + return res(ctx.status(200)); + }), + ); + + currentBaseUrl = newBaseUrl; + await coderClientApi.validateAuth(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining<Partial<CoderClientSnapshot>>({ + assetsRoute: expect.stringContaining(newBaseUrl), + apiRoute: expect.stringContaining(newBaseUrl), + }), + ); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts new file mode 100644 index 00000000..37d8b132 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -0,0 +1,393 @@ +/** + * @file This class is a little chaotic. It's basically in charge of juggling + * and coordinating several different systems together: + * + * 1. Backstage APIs (API classes/factories, as well as proxies) + * 2. React (making sure that mutable class state can be turned into immutable + * state snapshots that are available synchronously from the first render) + * 3. The custom auth API(s) that we build out for Backstage + * 4. The Coder SDK (either the eventual real one, or the fake stopgap) + * 5. Axios (which we need, because it's what the Coder SDK uses) + * + * All while being easy for the end-user to drop into their own Backstage + * deployment. + */ +import globalAxios, { + type InternalAxiosRequestConfig, + AxiosError, +} from 'axios'; +import { + type DiscoveryApi, + type IdentityApi, + createApiRef, +} from '@backstage/core-plugin-api'; +import { BackstageHttpError } from './errors'; + +import type { CoderAuthApi } from './Auth'; +import { + type UserLoginType, + type Workspace, + type WorkspaceBuildParameter, + type WorkspacesRequest, + type WorkspacesResponse, + CODER_API_REF_ID_PREFIX, +} from '../typesConstants'; +import { StateSnapshotManager } from '../utils/StateSnapshotManager'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; + +type CoderClientConfigOptions = Readonly<{ + proxyPrefix: string; + apiRoutePrefix: string; + authHeaderKey: string; + assetsRoutePrefix: string; + requestTimeoutMs: number; +}>; + +export const defaultCoderClientConfigOptions = { + proxyPrefix: '/coder', + apiRoutePrefix: '/api/v2', + assetsRoutePrefix: '/', // Deliberately left as single slash + authHeaderKey: 'Coder-Session-Token', + requestTimeoutMs: 20_000, +} as const satisfies CoderClientConfigOptions; + +export type CoderClientSnapshot = Readonly<{ + isAuthValid: boolean; + apiRoute: string; + assetsRoute: string; +}>; + +/** + * @todo This should eventually be the real Coder SDK. + */ +type RawCoderSdkApi = { + getUserLoginType: () => Promise<UserLoginType>; + getWorkspaces: (options: WorkspacesRequest) => Promise<WorkspacesResponse>; + getWorkspaceBuildParameters: ( + input: string, + ) => Promise<readonly WorkspaceBuildParameter[]>; +}; + +/** + * A version of the main Coder SDK API, with additional Backstage-specific + * methods and properties. + */ +export type BackstageCoderSdkApi = Readonly< + RawCoderSdkApi & { + getWorkspacesByRepo: ( + coderQuery: string, + config: CoderWorkspacesConfig, + ) => Promise<readonly Workspace[]>; + } +>; + +type SubscriptionCallback = (newSnapshot: CoderClientSnapshot) => void; + +export type CoderClientApi = Readonly<{ + sdkApi: BackstageCoderSdkApi; + isAuthValid: boolean; + validateAuth: () => Promise<boolean>; + + getStateSnapshot: () => CoderClientSnapshot; + unsubscribe: (callback: SubscriptionCallback) => void; + subscribe: (callback: SubscriptionCallback) => () => void; + cleanupClient: () => void; +}>; + +/** + * @todo Using an Axios instance to ensure that even if another user is using + * Axios, there's no risk of our request intercepting logic messing up non-Coder + * requests. + * + * However, the current version of the SDK does NOT have this behavior. Make + * sure that it does when it finally gets built out. + */ +const axiosInstance = globalAxios.create(); + +type CoderClientConstructorInputs = Partial<CoderClientConfigOptions> & { + apis: Readonly<{ + identityApi: IdentityApi; + discoveryApi: DiscoveryApi; + authApi: CoderAuthApi; + }>; +}; + +export class CoderClient implements CoderClientApi { + private readonly identityApi: IdentityApi; + private readonly discoveryApi: DiscoveryApi; + private readonly authApi: CoderAuthApi; + + private readonly options: CoderClientConfigOptions; + private readonly snapshotManager: StateSnapshotManager<CoderClientSnapshot>; + private readonly axiosInterceptorId: number; + private readonly abortController: AbortController; + + private latestProxyEndpoint: string; + readonly sdkApi: BackstageCoderSdkApi; + + /* *************************************************************************** + * There is some funky (but necessary) stuff going on in this class - a lot of + * the class methods are passed directly to other systems. Just to be on the + * safe side, all methods (public and private) should be defined as arrow + * functions, to ensure the methods can't ever lose their `this` contexts + * + * This technically defeats some of the memory optimizations you would + * normally get with class methods (arrow methods will be rebuilt from + * scratch each time the class is instantiated), but because CoderClient will + * likely be instantiated only once for the entire app's lifecycle, that won't + * matter much at all + ****************************************************************************/ + + constructor(inputs: CoderClientConstructorInputs) { + const { apis, ...options } = inputs; + const { discoveryApi, identityApi, authApi } = apis; + + // The "easy setup" part - initialize internal properties + this.identityApi = identityApi; + this.discoveryApi = discoveryApi; + this.authApi = authApi; + this.latestProxyEndpoint = ''; + this.options = { ...defaultCoderClientConfigOptions, ...options }; + + /** + * Wire up SDK API namespace. + * + * @todo All methods are defined locally in the class, but this should + * eventually be updated so that 99% of methods come from the SDK, with a + * few extra methods patched in for Backstage convenience + */ + this.sdkApi = { + getUserLoginType: this.getUserLoginType, + getWorkspaceBuildParameters: this.getWorkspaceBuildParameters, + getWorkspaces: this.getWorkspaces, + getWorkspacesByRepo: this.getWorkspacesByRepo, + }; + + // Wire up Backstage APIs and Axios to be aware of each other + this.abortController = new AbortController(); + this.axiosInterceptorId = axiosInstance.interceptors.request.use( + this.interceptAxiosRequest, + ); + + // Hook up snapshot manager so that external systems can be made aware when + // state changes, all in a render-safe way + this.snapshotManager = new StateSnapshotManager({ + initialSnapshot: this.prepareNewStateSnapshot(), + }); + + // Set up logic for syncing client snapshots to auth state changes + this.authApi.subscribe(newAuthSnapshot => { + const latestClientSnapshot = this.getStateSnapshot(); + if (newAuthSnapshot.isTokenValid !== latestClientSnapshot.isAuthValid) { + this.notifySubscriptionsOfStateChange(); + } + }); + + // Call DiscoveryApi to populate initial endpoint path, so that the path + // can be accessed synchronously from the UI. Should be called last after + // all other initialization steps + void this.getProxyEndpoint(); + } + + get isAuthValid(): boolean { + return this.authApi.isTokenValid; + } + + // Request configs are created on the per-request basis, so mutating a config + // won't mess up future non-Coder requests that also uses Axios + private interceptAxiosRequest = async ( + config: InternalAxiosRequestConfig, + ): Promise<InternalAxiosRequestConfig> => { + const { authHeaderKey, apiRoutePrefix } = this.options; + + const proxyEndpoint = await this.getProxyEndpoint(); + config.baseURL = `${proxyEndpoint}${apiRoutePrefix}`; + config.signal = this.abortController.signal; + config.headers[authHeaderKey] = this.authApi.requestToken() ?? undefined; + + const bearerToken = (await this.identityApi.getCredentials()).token; + if (bearerToken) { + config.headers.Authorization = `Bearer ${bearerToken}`; + } + + return config; + }; + + private prepareNewStateSnapshot = (): CoderClientSnapshot => { + const base = this.latestProxyEndpoint; + const { apiRoutePrefix, assetsRoutePrefix } = this.options; + + return { + isAuthValid: this.authApi.isTokenValid, + apiRoute: `${base}${apiRoutePrefix}`, + assetsRoute: `${base}${assetsRoutePrefix}`, + }; + }; + + private notifySubscriptionsOfStateChange = (): void => { + const newSnapshot = this.prepareNewStateSnapshot(); + this.snapshotManager.updateSnapshot(newSnapshot); + }; + + // Backstage officially recommends that you use the DiscoveryApi over the + // ConfigApi nowadays, and that you call it before each request. But the + // problem is that the Discovery API has no synchronous methods for getting + // endpoints, meaning that there's no great built-in way to access that data + // from the UI's render logic. Have to cache the return value to close the gap + private getProxyEndpoint = async (): Promise<string> => { + const latestBase = await this.discoveryApi.getBaseUrl('proxy'); + const withProxy = `${latestBase}${this.options.proxyPrefix}`; + + this.latestProxyEndpoint = withProxy; + this.notifySubscriptionsOfStateChange(); + + return withProxy; + }; + + private getUserLoginType = async (): Promise<UserLoginType> => { + const response = await axiosInstance.get<UserLoginType>( + '/users/me/login-type', + ); + + return response.data; + }; + + private remapWorkspaceIconUrls = ( + workspaces: readonly Workspace[], + ): Workspace[] => { + const { assetsRoute } = this.getStateSnapshot(); + + return workspaces.map(ws => { + const templateIconUrl = ws.template_icon; + if (!templateIconUrl.startsWith('/')) { + return ws; + } + + return { + ...ws, + template_icon: `${assetsRoute}${templateIconUrl}`, + }; + }); + }; + + private getWorkspaceBuildParameters = async ( + workspaceBuildId: string, + ): Promise<readonly WorkspaceBuildParameter[]> => { + const response = await axiosInstance.get< + readonly WorkspaceBuildParameter[] + >(`/workspacebuilds/${workspaceBuildId}/parameters`); + + return response.data; + }; + + private getWorkspaces = async ( + options: WorkspacesRequest, + ): Promise<WorkspacesResponse> => { + const urlParams = new URLSearchParams({ + q: options.q ?? '', + limit: String(options.limit || 0), + }); + + const { data } = await axiosInstance.get<WorkspacesResponse>( + `/workspaces?${urlParams.toString()}`, + ); + + const remapped: WorkspacesResponse = { + ...data, + workspaces: this.remapWorkspaceIconUrls(data.workspaces), + }; + + return remapped; + }; + + private getWorkspacesByRepo = async ( + coderQuery: string, + config: CoderWorkspacesConfig, + ): Promise<readonly Workspace[]> => { + const { workspaces } = await this.getWorkspaces({ + q: coderQuery, + limit: 0, + }); + + const remappedWorkspaces = this.remapWorkspaceIconUrls(workspaces); + const paramResults = await Promise.allSettled( + remappedWorkspaces.map(ws => + this.sdkApi.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); + + const matchedWorkspaces: Workspace[] = []; + for (const [index, res] of paramResults.entries()) { + if (res.status === 'rejected') { + continue; + } + + for (const param of res.value) { + const include = + config.repoUrlParamKeys.includes(param.name) && + param.value === config.repoUrl; + + if (include) { + // Doing type assertion just in case noUncheckedIndexedAccess compiler + // setting ever gets turned on; this shouldn't ever break, but it's + // technically not type-safe + matchedWorkspaces.push(workspaces[index] as Workspace); + break; + } + } + } + + return matchedWorkspaces; + }; + + unsubscribe = (callback: SubscriptionCallback): void => { + this.snapshotManager.unsubscribe(callback); + }; + + subscribe = (callback: SubscriptionCallback): (() => void) => { + return this.snapshotManager.subscribe(callback); + }; + + getStateSnapshot = (): CoderClientSnapshot => { + return this.snapshotManager.getSnapshot(); + }; + + validateAuth = async (): Promise<boolean> => { + const dispatchNewStatus = this.authApi.getAuthStateSetter(); + + try { + // Dummy request; just need something that all users would have access + // to, and that doesn't require a body + await this.sdkApi.getUserLoginType(); + dispatchNewStatus(true); + return true; + } catch (err) { + dispatchNewStatus(false); + + if (!(err instanceof AxiosError)) { + throw err; + } + + const response = err.response; + if (response === undefined) { + err.message = `No Axios response to reference - ${err.message}`; + throw err; + } + + if (response.status >= 400 && response.status !== 401) { + throw new BackstageHttpError('Failed to complete request', response); + } + } + + return false; + }; + + cleanupClient = () => { + this.abortController.abort(); + axiosInstance.interceptors.request.eject(this.axiosInterceptorId); + }; +} + +export const coderClientApiRef = createApiRef<CoderClient>({ + id: `${CODER_API_REF_ID_PREFIX}.coder-client`, +}); diff --git a/plugins/backstage-plugin-coder/src/api/errors.ts b/plugins/backstage-plugin-coder/src/api/errors.ts new file mode 100644 index 00000000..854d7283 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/errors.ts @@ -0,0 +1,32 @@ +import type { AxiosHeaderValue, AxiosResponse } from 'axios'; + +/** + * Makes it easier to expose HTTP responses in the event of errors and also + * gives TypeScript a faster way to type-narrow on those errors + */ +export class BackstageHttpError extends Error { + #failedResponse: AxiosResponse; + + constructor(errorMessage: string, failedResponse: AxiosResponse) { + super(errorMessage); + this.name = 'BackstageHttpError'; + this.#failedResponse = failedResponse; + } + + static isInstance(value: unknown): value is BackstageHttpError { + return value instanceof BackstageHttpError; + } + + get status(): number { + return this.#failedResponse.status; + } + + get ok(): boolean { + const status = this.#failedResponse.status; + return !(status >= 200 && status <= 299); + } + + get contentType(): AxiosHeaderValue | undefined { + return this.#failedResponse.headers['Content-Type']; + } +} diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index f36ce2da..e2dbc4a5 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -1,5 +1,5 @@ /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */ -import { ConfigReader } from '@backstage/core-app-api'; +import { ConfigReader, FrontendHostDiscovery } from '@backstage/core-app-api'; import { MockConfigApi, MockErrorApi } from '@backstage/test-utils'; import type { ScmIntegrationRegistry } from '@backstage/integration'; /* eslint-enable @backstage/no-undeclared-imports */ @@ -17,7 +17,10 @@ import { import { ScmIntegrationsApi } from '@backstage/integration-react'; import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; -import { IdentityApi } from '@backstage/core-plugin-api'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { CoderAuthApi } from '../api/Auth'; +import { CoderClient } from '../api/CoderClient'; +import { CoderTokenAuth } from '../api/CoderTokenAuth'; /** * This is the key that Backstage checks from the entity data to determine the @@ -285,3 +288,71 @@ export function getMockLocalStorage( }, }; } + +export function getMockDiscoveryApi(): DiscoveryApi { + return FrontendHostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: mockBackstageUrlRoot, + }, + }), + ); +} + +export function getMockCoderTokenAuth(): CoderTokenAuth { + return new CoderTokenAuth({ + localStorage: getMockLocalStorage(), + }); +} + +type SetupCoderClientInputs = Readonly<{ + discoveryApi?: DiscoveryApi; + identityApi?: IdentityApi; + authApi?: CoderAuthApi; +}>; + +type SetupCoderClientResult = Readonly<{ + authApi: CoderAuthApi; + coderClientApi: CoderClient; +}>; + +/** + * @todo 2024-04-23 - This is a workaround for making sure that the Axios + * instance doesn't get overloaded with different request interceptors from each + * test case. + * + * The SDK value we'll eventually be grabbing (and its Axios instance) are + * basically set up as a global singleton, which means that you get less ability + * to do test isolation. Better to make the updates upstream so that the SDK + * can be re-instantiated for different tests, and then have the garbage + * collector handle disposing all of the values. + */ +const activeClients = new Set<CoderClient>(); +afterEach(() => { + activeClients.forEach(client => client.cleanupClient()); + activeClients.clear(); +}); + +/** + * Gives back a Coder Client, its underlying auth implementation, and also + * handles cleanup for the Coder client between test runs. + * + * It is strongly recommended that you create all Coder clients via this + * function. + */ +export function setupCoderClient({ + authApi = getMockCoderTokenAuth(), + discoveryApi = getMockDiscoveryApi(), + identityApi = getMockIdentityApi(), +}: SetupCoderClientInputs): SetupCoderClientResult { + const mockCoderClientApi = new CoderClient({ + apis: { identityApi, discoveryApi, authApi }, + }); + + activeClients.add(mockCoderClientApi); + + return { + authApi, + coderClientApi: mockCoderClientApi, + }; +} diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 99db7c1b..aac3964d 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -19,8 +19,12 @@ import { mockCoderAuthToken, mockBackstageProxyEndpoint as root, } from './mockBackstageData'; -import type { Workspace, WorkspacesResponse } from '../typesConstants'; -import { CODER_AUTH_HEADER_KEY } from '../api'; +import { defaultCoderClientConfigOptions } from '../api/CoderClient'; +import type { + Workspace, + WorkspacesResponse, + UserLoginType, +} from '../typesConstants'; type RestResolver<TBody extends DefaultBodyType = any> = ResponseResolver< RestRequest<TBody>, @@ -33,6 +37,18 @@ export type RestResolverMiddleware<TBody extends DefaultBodyType = any> = ( ) => RestResolver<TBody>; const defaultMiddleware = [ + function validateCoderSessionToken(handler) { + return (req, res, ctx) => { + const headerKey = defaultCoderClientConfigOptions.authHeaderKey; + const token = req.headers.get(headerKey); + + if (token === mockCoderAuthToken) { + return handler(req, res, ctx); + } + + return res(ctx.status(401)); + }; + }, function validateBearerToken(handler) { return (req, res, ctx) => { const tokenRe = /^Bearer (.+)$/; @@ -59,7 +75,7 @@ export function wrapInDefaultMiddleware<TBody extends DefaultBodyType = any>( }, resolver); } -function wrappedGet<TBody extends DefaultBodyType = any>( +export function wrappedGet<TBody extends DefaultBodyType = any>( path: string, resolver: RestResolver<TBody>, ): RestHandler { @@ -103,14 +119,20 @@ const mainTestHandlers: readonly RestHandler[] = [ }, ), - // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me`, (req, res, ctx) => { - const token = req.headers.get(CODER_AUTH_HEADER_KEY); - if (token === mockCoderAuthToken) { - return res(ctx.status(200)); - } + // This is the old dummy request used to verify a user's auth status + wrappedGet(`${root}/users/me`, (_, res, ctx) => { + return res(ctx.status(200)); + }), - return res(ctx.status(401)); + // This is the new dummy request used to verify a user's auth status that the + // Coder SDK will use + wrappedGet(`${root}/users/me/login-type`, (_req, res, ctx) => { + return res( + ctx.status(200), + ctx.json<UserLoginType>({ + login_type: 'token', + }), + ); }), ]; diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index ece3bbb0..777c9bc0 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -92,3 +92,28 @@ export type WorkspacesResponse = Output<typeof workspacesResponseSchema>; export type WorkspaceBuildParameter = Output< typeof workspaceBuildParameterSchema >; + +/** + * @todo Replace these type definitions with the full Coder SDK API once we have + * that built out and ready to import into other projects. Be sure to export out + * all type definitions from the API under a single namespace, too. (e.g., + * export type * as CoderSdkTypes from 'coder-ts-sdk') + * + * The types for RawCoderSdkApi should only include functions/values that exist + * on the current "pseudo-SDK" found in the main coder/coder repo, and that are + * likely to carry over to the full SDK. + * + * @see {@link https://github.com/coder/coder/tree/main/site/src/api} + */ +export type WorkspacesRequest = Readonly<{ + after_id?: string; + limit?: number; + offset?: number; + q?: string; +}>; + +// Return value used for the dummy requests used to verify a user's auth status +// for the Coder token auth logic +export type UserLoginType = Readonly<{ + login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +}>; diff --git a/yarn.lock b/yarn.lock index a60186cb..d11bc7d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8713,7 +8713,7 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0": +"@types/react-dom@*", "@types/react-dom@^18.0.0": version "18.2.21" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== @@ -8751,7 +8751,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": +"@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0": version "18.2.64" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== @@ -8760,6 +8760,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^16.13.1 || ^17.0.0": + version "17.0.80" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.80.tgz#a5dfc351d6a41257eb592d73d3a85d3b7dbcbb41" + integrity sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "^0.16" + csstype "^3.0.2" + "@types/request@^2.47.1", "@types/request@^2.48.8": version "2.48.12" resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.12.tgz#0f590f615a10f87da18e9790ac94c29ec4c5ef30" @@ -8787,7 +8796,7 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*": +"@types/scheduler@*", "@types/scheduler@^0.16": version "0.16.8" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== @@ -9951,6 +9960,15 @@ axios@^1.0.0, axios@^1.4.0, axios@^1.6.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" + integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -13519,6 +13537,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -21890,16 +21913,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21973,7 +21987,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -21987,13 +22001,6 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23797,7 +23804,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23815,15 +23822,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"