diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.test.tsx
new file mode 100644
index 00000000..1ccf2e75
--- /dev/null
+++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.test.tsx
@@ -0,0 +1,193 @@
+/**
+ * @file Ideally all the files in CoderProvider could be treated as
+ * implementation details, and we could have a single test file for all the
+ * pieces joined together.
+ *
+ * But because the auth is so complicated, it helps to have tests just for it.
+ *
+ * ---
+ * @todo 2024-05-23 - Right now, there is a conflict when you try to call
+ * Backstage's wrapInTestApp and also try to mock out localStorage. They
+ * interact in such a way that when you call your mock's getItem method, it
+ * immediately throws an error. Didn't want to get rid of wrapInTestApp, because
+ * then that would require removing official Backstage components. wrapInTestApp
+ * sets up a lot of things behind the scenes like React Router that these
+ * components rely on.
+ *
+ * Figured out a way to write the tests that didn't involve extra mocking, but
+ * it's not as airtight as it could be. Definitely worth opening an issue with
+ * the Backstage repo upstream.
+ */
+import React, { type ReactNode } from 'react';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import {
+ BACKSTAGE_APP_ROOT_ID,
+ CoderAuthProvider,
+ TOKEN_STORAGE_KEY,
+ useEndUserCoderAuth,
+ useInternalCoderAuth,
+} from './CoderAuthProvider';
+import { CoderClient, coderClientApiRef } from '../../api/CoderClient';
+import {
+ getMockConfigApi,
+ getMockDiscoveryApi,
+ getMockIdentityApi,
+ mockAppConfig,
+ mockCoderAuthToken,
+} from '../../testHelpers/mockBackstageData';
+import { UrlSync } from '../../api/UrlSync';
+import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { getMockQueryClient } from '../../testHelpers/setup';
+import userEvent from '@testing-library/user-event';
+import { CoderAppConfigProvider } from './CoderAppConfigProvider';
+
+afterEach(() => {
+ jest.restoreAllMocks();
+
+ const appRootNodes = document.querySelectorAll(BACKSTAGE_APP_ROOT_ID);
+ appRootNodes.forEach(node => node.remove());
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
+});
+
+function renderAuthProvider(children?: ReactNode) {
+ const urlSync = new UrlSync({
+ apis: {
+ configApi: getMockConfigApi(),
+ discoveryApi: getMockDiscoveryApi(),
+ },
+ });
+
+ const queryClient = getMockQueryClient();
+ const identityApi = getMockIdentityApi();
+
+ // Can't use initialToken property, because then the Auth provider won't be
+ // aware of it. When testing for UI authentication, we need to feed the token
+ // from localStorage to the provider, which then feeds it to the client while
+ // keeping track of the React state changes
+ const coderClient = new CoderClient({
+ apis: { urlSync, identityApi },
+ });
+
+ const mockAppRoot = document.createElement('div');
+ mockAppRoot.id = BACKSTAGE_APP_ROOT_ID;
+ document.body.append(mockAppRoot);
+
+ return render(
+
+
+
+ {children}
+
+
+ ,
+ {
+ baseElement: mockAppRoot,
+ wrapper: ({ children }) => wrapInTestApp(children),
+ },
+ );
+}
+
+describe(`${CoderAuthProvider.name}`, () => {
+ /**
+ * @todo Figure out what general auth state logic could benefit from tests
+ * and put them in a separate describe block
+ */
+ describe('Fallback auth input', () => {
+ const fallbackTriggerMatcher = /Authenticate with Coder/;
+
+ function MockTrackedComponent() {
+ const auth = useInternalCoderAuth();
+ return
Authenticated? {auth.isAuthenticated ? 'Yes!' : 'No...'}
;
+ }
+
+ function MockEndUserComponent() {
+ const auth = useEndUserCoderAuth();
+ return Authenticated? {auth.isAuthenticated ? 'Yes!' : 'No...'}
;
+ }
+
+ it('Will never display the auth fallback if the user is already authenticated', async () => {
+ /**
+ * Not 100% sure on why this works. We load the token in before rendering,
+ * so that we can bring the token into the UI on the initial render
+ *
+ * But as part of that rendering, wrapInTestApp eventually replaces
+ * localStorage with a mock. So the initial state is getting carried over
+ * to the mock? Or maybe it's not really a full mock and is just a spy?
+ */
+ window.localStorage.setItem(TOKEN_STORAGE_KEY, mockCoderAuthToken);
+
+ renderAuthProvider(
+ <>
+
+
+ >,
+ );
+
+ await waitFor(() => {
+ const authenticatedComponents = screen.getAllByText(/Yes!/);
+ expect(authenticatedComponents).toHaveLength(2);
+ });
+
+ const authFallbackTrigger = screen.queryByRole('button', {
+ name: fallbackTriggerMatcher,
+ });
+
+ expect(authFallbackTrigger).not.toBeInTheDocument();
+ });
+
+ it('Will display an auth fallback input when there are no components on screen that use Coder plugin logic', async () => {
+ renderAuthProvider();
+ const authFallbackTrigger = await screen.findByRole('button', {
+ name: fallbackTriggerMatcher,
+ });
+
+ expect(authFallbackTrigger).toBeInTheDocument();
+ });
+
+ it('Will never display the auth fallback if there are tracked Coder components that let you submit auth info in other ways', async () => {
+ renderAuthProvider();
+
+ const authFallbackTrigger = screen.queryByRole('button', {
+ name: fallbackTriggerMatcher,
+ });
+
+ await screen.findByText(/No\.\.\./);
+ expect(authFallbackTrigger).not.toBeInTheDocument();
+ });
+
+ it(`Does not consider users of ${useEndUserCoderAuth.name} when deciding whether to show fallback auth UI`, async () => {
+ renderAuthProvider();
+ const authFallbackTrigger = await screen.findByRole('button', {
+ name: fallbackTriggerMatcher,
+ });
+
+ expect(authFallbackTrigger).toBeInTheDocument();
+ });
+
+ it('Lets the user go through a full authentication flow via the fallback auth UI', async () => {
+ renderAuthProvider();
+ const user = userEvent.setup();
+
+ const authFallbackTrigger = await screen.findByRole('button', {
+ name: fallbackTriggerMatcher,
+ });
+
+ await user.click(authFallbackTrigger);
+ const authForm = await screen.findByRole('form', {
+ name: /Authenticate with Coder/,
+ });
+
+ const tokenInput = await within(authForm).findByLabelText(/Auth token/);
+ await user.type(tokenInput, mockCoderAuthToken);
+
+ const submitButton = await within(authForm).findByRole('button', {
+ name: /Authenticate/,
+ });
+
+ await user.click(submitButton);
+ expect(authForm).not.toBeInTheDocument();
+ expect(authFallbackTrigger).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx
index c9b6fbb1..2d8ec99a 100644
--- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx
+++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx
@@ -27,9 +27,9 @@ import { coderClientApiRef } from '../../api/CoderClient';
import { CoderLogo } from '../CoderLogo';
import { CoderAuthFormDialog } from '../CoderAuthFormDialog';
-const BACKSTAGE_APP_ROOT_ID = '#root';
+export const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token';
+export const BACKSTAGE_APP_ROOT_ID = '#root';
const FALLBACK_UI_OVERRIDE_CLASS_NAME = 'backstage-root-override';
-const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token';
// Handles auth edge case where a previously-valid token can't be verified. Not
// immediately removing token to provide better UX in case someone's internet
@@ -409,12 +409,6 @@ function generateAuthState({
};
}
-// Have to get the root of the React application to adjust its dimensions when
-// we display the fallback UI. Sadly, we can't assert that the root is always
-// defined from outside a UI component, because throwing any errors here would
-// blow up the entire Backstage application, and wreck all the other plugins
-const mainAppRoot = document.querySelector(BACKSTAGE_APP_ROOT_ID);
-
type StyleKey = 'landmarkWrapper' | 'dialogButton' | 'logo';
type StyleProps = Readonly<{ isDialogOpen: boolean }>;
@@ -455,6 +449,10 @@ function FallbackAuthUi() {
const fallbackRef = useRef(null);
useLayoutEffect(() => {
const fallback = fallbackRef.current;
+ const mainAppRoot = document.querySelector(
+ BACKSTAGE_APP_ROOT_ID,
+ );
+
const mainAppContainer =
mainAppRoot?.querySelector('main') ?? null;