Skip to content

Commit 71eed71

Browse files
authored
Merge pull request #1265 from joshunrau/pw
adjust playwright setup for automatic authentication on app pages
2 parents c8a7147 + c7c3208 commit 71eed71

File tree

16 files changed

+163
-51
lines changed

16 files changed

+163
-51
lines changed

apps/api/src/core/decorators/throttle-login-request.decorator.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import { DEFAULT_LOGIN_REQUEST_THROTTLER_LIMIT, DEFAULT_LOGIN_REQUEST_THROTTLER_
1010
const LOGIN_REQUEST_THROTTLER_LIMIT = $NumberLike
1111
.pipe(z.number().int().positive())
1212
.default(DEFAULT_LOGIN_REQUEST_THROTTLER_LIMIT)
13-
.parse(process.env.LOGIN_REQUEST_THROTTLER_LIMIT);
13+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
14+
.parse(process.env.LOGIN_REQUEST_THROTTLER_LIMIT || undefined);
1415

1516
const LOGIN_REQUEST_THROTTLER_TTL = $NumberLike
1617
.pipe(z.number().int().positive())
1718
.default(DEFAULT_LOGIN_REQUEST_THROTTLER_TTL)
18-
.parse(process.env.LOGIN_REQUEST_THROTTLER_TTL);
19+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
20+
.parse(process.env.LOGIN_REQUEST_THROTTLER_TTL || undefined);
1921

2022
export function ThrottleLoginRequest() {
2123
return applyDecorators(

apps/web/src/store/slices/auth.slice.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,40 @@ import { jwtDecode } from 'jwt-decode';
55

66
import type { AuthSlice, SliceCreator } from '../types';
77

8-
export const createAuthSlice: SliceCreator<AuthSlice> = (set) => ({
9-
accessToken: null,
10-
changeGroup: (group) => {
11-
set({ currentGroup: group, currentSession: null });
12-
},
13-
currentGroup: null,
14-
currentUser: null,
15-
login: (accessToken) => {
16-
const { groups, permissions, ...rest } = jwtDecode<TokenPayload>(accessToken);
17-
const ability = createMongoAbility<PureAbility<[AppAction, AppSubjectName], any>>(permissions);
18-
set({
19-
accessToken,
20-
currentGroup: groups[0],
21-
currentUser: { ability, groups, ...rest }
22-
});
23-
},
24-
logout: () => {
25-
window.location.reload();
26-
}
27-
});
8+
const parseAccessToken = (accessToken: string) => {
9+
const { groups, permissions, ...rest } = jwtDecode<TokenPayload>(accessToken);
10+
const ability = createMongoAbility<PureAbility<[AppAction, AppSubjectName], any>>(permissions);
11+
return {
12+
currentGroup: groups[0],
13+
currentUser: {
14+
ability,
15+
groups,
16+
...rest
17+
}
18+
};
19+
};
20+
21+
export const createAuthSlice: SliceCreator<AuthSlice> = (set) => {
22+
const accessToken = window.__PLAYWRIGHT_ACCESS_TOKEN__ ?? null;
23+
const initialState = accessToken ? parseAccessToken(accessToken) : null;
24+
25+
return {
26+
accessToken,
27+
changeGroup: (group) => {
28+
set({ currentGroup: group, currentSession: null });
29+
},
30+
currentGroup: initialState?.currentGroup ?? null,
31+
currentUser: initialState?.currentUser ?? null,
32+
login: (accessToken) => {
33+
const { currentGroup, currentUser } = parseAccessToken(accessToken);
34+
set({
35+
accessToken,
36+
currentGroup,
37+
currentUser
38+
});
39+
},
40+
logout: () => {
41+
window.location.reload();
42+
}
43+
};
44+
};

apps/web/src/vite-env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,8 @@ interface ImportMeta {
2727
readonly env: ImportMetaEnv;
2828
}
2929

30+
interface Window {
31+
__PLAYWRIGHT_ACCESS_TOKEN__?: string;
32+
}
33+
3034
declare const __RELEASE__: import('@opendatacapture/schemas/setup').ReleaseInfo;

testing/e2e/playwright.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import * as crypto from 'node:crypto';
12
import * as path from 'node:path';
23

34
import { parseNumber, range, unwrap } from '@douglasneuroinformatics/libjs';
45
import { defineConfig, devices } from '@playwright/test';
56
import type { Project } from '@playwright/test';
67

8+
import { AUTH_STORAGE_DIR } from './src/helpers/constants';
9+
710
import type { BrowserTarget, ProjectMetadata } from './src/helpers/types';
811

912
const apiPort = parseNumber(process.env.API_DEV_SERVER_PORT);
@@ -26,6 +29,8 @@ const browsers: { target: BrowserTarget; use: Project['use'] }[] = [
2629
] as const;
2730

2831
export default defineConfig({
32+
globalSetup: path.resolve(import.meta.dirname, 'src/global/global.setup.ts'),
33+
globalTeardown: path.resolve(import.meta.dirname, 'src/global/global.teardown.ts'),
2934
maxFailures: 1,
3035
outputDir: path.resolve(import.meta.dirname, '.playwright/output'),
3136
projects: [
@@ -46,9 +51,12 @@ export default defineConfig({
4651
},
4752
...unwrap(range(1, 4)).flatMap((i) => {
4853
return browsers.map((browser) => {
54+
const browserId = crypto.createHash('sha256').update(browser.target).digest('hex');
4955
return {
5056
dependencies: i === 1 ? ['Global Setup'] : [`${i - 1}.x - ${browser.target}`],
5157
metadata: {
58+
authStorageFile: path.resolve(AUTH_STORAGE_DIR, `${browserId}.json`),
59+
browserId,
5260
browserTarget: browser.target
5361
} satisfies ProjectMetadata,
5462
name: `${i}.x - ${browser.target}`,

testing/e2e/src/1.1-auth.spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
1+
import { users } from './helpers/data';
12
import { expect, test } from './helpers/fixtures';
23

3-
// no need to test the actual login here as it is tested on every other page
4+
import type { ProjectAuth } from './helpers/types';
45

56
test.describe('redirects', () => {
67
test('should redirect to login page from the index page', async ({ page }) => {
78
await page.goto('/');
89
await expect(page).toHaveURL('/auth/login');
910
});
1011
});
12+
13+
test.describe('login page', () => {
14+
test('should allow logging in', async ({ getPageModel, getProjectMetadata, setProjectAuth }) => {
15+
const credentials = users[getProjectMetadata('browserTarget')];
16+
const loginPage = await getPageModel('/auth/login');
17+
const loginResponsePromise = loginPage.$ref.waitForResponse((response) => {
18+
return response.url().endsWith('/v1/auth/login') && response.status() === 200;
19+
});
20+
21+
await loginPage.fillLoginForm(credentials);
22+
await loginPage.expect.toHaveURL('/dashboard');
23+
24+
const response = await loginResponsePromise;
25+
const body = await response.json();
26+
expect(typeof body.accessToken).toBe('string');
27+
28+
await setProjectAuth({ accessToken: body.accessToken as string } satisfies ProjectAuth);
29+
});
30+
});
Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import { expect, test } from './helpers/fixtures';
22

33
test.describe('dashboard', () => {
4-
test.beforeEach(async ({ login, page }) => {
5-
await login();
6-
await expect(page).toHaveURL('/dashboard');
7-
});
8-
94
test('should display the dashboard header', async ({ getPageModel }) => {
10-
const dashboardPage = getPageModel('/dashboard');
5+
const dashboardPage = await getPageModel('/dashboard');
116
await expect(dashboardPage.pageHeader).toBeVisible();
127
await expect(dashboardPage.pageHeader).toContainText('Dashboard');
138
});

testing/e2e/src/global/global.setup.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ test.describe.serial(() => {
1313
await expect(response.json()).resolves.toMatchObject({ isSetup: false });
1414
});
1515
test('should successfully setup', async ({ getPageModel }) => {
16-
const setupPage = getPageModel('/setup');
17-
await setupPage.goto('/setup');
16+
const setupPage = await getPageModel('/setup');
1817
await setupPage.fillSetupForm(initAppOptions);
1918
await setupPage.expect.toHaveURL('/auth/login');
2019
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as fs from 'node:fs';
2+
3+
import type { FullConfig } from '@playwright/test';
4+
5+
import { AUTH_STORAGE_DIR } from '../helpers/constants';
6+
7+
export default function setup(_config: FullConfig) {
8+
if (!fs.existsSync(AUTH_STORAGE_DIR)) {
9+
fs.mkdirSync(AUTH_STORAGE_DIR, { recursive: true });
10+
}
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as fs from 'node:fs';
2+
3+
import { AUTH_STORAGE_DIR } from '../helpers/constants';
4+
5+
export default function teardown() {
6+
if (fs.existsSync(AUTH_STORAGE_DIR)) {
7+
fs.rmSync(AUTH_STORAGE_DIR, { force: true, recursive: true });
8+
}
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import * as path from 'node:path';
2+
3+
export const AUTH_STORAGE_DIR = path.resolve(import.meta.dirname, '../../.playwright/auth');

0 commit comments

Comments
 (0)