Skip to content

Commit fc08cc4

Browse files
committed
Simplify auth
1 parent 25e4a50 commit fc08cc4

4 files changed

Lines changed: 47 additions & 46 deletions

File tree

cmd/ui/tests/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ A Playwright **setup project** that runs once per Playwright invocation before a
2424
2. Delegates to `bh-playwright-testing` helpers to manage per-theme auth sessions
2525

2626
> About auth session management:\
27-
> `loginAndSnapshotThemes` logs in via `/ui/login`, snapshots `storageState` to `playwright/.auth/user-light.json`, toggles dark mode (waiting for the throttled `persistedState` write to land), then snapshots `playwright/.auth/user-dark.json`. Capturing both theme snapshots from a single session avoids the parallel-login race where two setups as the same user would invalidate each other's session. Downstream projects load the snapshot that matches their theme via `authStorageStateFor(theme)`.
27+
> `loginAndSnapshotThemes` logs in via `/ui/login`, then writes the canonical light/dark `persistedState` (carrying over the session token from login) directly into localStorage to snapshot `storageState` to `playwright/.auth/user-light.json` and `playwright/.auth/user-dark.json`. Capturing both theme snapshots from a single session avoids the parallel-login race where two setups as the same user would invalidate each other's session. Downstream projects load the snapshot that matches their theme via `authStorageStateFor(theme)`.
2828
2929
## Conventions
3030

cmd/ui/tests/a11y/download-collectors.a11y.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ test.describe('Download Collectors page accessibility', () => {
2323
}, testInfo) => {
2424
await page.goto('/ui/download-collectors');
2525

26-
// Wait for page label to load
27-
await page.getByRole('heading', { name: 'Download Collectors' }).waitFor({ state: 'visible' });
26+
// Wait for collector download button to appear
27+
await page.getByRole('button', { name: 'Download SharpHound' }).waitFor({ state: 'visible' });
2828

2929
const results = await makeAxeBuilder().include('#content-wrapper').analyze();
3030
await expectNoAccessibilityViolations(testInfo, results, { page });

cmd/ui/tests/global.setup.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
//
1515
// SPDX-License-Identifier: Apache-2.0
1616

17-
import { expect, test as setup } from 'bh-playwright-testing';
17+
import { test as setup } from 'bh-playwright-testing';
1818
import { loginAndSnapshotThemes } from 'bh-playwright-testing/auth';
1919
import { installGraphHasDataStub } from 'bh-playwright-testing/stubs/graph-has-data';
2020
import { authStorageStateFor, type Theme } from 'bh-playwright-testing/themes';
@@ -41,15 +41,5 @@ setup('Generate and cache auth state for light and dark theme', async ({ page })
4141
username,
4242
password,
4343
storageStatePathFor: (theme: Theme) => path.resolve(__dirname, '..', authStorageStateFor(theme)),
44-
// React Query still goes through one undefined render before the stub lands, and MUI's
45-
// Dialog backdrop persists during its close transition. If the dialog raced ahead of us,
46-
// dismiss it so the dark-mode click is not intercepted.
47-
dismissPostLogin: async (page) => {
48-
const cancel = page.getByTestId('confirmation-dialog_button-no');
49-
await cancel.click({ timeout: 2_000 }).catch(() => {
50-
/* dialog never opened — nothing to dismiss */
51-
});
52-
await expect(cancel).toBeHidden();
53-
},
5444
});
5545
});

packages/javascript/bh-playwright-testing/src/auth.ts

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,44 @@ import type { Page } from '@playwright/test';
1818
import { expect } from '@playwright/test';
1919
import type { Theme } from './themes';
2020

21+
const persistedState = {
22+
auth: {
23+
sessionToken: '',
24+
},
25+
global: {
26+
view: {
27+
darkMode: false,
28+
autoRunQueries: true,
29+
notifications: [],
30+
exploreLayout: 'sequential',
31+
isExploreTableSelected: false,
32+
isExploreLayoutSelected: false,
33+
selectedExploreTableColumns: { kind: true, label: true, objectId: true, isTierZero: true },
34+
pinnedExploreTableColumns: ['action-menu', 'kind', 'label'],
35+
timeoutSetting: false,
36+
isExploreGraphHighlight: true,
37+
isExploreGraphLabelClip: true,
38+
},
39+
},
40+
};
41+
2142
export type LoginAndSnapshotThemesOptions = {
2243
page: Page;
2344
username: string;
2445
password: string;
2546
// Resolves a per-theme `storageState` file path. Callers typically wrap
2647
// `authStorageStateFor(theme)` from `./themes` with `path.resolve(...)`.
2748
storageStatePathFor: (theme: Theme) => string;
28-
// Optional hook for dismissing any post-login overlay (e.g. an upload dialog) that
29-
// could intercept the dark-mode toggle click. Invoked after the login form clears.
30-
dismissPostLogin?: (page: Page) => Promise<void>;
3149
};
3250

