diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 96bebe6a5..060542562 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,13 +1,13 @@ -import { loadI18n, pickBestLocale } from '$lib/i18n'; -import { AUTH_COOKIE_NAME, getUser, isAuthn } from '$lib/user' -import { apiVersion } from '$lib/util/version'; -import { redirect, type Handle, type HandleFetch, type HandleServerError, type RequestEvent, type ResolveOptions } from '@sveltejs/kit' -import { ensureErrorIsTraced, traceRequest, traceFetch } from '$lib/otel/otel.server' -import { env } from '$env/dynamic/private'; -import { getErrorMessage, validateFetchResponse } from './hooks.shared'; -import { setViewMode } from './routes/(authenticated)/shared'; +import {loadI18n, pickBestLocale} from '$lib/i18n'; +import {AUTH_COOKIE_NAME, getUser, isAuthn} from '$lib/user'; +import {apiVersion} from '$lib/util/version'; +import {redirect, type Handle, type HandleFetch, type HandleServerError, type RequestEvent, type ResolveOptions} from '@sveltejs/kit'; +import {ensureErrorIsTraced, traceRequest, traceFetch} from '$lib/otel/otel.server'; +import {env} from '$env/dynamic/private'; +import {getErrorMessage, validateFetchResponse} from './hooks.shared'; +import {setViewMode} from './routes/(authenticated)/shared'; import * as setCookieParser from 'set-cookie-parser'; -import { AUTHENTICATED_ROOT, UNAUTHENTICATED_ROOT } from './routes'; +import {AUTHENTICATED_ROOT, UNAUTHENTICATED_ROOT} from './routes'; const PUBLIC_ROUTE_ROOTS = [ UNAUTHENTICATED_ROOT, @@ -29,7 +29,7 @@ async function initI18n(event: RequestEvent): Promise { } // eslint-disable-next-line func-style -export const handle: Handle = ({ event, resolve }) => { +export const handle: Handle = ({event, resolve}) => { console.log(`HTTP request: ${event.request.method} ${event.request.url}`); event.locals.getUser = () => getUser(event.cookies); return traceRequest(event, async () => { @@ -39,14 +39,21 @@ export const handle: Handle = ({ event, resolve }) => { filterSerializedResponseHeaders: () => true, } - const { cookies, route: { id: routeId } } = event; + const {cookies, route: {id: routeId}} = event; if (!routeId) { redirect(307, '/'); } else if (PUBLIC_ROUTE_ROOTS.includes(getRoot(routeId))) { - return resolve(event, options); + const response = await resolve(event, options); + if (routeId.endsWith('/logout')) { + response.headers.set('Clear-Site-Data', '"cache"'); + } + return response; } else if (!isAuthn(cookies)) { const relativePath = event.url.href.substring(event.url.origin.length); - redirect(307, `/login?ReturnUrl=${encodeURIComponent(relativePath)}`); + if (relativePath !== '/') + redirect(307, `/login?ReturnUrl=${encodeURIComponent(relativePath)}`); + else + redirect(307, '/login'); } //when at home if (routeId == `/${AUTHENTICATED_ROOT}`) { @@ -54,7 +61,7 @@ export const handle: Handle = ({ event, resolve }) => { } return resolve(event, options); - }) + }); }; // eslint-disable-next-line func-style diff --git a/frontend/src/routes/(authenticated)/admin/+page.svelte b/frontend/src/routes/(authenticated)/admin/+page.svelte index 541ed2529..c9e600101 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.svelte +++ b/frontend/src/routes/(authenticated)/admin/+page.svelte @@ -52,6 +52,7 @@ $: tab = $queryParamValues.tab; const loadingUsers = derived(navigating, (nav) => { + if (!nav?.to?.route.id?.endsWith('/admin')) return false; const fromUrl = nav?.from?.url; return fromUrl && userFilterKeys.some((key) => (fromUrl.searchParams.get(key) ?? defaultQueryParamValues[key])?.toString() !== $queryParamValues[key]); diff --git a/frontend/src/routes/(authenticated)/logout/+server.ts b/frontend/src/routes/(authenticated)/logout/+server.ts deleted file mode 100644 index 002fcbf1f..000000000 --- a/frontend/src/routes/(authenticated)/logout/+server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {RequestEvent} from './$types' -import { logout } from '$lib/user' -import { redirect } from '@sveltejs/kit' - -export function GET({cookies}: RequestEvent) : void { - logout(cookies) - - redirect(303, '/login'); -} diff --git a/frontend/src/routes/(unauthenticated)/logout/+server.ts b/frontend/src/routes/(unauthenticated)/logout/+server.ts new file mode 100644 index 000000000..9ffc801f5 --- /dev/null +++ b/frontend/src/routes/(unauthenticated)/logout/+server.ts @@ -0,0 +1,9 @@ +import type {RequestEvent} from './$types'; +import {logout} from '$lib/user'; +import {redirect} from '@sveltejs/kit'; + +export function GET({cookies}: RequestEvent): void { + logout(cookies); + + redirect(303, '/login'); +} diff --git a/frontend/tests/components/authenticatedDrawer.ts b/frontend/tests/components/authenticatedDrawer.ts new file mode 100644 index 000000000..b9e18a505 --- /dev/null +++ b/frontend/tests/components/authenticatedDrawer.ts @@ -0,0 +1,19 @@ +import {type Locator, type Page} from '@playwright/test'; +import {BaseComponent} from './baseComponent'; +import {LoginPage} from '../pages/loginPage'; + +export class AuthenticatedDrawer extends BaseComponent { + + get logoutLink(): Locator { + return this.componentLocator.getByRole('link', { name: 'Log out' }); + } + + constructor(page: Page) { + super(page, page.locator(`.drawer .drawer-side:has-text("Log out")`)); + } + + async logout(): Promise { + await this.logoutLink.click(); + return new LoginPage(this.page).waitFor(); + } +} diff --git a/frontend/tests/emailWorkflow.test.ts b/frontend/tests/emailWorkflow.test.ts index 988c85934..7b735d3b0 100644 --- a/frontend/tests/emailWorkflow.test.ts +++ b/frontend/tests/emailWorkflow.test.ts @@ -16,7 +16,7 @@ const userIdsToDelete: string[] = []; test.afterEach(async ({ page }) => { if (userIdsToDelete.length > 0) { - await loginAs(page.request, 'admin', defaultPassword); + await loginAs(page.request, 'admin'); for (const userId of userIdsToDelete) { await deleteUser(page.request, userId); } @@ -110,7 +110,7 @@ test('forgot password', async ({ page, tempUser }) => { test('register via new-user invitation email', async ({ page, mailboxFactory }) => { test.setTimeout(TEST_TIMEOUT_2X); - await loginAs(page.request, 'admin', defaultPassword); + await loginAs(page.request, 'admin'); const adminPage = await new AdminDashboardPage(page).goto(); const projectPage = await adminPage.openProject('Sena 3', 'sena-3'); diff --git a/frontend/tests/errorHandling.test.ts b/frontend/tests/errorHandling.test.ts index c7d6c0385..935e372ce 100644 --- a/frontend/tests/errorHandling.test.ts +++ b/frontend/tests/errorHandling.test.ts @@ -58,7 +58,7 @@ test('catch fetch 500 and error dialog', async ({ page }) => { //we want to verify that once we get the 500 in GQL we can still navigate to another page test('client-side gql 500 does not break the application', async ({ page }) => { - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); await new SandboxPage(page).goto(); // Create promise first before triggering the action const responsePromise = page.waitForResponse('/api/graphql'); @@ -72,7 +72,7 @@ test('client-side gql 500 does not break the application', async ({ page }) => { }); test('server-side gql 500 does not kill the server', async ({ page }) => { - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); await new SandboxPage(page).goto({ urlEnd: '?ssr-gql-500', expectErrorResponse: true }); await expect(page.locator(':text-matches("Unexpected Execution Error", "g")').first()).toBeVisible(); // we've verified that a 500 occured, now we verify that the server is still alive @@ -89,7 +89,7 @@ test('server page load 401 is redirected to login', async ({ context }) => { test('client page load 401 is redirected to login', async ({ page }) => { // TODO: Move this to a setup script as recommended by https://playwright.dev/docs/auth - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); const adminDashboardPage = await new AdminDashboardPage(page).goto(); // Now mess up the login cookie and watch the redirect @@ -122,14 +122,14 @@ test('can catch 403 errors from goto in new tab', async ({ page, context }) => { }); test('page load 403 is redirected to home', async ({ page }) => { - await loginAs(page.request, 'manager', testEnv.defaultPassword); + await loginAs(page.request, 'manager'); await new SandboxPage(page).goto(); await page.getByText('Goto page load 403', {exact: true}).click(); await new UserDashboardPage(page).waitFor(); }); test('page load 403 in new tab is redirected to home', async ({ page }) => { - await loginAs(page.request, 'manager', testEnv.defaultPassword); + await loginAs(page.request, 'manager'); await new SandboxPage(page).goto(); const pagePromise = page.context().waitForEvent('page'); await page.getByText('Goto page load 403 new tab').click(); diff --git a/frontend/tests/fixtures.ts b/frontend/tests/fixtures.ts index a1266df15..9221c4b26 100644 --- a/frontend/tests/fixtures.ts +++ b/frontend/tests/fixtures.ts @@ -108,7 +108,7 @@ export const test = base.extend({ }); await use(tempUser); const context = await browser.newContext(); - await loginAs(context.request, 'admin', testEnv.defaultPassword); + await loginAs(context.request, 'admin'); await deleteUser(context.request, tempUser.id); await context.close(); }, diff --git a/frontend/tests/logout.test.ts b/frontend/tests/logout.test.ts new file mode 100644 index 000000000..f67c92f22 --- /dev/null +++ b/frontend/tests/logout.test.ts @@ -0,0 +1,12 @@ +import {AdminDashboardPage} from './pages/adminDashboardPage'; +import {loginAs} from './utils/authHelpers'; +import {test} from './fixtures'; + +test('Back button after logout redirects back to login page', async ({page}) => { + await loginAs(page.request, 'admin'); + const adminPage = await new AdminDashboardPage(page).goto(); + const drawer = await adminPage.openDrawer(); + const loginPage = await drawer.logout(); + await page.goBack(); + await loginPage.waitFor(); +}); diff --git a/frontend/tests/pages/authenticatedBasePage.ts b/frontend/tests/pages/authenticatedBasePage.ts index 3a7154646..f71a1cf99 100644 --- a/frontend/tests/pages/authenticatedBasePage.ts +++ b/frontend/tests/pages/authenticatedBasePage.ts @@ -1,21 +1,32 @@ -import type { Locator, Page } from '@playwright/test'; -import { BasePage } from './basePage'; -import { EmailVerificationAlert } from '../components/emailVerificationAlert'; +import type {Locator, Page} from '@playwright/test'; + +import {AuthenticatedDrawer} from '../components/authenticatedDrawer'; +import {BasePage} from './basePage'; +import {EmailVerificationAlert} from '../components/emailVerificationAlert'; export class AuthenticatedBasePage extends BasePage { readonly emailVerificationAlert: EmailVerificationAlert; + private drawerToggle: Locator; + constructor(page: Page, locator: Locator | Locator[], url?: string) { + const drawerToggle = page.locator('label .i-mdi-account-circle'); if (Array.isArray(locator)) { - locator = [page.locator('label .i-mdi-account-circle'), ...locator]; + locator = [drawerToggle, ...locator]; } else { - locator = [page.locator('label .i-mdi-account-circle'), locator]; + locator = [drawerToggle, locator]; } super(page, locator, url); + this.drawerToggle = drawerToggle; this.emailVerificationAlert = new EmailVerificationAlert(page); } clickHome(): Promise { return this.page.locator('.breadcrumbs').getByRole('link', {name: 'Home'}).click(); } + + async openDrawer(): Promise { + await this.drawerToggle.click(); + return new AuthenticatedDrawer(this.page).waitFor(); + } } diff --git a/frontend/tests/recreateProject.test.ts b/frontend/tests/recreateProject.test.ts index 9c0817997..667b24a3a 100644 --- a/frontend/tests/recreateProject.test.ts +++ b/frontend/tests/recreateProject.test.ts @@ -9,7 +9,7 @@ import { expect } from '@playwright/test'; test('delete and recreate project', async ({ page, uniqueTestId }) => { // Step 1: Login - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); const adminDashboard = await new AdminDashboardPage(page).goto(); // Step 2: Create a new project diff --git a/frontend/tests/resetProject.test.ts b/frontend/tests/resetProject.test.ts index 359909e7a..f90d90b5d 100644 --- a/frontend/tests/resetProject.test.ts +++ b/frontend/tests/resetProject.test.ts @@ -23,7 +23,7 @@ test('reset project and upload .zip file', async ({ page, tempProject, tempDir } const allZeroHash = '0000000000000000000000000000000000000000'; // Step 1: Populate project with known initial state - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); const adminDashboardPage = await new AdminDashboardPage(page).goto(); await adminDashboardPage.clickProject(tempProject.name); const projectPage = await new ProjectPage(page, tempProject.name, tempProject.code).waitFor(); diff --git a/frontend/tests/utils/authHelpers.ts b/frontend/tests/utils/authHelpers.ts index 97df271d7..b51d5c0ee 100644 --- a/frontend/tests/utils/authHelpers.ts +++ b/frontend/tests/utils/authHelpers.ts @@ -1,12 +1,12 @@ -import { expect, type APIRequestContext, type Page } from '@playwright/test'; -import { serverBaseUrl } from '../envVars'; -import { RegisterPage } from '../pages/registerPage'; -import { UserDashboardPage } from '../pages/userDashboardPage'; -import type { UUID } from 'crypto'; -import { executeGql } from './gqlHelpers'; -import { LoginPage } from '../pages/loginPage'; +import {expect, type APIRequestContext, type Page} from '@playwright/test'; +import {defaultPassword, serverBaseUrl} from '../envVars'; +import {RegisterPage} from '../pages/registerPage'; +import {UserDashboardPage} from '../pages/userDashboardPage'; +import type {UUID} from 'crypto'; +import {executeGql} from './gqlHelpers'; +import {LoginPage} from '../pages/loginPage'; -export async function loginAs(api: APIRequestContext, emailOrUsername: string, password: string): Promise { +export async function loginAs(api: APIRequestContext, emailOrUsername: string, password: string = defaultPassword): Promise { const loginData = { emailOrUsername: emailOrUsername, password: password, diff --git a/frontend/tests/viewerPage.test.ts b/frontend/tests/viewerPage.test.ts index 9c1c01fa2..0237b33dd 100644 --- a/frontend/tests/viewerPage.test.ts +++ b/frontend/tests/viewerPage.test.ts @@ -1,5 +1,3 @@ -import * as testEnv from './envVars'; - import {UserDashboardPage} from './pages/userDashboardPage'; import {ViewerPage} from './pages/viewerPage'; import {expect} from '@playwright/test'; @@ -10,7 +8,7 @@ test.describe('Viewer Page', () => { test('navigate to viewer', async ({page}) => { // Step 1: Login - await loginAs(page.request, 'editor', testEnv.defaultPassword); + await loginAs(page.request, 'editor'); const userDashboard = await new UserDashboardPage(page).goto(); // Step 2: Click through to viewer @@ -21,7 +19,7 @@ test.describe('Viewer Page', () => { test('find entry', async ({page}) => { // Step 1: Login to viewer - await loginAs(page.request, 'editor', testEnv.defaultPassword); + await loginAs(page.request, 'editor'); const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3').goto(); await viewerPage.dismissAboutDialog(); @@ -51,7 +49,7 @@ test.describe('Viewer Page', () => { test('entry details', async ({page}) => { // Step 1: Login to viewer at entry "thembe" - await loginAs(page.request, 'editor', testEnv.defaultPassword); + await loginAs(page.request, 'editor'); const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3') .goto({urlEnd: '?entryId=49cc9257-90c7-4fe0-a9e0-2c8d72aa5e2b&search=animal'}); await viewerPage.dismissAboutDialog();