Skip to content

chore(Coder plugin): add tests for auth fallback UI #129

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 65 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
9db4173
wip: commit progress on fallback UI
Parkreiner May 14, 2024
50d9008
chore: move dep to peer dependencies
Parkreiner May 14, 2024
c57d4c9
wip: commit more progress
Parkreiner May 15, 2024
e567260
wip: more progress
Parkreiner May 15, 2024
61ee528
refactor: consolidate card logic
Parkreiner May 15, 2024
9b16cc2
fix: update component tracking hooks
Parkreiner May 15, 2024
344593d
fix: add a11y landmark to auth fallback
Parkreiner May 15, 2024
2041f17
wip: commit more style progress
Parkreiner May 15, 2024
43cfd52
wip: commit more progress
Parkreiner May 15, 2024
560d009
wip: more progress
Parkreiner May 17, 2024
98faa12
wip: cleanup current approach
Parkreiner May 17, 2024
a73b1b1
wip: commit progress on observer approach
Parkreiner May 17, 2024
0011dd2
wip: fix infinite loop for mutation logic
Parkreiner May 17, 2024
b8bc1ef
fix: prevent padding patches from firing too often
Parkreiner May 17, 2024
2112995
fix: improve scoping of style overrides
Parkreiner May 17, 2024
d04274c
chore: finish intial version of fallback stylling
Parkreiner May 17, 2024
ae85984
fix: tidy up types
Parkreiner May 17, 2024
6e18204
wip: create initial version of dialog form
Parkreiner May 17, 2024
f097f9b
wip: commit progress on modal
Parkreiner May 20, 2024
e1a70dd
chore: finish styling for modal wrapper
Parkreiner May 21, 2024
c4101f6
fix: update padding for FormDialog
Parkreiner May 21, 2024
f8bb852
wip: start extracting out auth form
Parkreiner May 21, 2024
98c96af
fix: add missing barrel export file
Parkreiner May 21, 2024
a404ddb
fix: make sure that auth form isn't dismissed early
Parkreiner May 21, 2024
af2f383
fix: update auth imports
Parkreiner May 21, 2024
5b04628
fix: update spacing for auth modal
Parkreiner May 22, 2024
8ae3d14
refactor: clean up auth provider for clarity
Parkreiner May 22, 2024
1b1f8c4
docs: rewrite comment for clarity
Parkreiner May 22, 2024
1ea39e0
fix: improve granularity between official Coder components and user c…
Parkreiner May 22, 2024
9236ca4
fix: update all internal consumers of useCoderAuth
Parkreiner May 22, 2024
b90ebd0
wip: commit initial version of useCoderQuery helper hook
Parkreiner May 22, 2024
12a7b3e
refactor: rename hooks to avoid confusion
Parkreiner May 22, 2024
c146788
fix: update exports for plugin
Parkreiner May 22, 2024
e7e7755
docs: fill in incomplete sentence
Parkreiner May 22, 2024
8493080
wip: commit initial version of useMutation wrapper
Parkreiner May 22, 2024
8087e19
refactor: extract retry factor into global constant
Parkreiner May 22, 2024
20fa328
merge
Parkreiner May 22, 2024
ca257ae
fix: add explicit return type to useCoderMutation
Parkreiner May 22, 2024
a92ec05
wip: start extracting auth logic into better reusable components
Parkreiner May 22, 2024
028c6b7
fix: update card to have better styling for body
Parkreiner May 22, 2024
aea9345
wip: commit progress on style refactoring
Parkreiner May 22, 2024
ceb65e1
fix: update vertical padding for card wrapper
Parkreiner May 22, 2024
8d1343f
chore: delete CoderAuthWrapper component
Parkreiner May 22, 2024
91e0702
fix: update styling for auth fallback
Parkreiner May 22, 2024
b644eec
chore: shrink size of PR
Parkreiner May 22, 2024
b127195
fix: update imports
Parkreiner May 22, 2024
7118896
docs: add comment about description setup
Parkreiner May 22, 2024
b364e0c
fix: remove risk of runtime render errors in auth form
Parkreiner May 23, 2024
39f9a06
fix: update imports
Parkreiner May 23, 2024
1148eae
fix: update font sizes to use relative units
Parkreiner May 23, 2024
5aca4f7
fix: update peer dependencies for react-dom
Parkreiner May 23, 2024
b04482e
refactor: clean up auth revalidation logic
Parkreiner May 23, 2024
063006c
wip: start updating tests for new code changes
Parkreiner May 23, 2024
1702755
fix: adding missing test case for auth card
Parkreiner May 23, 2024
1d928bb
wip: commit progress on auth form test updates
Parkreiner May 23, 2024
34f027f
fix: removal vetigal properties
Parkreiner May 23, 2024
e0a4760
fix: get all CoderAuthForm tests passing
Parkreiner May 23, 2024
15a4e22
fix: update import for auth hook in test
Parkreiner May 23, 2024
ba43e8a
wip: commit progress on tests
Parkreiner May 23, 2024
06c128a
wip: commit more test progress
Parkreiner May 23, 2024
fc9f83d
wip: commit progress on fixing test isolation
Parkreiner May 23, 2024
44801af
chore: finish all tests for auth fallback logic
Parkreiner May 24, 2024
26c65ed
refactor: clean up setup code and revise comments
Parkreiner May 24, 2024
719a9a6
Merge branch 'main' into mes/auth-fallback-tests
Parkreiner May 24, 2024
c553dc3
fix: remove risks of race conditions in tests
Parkreiner May 28, 2024
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
Original file line number Diff line number Diff line change
@@ -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(
<TestApiProvider apis={[[coderClientApiRef, coderClient]]}>
<QueryClientProvider client={queryClient}>
<CoderAppConfigProvider appConfig={mockAppConfig}>
<CoderAuthProvider>{children}</CoderAuthProvider>
</CoderAppConfigProvider>
</QueryClientProvider>
</TestApiProvider>,
{
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 <p>Authenticated? {auth.isAuthenticated ? 'Yes!' : 'No...'}</p>;
}

function MockEndUserComponent() {
const auth = useEndUserCoderAuth();
return <p>Authenticated? {auth.isAuthenticated ? 'Yes!' : 'No...'}</p>;
}

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(
<>
<MockTrackedComponent />
<MockEndUserComponent />
</>,
);

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(<MockTrackedComponent />);

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(<MockEndUserComponent />);
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLElement>(BACKSTAGE_APP_ROOT_ID);

type StyleKey = 'landmarkWrapper' | 'dialogButton' | 'logo';
type StyleProps = Readonly<{ isDialogOpen: boolean }>;

Expand Down Expand Up @@ -455,6 +449,10 @@ function FallbackAuthUi() {
const fallbackRef = useRef<HTMLElement>(null);
useLayoutEffect(() => {
const fallback = fallbackRef.current;
const mainAppRoot = document.querySelector<HTMLElement>(
BACKSTAGE_APP_ROOT_ID,
);
Comment on lines +452 to +454
Copy link
Member Author

Choose a reason for hiding this comment

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

Just decided to tuck this in here to keep the layout effect logic more self-contained


const mainAppContainer =
mainAppRoot?.querySelector<HTMLElement>('main') ?? null;

Expand Down