3351
// Login once and snapshot `storageState` for both light and dark themes.
3452
//
3553
// Capturing both snapshots from a single session avoids the parallel-login race where two
36-
// setups as the same user would invalidate each other's session. This relies on the BH
37-
// shared UI shell (login form labels "Email Address" / "Password", the LOGIN submit button,
38-
// the `global_nav-dark-mode` toggle, and the `persistedState` localStorage key written by
39-
// the global store's throttled subscriber) being identical across consumers.
54+
// setups as the same user would invalidate each other's session. Rather than toggling the
55+
// theme through the UI, we write the canonical light/dark `persistedState` directly into
56+
// localStorage (carrying over the real session token from login) before each snapshot.
4057
export async function loginAndSnapshotThemes(opts: LoginAndSnapshotThemesOptions): Promise<void> {
41-
const { page, username, password, storageStatePathFor, dismissPostLogin } = opts;
58+
const { page, username, password, storageStatePathFor } = opts;
4259

4360
try {
4461
await page.goto('/ui/login');
@@ -48,33 +65,27 @@ export async function loginAndSnapshotThemes(opts: LoginAndSnapshotThemesOptions
4865
// Use `exact` as some environments also have "Login Via SSO".
4966
await page.getByRole('button', { name: 'LOGIN', exact: true }).click();
5067

51-
// Race the success path (navigation off /ui/login) against the most common failure
52-
// path (rejected creds leave you on /ui/login with an inline toast). This surfaces
53-
// auth failures in ~15s with a clear message.
68+
// Rejected creds leave you on /ui/login, so waiting to navigate away surfaces auth
69+
// failures in ~15s with a clear message.
5470
await expect(page).not.toHaveURL(/\/ui\/login(\?|$)/, { timeout: 15_000 });
55-
await page.getByTestId('global_nav-dark-mode').waitFor({ state: 'visible' });
56-
57-
if (dismissPostLogin) {
58-
await dismissPostLogin(page);
59-
}
6071

61-
// Snapshot light first, while the UI is still in its default theme.
62-
await page.context().storageState({ path: storageStatePathFor('light') });
72+
// Carry over the real session token written by the login flow so the snapshots stay
73+
// authenticated after we overwrite `persistedState`.
74+
const sessionToken = await page.evaluate(() => {
75+
const raw = localStorage.getItem('persistedState');
76+
return raw ? JSON.parse(raw)?.auth?.sessionToken ?? '' : '';
77+
});
6378

64-
// Click the nav dark-mode toggle. The inner Switch is `inert`, so the click handler
65-
// lives on the parent item targeted by this test id.
66-
await page.getByTestId('global_nav-dark-mode').click();
67-
68-
// The store persists via a throttled (1s) subscriber. Poll until the new value lands
69-
// in localStorage before snapshotting storageState, so the next test boots in dark mode.
70-
await expect
71-
.poll(async () => {
72-
const raw = await page.evaluate(() => localStorage.getItem('persistedState'));
73-
return raw ? JSON.parse(raw)?.global?.view?.darkMode : null;
74-
})
75-
.toBe(true);
76-
77-
await page.context().storageState({ path: storageStatePathFor('dark') });
79+
for (const [theme, darkMode] of [
80+
['light', false],
81+
['dark', true],
82+
] as const) {
83+
const state = structuredClone(persistedState);
84+
state.global.view.darkMode = darkMode;
85+
state.auth.sessionToken = sessionToken;
86+
await page.evaluate((next) => localStorage.setItem('persistedState', JSON.stringify(next)), state);
87+
await page.context().storageState({ path: storageStatePathFor(theme) });
88+
}
7889
} catch (error) {
7990
throw new Error(`Auth setup failed at ${page.url()}: ${(error as Error).message}`, { cause: error });
8091
}

0 commit comments

Comments
 (0)