diff --git a/e2e-tests/tests/account-home.spec.ts b/e2e-tests/tests/account-home.spec.ts index d1bb7363..205ace99 100644 --- a/e2e-tests/tests/account-home.spec.ts +++ b/e2e-tests/tests/account-home.spec.ts @@ -1,10 +1,35 @@ import { expect, test } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; +import { loginWithCftIdam } from "../utils/cft-idam-helpers.js"; test.describe("Account Home Page", () => { + // Authenticate before each test + test.beforeEach(async ({ page }) => { + // Navigate to sign-in page + await page.goto("/sign-in"); + + // Select HMCTS account option + const hmctsRadio = page.getByRole("radio", { name: /with a myhmcts account/i }); + await hmctsRadio.check(); + + // Click continue + const continueButton = page.getByRole("button", { name: /continue/i }); + await continueButton.click(); + + // Perform CFT IDAM login + await loginWithCftIdam( + page, + process.env.CFT_VALID_TEST_ACCOUNT!, + process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! + ); + + // Should be redirected to account-home after successful login + await expect(page).toHaveURL(/\/account-home/); + }); + test.describe("Page Load and Content", () => { test("should load the account home page", async ({ page }) => { - await page.goto("/account-home"); + // Already on account-home from beforeEach await expect(page).toHaveTitle(/Dashboard - Your account/i); }); diff --git a/e2e-tests/tests/cft-idam/cft-idam-session.spec.ts b/e2e-tests/tests/cft-idam/cft-idam-session.spec.ts deleted file mode 100644 index a17d0c7d..00000000 --- a/e2e-tests/tests/cft-idam/cft-idam-session.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { loginWithCftIdam, assertAuthenticated, assertNotAuthenticated } from '../../utils/cft-idam-helpers.js'; - -test.describe('CFT IDAM Session Management', () => { - test('Session persists across page navigations', async ({ page }) => { - // Skip if credentials are not configured - test.skip(!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM test credentials not configured'); - - await page.goto('/sign-in'); - - // Select HMCTS account and continue - const hmctsRadio = page.getByRole('radio', { name: /with a myhmcts account/i }); - await hmctsRadio.check(); - const continueButton = page.getByRole('button', { name: /continue/i }); - await continueButton.click(); - - // Login with CFT IDAM - await loginWithCftIdam( - page, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! - ); - await assertAuthenticated(page); - - // Navigate to different pages - session should persist - await page.goto('/account-home'); - await assertAuthenticated(page); - - await page.goto('/'); - await assertAuthenticated(page); - - await page.goto('/account-home'); - await assertAuthenticated(page); - }); - - test('Session persists after page reload', async ({ page }) => { - // Skip if credentials are not configured - test.skip(!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM test credentials not configured'); - - await page.goto('/sign-in'); - - // Select HMCTS account and continue - const hmctsRadio = page.getByRole('radio', { name: /with a myhmcts account/i }); - await hmctsRadio.check(); - const continueButton = page.getByRole('button', { name: /continue/i }); - await continueButton.click(); - - // Login with CFT IDAM - await loginWithCftIdam( - page, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! - ); - await assertAuthenticated(page); - - // Reload the page - session should persist - await page.reload(); - await assertAuthenticated(page); - }); - - test('Logout clears session and redirects to logged out page', async ({ page }) => { - // Skip if credentials are not configured - test.skip(!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM test credentials not configured'); - - await page.goto('/sign-in'); - - // Select HMCTS account and continue - const hmctsRadio = page.getByRole('radio', { name: /with a myhmcts account/i }); - await hmctsRadio.check(); - const continueButton = page.getByRole('button', { name: /continue/i }); - await continueButton.click(); - - // Login with CFT IDAM - await loginWithCftIdam( - page, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! - ); - await assertAuthenticated(page); - - // Logout - await page.goto('/logout'); - - // Should redirect to session-logged-out page - await expect(page).toHaveURL('/session-logged-out'); - }); - - test('Multiple concurrent sessions from same user are handled correctly', async ({ browser }) => { - // Skip if credentials are not configured - test.skip(!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM test credentials not configured'); - - const context1 = await browser.newContext(); - const page1 = await context1.newPage(); - const context2 = await browser.newContext(); - const page2 = await context2.newPage(); - - // Login in first context - await page1.goto('/sign-in'); - const hmctsRadio1 = page1.getByRole('radio', { name: /with a myhmcts account/i }); - await hmctsRadio1.check(); - const continueButton1 = page1.getByRole('button', { name: /continue/i }); - await continueButton1.click(); - await loginWithCftIdam( - page1, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! - ); - await assertAuthenticated(page1); - - // Login in second context - await page2.goto('/sign-in'); - const hmctsRadio2 = page2.getByRole('radio', { name: /with a myhmcts account/i }); - await hmctsRadio2.check(); - const continueButton2 = page2.getByRole('button', { name: /continue/i }); - await continueButton2.click(); - await loginWithCftIdam( - page2, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! - ); - await assertAuthenticated(page2); - - // Both sessions should remain valid - await page1.reload(); - await assertAuthenticated(page1); - await page2.reload(); - await assertAuthenticated(page2); - - await context1.close(); - await context2.close(); - }); - - test.skip('Accessing protected route without session redirects to sign-in', async ({ page }) => { - // Note: This test is currently skipped because the application allows access to /account-home without authentication - // TODO: Implement authentication middleware to protect this route - await page.goto('/account-home'); - - // Should redirect to sign-in page - await expect(page).toHaveURL(/sign-in/); - await assertNotAuthenticated(page); - }); -}); diff --git a/e2e-tests/tests/cft-idam/cft-idam-login.spec.ts b/e2e-tests/tests/cft-idam/cft-idam.spec.ts similarity index 62% rename from e2e-tests/tests/cft-idam/cft-idam-login.spec.ts rename to e2e-tests/tests/cft-idam/cft-idam.spec.ts index 34a4f054..3cdbfc7b 100644 --- a/e2e-tests/tests/cft-idam/cft-idam-login.spec.ts +++ b/e2e-tests/tests/cft-idam/cft-idam.spec.ts @@ -7,9 +7,6 @@ import { loginWithCftIdam, assertAuthenticated, assertNotAuthenticated, logout } test.describe('CFT IDAM Login Flow', () => { test('Valid user can select HMCTS account and login via CFT IDAM', async ({ page }) => { - // Skip if credentials are not configured - test.skip(!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM test credentials not configured'); - await page.goto('/sign-in'); // Select HMCTS account option @@ -36,40 +33,8 @@ test.describe('CFT IDAM Login Flow', () => { await assertAuthenticated(page); }); - test('Valid user maintains session after login', async ({ page }) => { - // Skip if credentials are not configured - test.skip(!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM test credentials not configured'); - - await page.goto('/sign-in'); - - // Login via CFT IDAM - const hmctsRadio = page.getByRole('radio', { name: /with a myhmcts account/i }); - await hmctsRadio.check(); - const continueButton = page.getByRole('button', { name: /continue/i }); - await continueButton.click(); - - await loginWithCftIdam( - page, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! - ); - - await expect(page).toHaveURL(/\/account-home/); - - // Navigate to another page - session should persist - await page.goto('/'); - await assertAuthenticated(page); - - // Navigate to account-home again - should not require re-authentication - await page.goto('/account-home'); - await expect(page).toHaveURL(/\/account-home/); - }); - test('User with rejected role (citizen) is redirected to cft-rejected page', async ({ page }) => { // Note: The "invalid" account is actually valid but has a rejected role (citizen) - // Skip if credentials are not configured - test.skip(!process.env.CFT_INVALID_TEST_ACCOUNT || !process.env.CFT_INVALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM invalid test credentials not configured'); - await page.goto('/sign-in'); // Select HMCTS account @@ -93,8 +58,8 @@ test.describe('CFT IDAM Login Flow', () => { await expect(heading).toBeVisible(); }); - - test('Language parameter is preserved through CFT IDAM redirect', async ({ page }) => { + test('Language and query parameters are preserved through CFT IDAM flow', async ({ page }) => { + // Test 1: Language parameter is preserved through CFT IDAM redirect await page.goto('/sign-in?lng=cy'); // Verify we're in Welsh mode @@ -108,51 +73,65 @@ test.describe('CFT IDAM Login Flow', () => { await continueButton.click(); // Check that language parameter is included in CFT IDAM redirect - const currentUrl = page.url(); + let currentUrl = page.url(); expect(currentUrl).toMatch(/lng=cy|ui_locales=cy/); + + // Test 2: CFT login preserves language parameter in redirect + await page.goto('/cft-login?lng=cy'); + await page.waitForLoadState('networkidle'); + currentUrl = page.url(); + expect(currentUrl).toMatch(/ui_locales=cy/); + + // Test 3: CFT rejected page displays Welsh content correctly + await page.goto('/cft-rejected?lng=cy'); + + // Verify language toggle shows English + const languageToggle = page.locator('.language'); + await expect(languageToggle).toContainText('English'); + + // Check for Welsh content + const welshHeading = page.locator('h1'); + await expect(welshHeading).toBeVisible(); + + // Check return link still works + const signInLink = page.getByRole('link', { name: /yn ôl.*fewngofnodi|return.*sign/i }); + await expect(signInLink).toHaveAttribute('href', '/sign-in'); + + // Run accessibility checks in Welsh + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .disableRules(['target-size', 'link-name']) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); }); - test.skip('Direct access to protected resource redirects to sign-in', async ({ page }) => { - // Note: This test is currently skipped because the application allows access to /account-home without authentication - // TODO: Implement authentication middleware to protect this route + test('Authorization flow handles errors and protected resource access', async ({ page }) => { + // Test 1: Direct access to protected resource redirects to sign-in await page.goto('/account-home'); - - // Should redirect to sign-in page (unauthenticated) await expect(page).toHaveURL(/sign-in/); await assertNotAuthenticated(page); - }); - test('Authorization flow handles missing code parameter', async ({ page }) => { - // Directly access the CFT IDAM return URL without code parameter + // Test 2: Authorization flow handles missing code parameter await page.goto('/cft-login/return'); - - // Should redirect to sign-in with error await expect(page).toHaveURL(/sign-in.*error/); - - // Verify error parameter is present - const url = new URL(page.url()); + let url = new URL(page.url()); expect(url.searchParams.get('error')).toBeTruthy(); - }); - test('Authorization flow handles invalid code parameter', async ({ page }) => { - // Access the CFT IDAM return URL with invalid code + // Test 3: Authorization flow handles invalid code parameter await page.goto('/cft-login/return?code=invalid-code-12345'); - - // Should redirect to sign-in with error (token exchange will fail) await expect(page).toHaveURL(/sign-in.*error/); - - // Verify error parameter is present - const url = new URL(page.url()); + url = new URL(page.url()); expect(url.searchParams.get('error')).toBeTruthy(); }); - test('User can logout after CFT IDAM login', async ({ page }) => { - // Skip if credentials are not configured - test.skip(!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD, 'CFT IDAM test credentials not configured'); + test('CFT IDAM login preserves original destination URL', async ({ page }) => { + // Try to access a specific protected page + await page.goto('/account-home'); - await page.goto('/sign-in'); + // Should redirect to sign-in + await expect(page).toHaveURL(/sign-in/); - // Login + // Complete login const hmctsRadio = page.getByRole('radio', { name: /with a myhmcts account/i }); await hmctsRadio.check(); const continueButton = page.getByRole('button', { name: /continue/i }); @@ -164,30 +143,28 @@ test.describe('CFT IDAM Login Flow', () => { process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! ); + // Should be redirected back to original destination await expect(page).toHaveURL(/\/account-home/); - - // Logout - await logout(page); - - // Verify logged out - await expect(page).toHaveURL('/session-logged-out'); - - // Note: /account-home is not currently protected by authentication middleware - // TODO: Once authentication middleware is implemented, add test to verify redirect to sign-in }); - test('CFT IDAM login preserves original destination URL', async ({ page }) => { - // Note: This test depends on the application's redirect behavior - // Skip if the feature is not implemented - test.skip(); + test('CFT login endpoint redirects to IDAM when configured', async ({ page }) => { + await page.goto('/cft-login'); + await page.waitForLoadState('networkidle'); - // Try to access a specific protected page - await page.goto('/admin-dashboard'); + // Should redirect to CFT IDAM + await expect(page).toHaveURL(/idam-web-public\.aat\.platform\.hmcts\.net/); + }); +}); - // Should redirect to sign-in +test.describe('CFT IDAM Session Management', () => { + test('Complete session lifecycle: login, navigation, reload, and logout', async ({ page }) => { + // Test 1: Accessing protected route without session redirects to sign-in + await page.goto('/account-home'); await expect(page).toHaveURL(/sign-in/); + await assertNotAuthenticated(page); - // Complete login + // Test 2: Login via CFT IDAM + await page.goto('/sign-in'); const hmctsRadio = page.getByRole('radio', { name: /with a myhmcts account/i }); await hmctsRadio.check(); const continueButton = page.getByRole('button', { name: /continue/i }); @@ -199,27 +176,70 @@ test.describe('CFT IDAM Login Flow', () => { process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! ); - // Should be redirected back to original destination - await expect(page).toHaveURL('/admin-dashboard'); + await expect(page).toHaveURL(/\/account-home/); + await assertAuthenticated(page); + + // Test 3: Session persists across page navigations + await page.goto('/'); + await assertAuthenticated(page); + await page.goto('/account-home'); + await expect(page).toHaveURL(/\/account-home/); + await assertAuthenticated(page); + + // Test 4: Session persists after page reload + await page.reload(); + await assertAuthenticated(page); + + // Test 5: User can logout + await logout(page); + await expect(page).toHaveURL('/session-logged-out'); }); - test('CFT IDAM is not available when disabled', async ({ page }) => { - // This test would need CFT IDAM to be disabled - // Skip in normal test runs - test.skip(); + test('Multiple concurrent sessions from same user are handled correctly', async ({ browser }) => { + const context1 = await browser.newContext(); + const page1 = await context1.newPage(); + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + // Login in first context + await page1.goto('/sign-in'); + const hmctsRadio1 = page1.getByRole('radio', { name: /with a myhmcts account/i }); + await hmctsRadio1.check(); + const continueButton1 = page1.getByRole('button', { name: /continue/i }); + await continueButton1.click(); + await loginWithCftIdam( + page1, + process.env.CFT_VALID_TEST_ACCOUNT!, + process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! + ); + await assertAuthenticated(page1); + + // Login in second context + await page2.goto('/sign-in'); + const hmctsRadio2 = page2.getByRole('radio', { name: /with a myhmcts account/i }); + await hmctsRadio2.check(); + const continueButton2 = page2.getByRole('button', { name: /continue/i }); + await continueButton2.click(); + await loginWithCftIdam( + page2, + process.env.CFT_VALID_TEST_ACCOUNT!, + process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! + ); + await assertAuthenticated(page2); - await page.goto('/cft-login'); + // Both sessions should remain valid + await page1.reload(); + await assertAuthenticated(page1); + await page2.reload(); + await assertAuthenticated(page2); - // Should return 503 error - await expect(page).toHaveURL(/cft-login/); - const content = await page.content(); - expect(content).toContain('503'); + await context1.close(); + await context2.close(); }); }); test.describe('CFT IDAM Rejected Page', () => { - test.skip('CFT rejected page displays correct content and is accessible', async ({ page }) => { - // Note: This test is skipped because it requires a user with rejected role + test('CFT rejected page displays correct content and is accessible', async ({ page }) => { // Enable when CFT_REJECTED_ROLE_ACCOUNT is available // Navigate to rejected page (normally after authentication) @@ -250,32 +270,7 @@ test.describe('CFT IDAM Rejected Page', () => { expect(accessibilityScanResults.violations).toEqual([]); }); - test.skip('CFT rejected page displays Welsh content correctly', async ({ page }) => { - // Note: This test is skipped because it requires a user with rejected role - - await page.goto('/cft-rejected?lng=cy'); - - // Verify language toggle shows English - const languageToggle = page.locator('.language'); - await expect(languageToggle).toContainText('English'); - - // Check for Welsh content - const heading = page.locator('h1'); - await expect(heading).toBeVisible(); - - // Check return link still works - const signInLink = page.getByRole('link'); - await expect(signInLink).toHaveAttribute('href', '/sign-in'); - - // Run accessibility checks in Welsh - const accessibilityScanResults = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) - .disableRules(['target-size', 'link-name']) - .analyze(); - expect(accessibilityScanResults.violations).toEqual([]); - }); - - test.skip('CFT rejected page supports keyboard navigation', async ({ page }) => { + test('CFT rejected page supports keyboard navigation', async ({ page }) => { await page.goto('/cft-rejected'); // Tab through interactive elements @@ -303,29 +298,3 @@ test.describe('CFT IDAM Rejected Page', () => { await expect(page).toHaveURL('/sign-in'); }); }); - -test.describe('CFT IDAM Configuration', () => { - test('CFT login endpoint is accessible when configured', async ({ page }) => { - await page.goto('/cft-login'); - - // Should redirect to CFT IDAM or show config error - await page.waitForTimeout(2000); - const currentUrl = page.url(); - - // Should either be on IDAM page or show 503 error - const isOnIdamPage = currentUrl.includes('idam-web-public.aat.platform.hmcts.net'); - const isOnErrorPage = currentUrl.includes('cft-login'); - - expect(isOnIdamPage || isOnErrorPage).toBe(true); - }); - - test('CFT login preserves query parameters in redirect', async ({ page }) => { - await page.goto('/cft-login?lng=cy&test=value'); - - await page.waitForTimeout(2000); - const currentUrl = page.url(); - - // Language parameter should be preserved - expect(currentUrl).toMatch(/lng=cy|ui_locales=cy/); - }); -}); diff --git a/e2e-tests/tests/sso/sso-authorisation.spec.ts b/e2e-tests/tests/sso/sso-authorisation.spec.ts deleted file mode 100644 index 410e684d..00000000 --- a/e2e-tests/tests/sso/sso-authorisation.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { loginWithSSO } from '../../utils/sso-helpers.js'; - -test.describe('SSO Role-Based Access Control', () => { - test('System Admin can access both dashboards', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/system-admin-dashboard'); - await page.goto('/admin-dashboard'); - await expect(page).toHaveURL('/admin-dashboard'); - }); - - test('Local Admin can access admin dashboard only', async ({ page }) => { - await page.goto('/admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_LOCAL_ADMIN_EMAIL!, - process.env.SSO_TEST_LOCAL_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/admin-dashboard'); - }); - - test('CTSC Admin can access admin dashboard only', async ({ page }) => { - await page.goto('/admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_CTSC_ADMIN_EMAIL!, - process.env.SSO_TEST_CTSC_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/admin-dashboard'); - }); - - test('User with no roles is redirected to rejected page', async ({ page }) => { - await page.goto('/admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_NO_ROLES_EMAIL!, - process.env.SSO_TEST_NO_ROLES_PASSWORD! - ); - await expect(page).toHaveURL('/sso-rejected'); - }); - - test('Local Admin cannot access system admin dashboard and is redirected to admin dashboard', async ({ page }) => { - await page.goto('/admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_LOCAL_ADMIN_EMAIL!, - process.env.SSO_TEST_LOCAL_ADMIN_PASSWORD! - ); - await page.goto('/system-admin-dashboard'); - await expect(page).toHaveURL('/admin-dashboard'); - }); - - test('CTSC Admin cannot access system admin dashboard and is redirected to admin dashboard', async ({ page }) => { - await page.goto('/admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_CTSC_ADMIN_EMAIL!, - process.env.SSO_TEST_CTSC_ADMIN_PASSWORD! - ); - await page.goto('/system-admin-dashboard'); - await expect(page).toHaveURL('/admin-dashboard'); - }); - - test('Role information is correctly set in user session', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/system-admin-dashboard'); - }); - - test('System Admin cannot access account-home and is redirected to admin-dashboard', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/system-admin-dashboard'); - await page.goto('/account-home'); - await expect(page).toHaveURL('/admin-dashboard'); - }); - - test('Local Admin cannot access account-home and is redirected to admin-dashboard', async ({ page }) => { - await page.goto('/admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_LOCAL_ADMIN_EMAIL!, - process.env.SSO_TEST_LOCAL_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/admin-dashboard'); - await page.goto('/account-home'); - await expect(page).toHaveURL('/admin-dashboard'); - }); - - test('CTSC Admin cannot access account-home and is redirected to admin-dashboard', async ({ page }) => { - await page.goto('/admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_CTSC_ADMIN_EMAIL!, - process.env.SSO_TEST_CTSC_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/admin-dashboard'); - await page.goto('/account-home'); - await expect(page).toHaveURL('/admin-dashboard'); - }); -}); diff --git a/e2e-tests/tests/sso/sso-login.spec.ts b/e2e-tests/tests/sso/sso-login.spec.ts deleted file mode 100644 index 6184a449..00000000 --- a/e2e-tests/tests/sso/sso-login.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { loginWithSSO, assertAuthenticated, assertNotAuthenticated } from '../../utils/sso-helpers.js'; - -test.describe('SSO Login Flow', () => { - test('System Admin can login via SSO and access system admin dashboard', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/system-admin-dashboard'); - await assertAuthenticated(page); - }); - - test('Local Admin can login via SSO and access admin dashboard', async ({ page }) => { - await page.goto('/admin-dashboard'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - await loginWithSSO( - page, - process.env.SSO_TEST_LOCAL_ADMIN_EMAIL!, - process.env.SSO_TEST_LOCAL_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/admin-dashboard'); - await assertAuthenticated(page); - }); - - test('CTSC Admin can login via SSO and access admin dashboard', async ({ page }) => { - await page.goto('/admin-dashboard'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - await loginWithSSO( - page, - process.env.SSO_TEST_CTSC_ADMIN_EMAIL!, - process.env.SSO_TEST_CTSC_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/admin-dashboard'); - await assertAuthenticated(page); - }); - - test('User with no roles can login but is redirected to rejected page', async ({ page }) => { - await page.goto('/admin-dashboard'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - await loginWithSSO( - page, - process.env.SSO_TEST_NO_ROLES_EMAIL!, - process.env.SSO_TEST_NO_ROLES_PASSWORD! - ); - await assertAuthenticated(page); - await expect(page).toHaveURL('/sso-rejected'); - }); - - test('Unauthenticated user is redirected to Azure AD login', async ({ page }) => { - await page.goto('/admin-dashboard'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - }); - - test('Login flow preserves original destination URL', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await expect(page).toHaveURL('/system-admin-dashboard'); - }); -}); diff --git a/e2e-tests/tests/sso/sso-session.spec.ts b/e2e-tests/tests/sso/sso-session.spec.ts deleted file mode 100644 index 5ccedcb9..00000000 --- a/e2e-tests/tests/sso/sso-session.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { loginWithSSO, assertAuthenticated, assertNotAuthenticated } from '../../utils/sso-helpers.js'; - -test.describe('SSO Session Management', () => { - test('Session persists across page navigations', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await assertAuthenticated(page); - await page.goto('/admin-dashboard'); - await assertAuthenticated(page); - await page.goto('/system-admin-dashboard'); - await assertAuthenticated(page); - }); - - test('Session persists after page reload', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await assertAuthenticated(page); - await page.reload(); - await assertAuthenticated(page); - }); - - test('Logout clears session and redirects to login', async ({ page }) => { - await page.goto('/system-admin-dashboard'); - await loginWithSSO( - page, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await assertAuthenticated(page); - await page.click('text=/logout|sign out/i'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - await assertNotAuthenticated(page); - }); - - test('Multiple concurrent sessions from same user are handled correctly', async ({ browser }) => { - const context1 = await browser.newContext(); - const page1 = await context1.newPage(); - const context2 = await browser.newContext(); - const page2 = await context2.newPage(); - - await page1.goto('/system-admin-dashboard'); - await loginWithSSO( - page1, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await assertAuthenticated(page1); - - await page2.goto('/system-admin-dashboard'); - await loginWithSSO( - page2, - process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, - process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! - ); - await assertAuthenticated(page2); - - await page1.reload(); - await assertAuthenticated(page1); - await page2.reload(); - await assertAuthenticated(page2); - - await context1.close(); - await context2.close(); - }); - - test('Accessing protected route without session redirects to login', async ({ page }) => { - await page.goto('/admin-dashboard'); - await expect(page).toHaveURL(/login.microsoftonline.com/); - }); -}); diff --git a/e2e-tests/tests/sso/sso.spec.ts b/e2e-tests/tests/sso/sso.spec.ts new file mode 100644 index 00000000..db509dc9 --- /dev/null +++ b/e2e-tests/tests/sso/sso.spec.ts @@ -0,0 +1,139 @@ +import { test, expect } from '@playwright/test'; +import { loginWithSSO, assertAuthenticated, assertNotAuthenticated } from '../../utils/sso-helpers.js'; + +test.describe('SSO Login Flow', () => { + test('System Admin can login via SSO and access both dashboards', async ({ page }) => { + await page.goto('/system-admin-dashboard'); + await expect(page).toHaveURL(/login.microsoftonline.com/); + await loginWithSSO( + page, + process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, + process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! + ); + await expect(page).toHaveURL('/system-admin-dashboard'); + await assertAuthenticated(page); + + // Verify System Admin can also access admin dashboard + await page.goto('/admin-dashboard'); + await expect(page).toHaveURL('/admin-dashboard'); + + // Verify System Admin cannot access account-home and is redirected to admin-dashboard + await page.goto('/account-home'); + await expect(page).toHaveURL('/admin-dashboard'); + }); + + test('Local Admin can login via SSO and access admin dashboard only', async ({ page }) => { + await page.goto('/admin-dashboard'); + await expect(page).toHaveURL(/login.microsoftonline.com/); + await loginWithSSO( + page, + process.env.SSO_TEST_LOCAL_ADMIN_EMAIL!, + process.env.SSO_TEST_LOCAL_ADMIN_PASSWORD! + ); + await expect(page).toHaveURL('/admin-dashboard'); + await assertAuthenticated(page); + + // Verify Local Admin cannot access system admin dashboard + await page.goto('/system-admin-dashboard'); + await expect(page).toHaveURL('/admin-dashboard'); + + // Verify Local Admin cannot access account-home + await page.goto('/account-home'); + await expect(page).toHaveURL('/admin-dashboard'); + }); + + test('CTSC Admin can login via SSO and access admin dashboard only', async ({ page }) => { + await page.goto('/admin-dashboard'); + await expect(page).toHaveURL(/login.microsoftonline.com/); + await loginWithSSO( + page, + process.env.SSO_TEST_CTSC_ADMIN_EMAIL!, + process.env.SSO_TEST_CTSC_ADMIN_PASSWORD! + ); + await expect(page).toHaveURL('/admin-dashboard'); + await assertAuthenticated(page); + + // Verify CTSC Admin cannot access system admin dashboard + await page.goto('/system-admin-dashboard'); + await expect(page).toHaveURL('/admin-dashboard'); + + // Verify CTSC Admin cannot access account-home + await page.goto('/account-home'); + await expect(page).toHaveURL('/admin-dashboard'); + }); + + test('User with no roles can login but is redirected to rejected page', async ({ page }) => { + await page.goto('/admin-dashboard'); + await expect(page).toHaveURL(/login.microsoftonline.com/); + await loginWithSSO( + page, + process.env.SSO_TEST_NO_ROLES_EMAIL!, + process.env.SSO_TEST_NO_ROLES_PASSWORD! + ); + await assertAuthenticated(page); + await expect(page).toHaveURL('/sso-rejected'); + }); + + test('Unauthenticated user is redirected to Azure AD login', async ({ page }) => { + await page.goto('/admin-dashboard'); + await expect(page).toHaveURL(/login.microsoftonline.com/); + }); +}); + +test.describe('SSO Session Management', () => { + test('Session persists across page navigations, reload, and logout clears session', async ({ page }) => { + await page.goto('/system-admin-dashboard'); + await loginWithSSO( + page, + process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, + process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! + ); + await assertAuthenticated(page); + + // Test navigation to different pages + await page.goto('/admin-dashboard'); + await assertAuthenticated(page); + await page.goto('/system-admin-dashboard'); + await assertAuthenticated(page); + + // Test page reload + await page.reload(); + await assertAuthenticated(page); + + // Test logout + await page.click('text=/logout|sign out/i'); + await expect(page).toHaveURL(/login.microsoftonline.com/); + await assertNotAuthenticated(page); + }); + + test('Multiple concurrent sessions from same user are handled correctly', async ({ browser }) => { + const context1 = await browser.newContext(); + const page1 = await context1.newPage(); + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await page1.goto('/system-admin-dashboard'); + await loginWithSSO( + page1, + process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, + process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! + ); + await assertAuthenticated(page1); + + await page2.goto('/system-admin-dashboard'); + await loginWithSSO( + page2, + process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, + process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD! + ); + await assertAuthenticated(page2); + + await page1.reload(); + await assertAuthenticated(page1); + await page2.reload(); + await assertAuthenticated(page2); + + await context1.close(); + await context2.close(); + }); +}); diff --git a/libs/auth/src/middleware/authenticate.test.ts b/libs/auth/src/middleware/authenticate.test.ts index 33871aab..7c67b707 100644 --- a/libs/auth/src/middleware/authenticate.test.ts +++ b/libs/auth/src/middleware/authenticate.test.ts @@ -1,8 +1,18 @@ import type { Request, Response } from "express"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { requireAuth } from "./authenticate.js"; +vi.mock("./redirect-helpers.js", () => ({ + redirectUnauthenticated: vi.fn() +})); + +import { redirectUnauthenticated } from "./redirect-helpers.js"; + describe("requireAuth middleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("should call next() if user is authenticated", () => { const middleware = requireAuth(); const req = { @@ -15,24 +25,21 @@ describe("requireAuth middleware", () => { middleware(req, res, next); expect(next).toHaveBeenCalledOnce(); + expect(redirectUnauthenticated).not.toHaveBeenCalled(); }); - it("should redirect to /auth/login if user is not authenticated", () => { + it("should call redirectUnauthenticated if user is not authenticated", () => { const middleware = requireAuth(); const req = { isAuthenticated: () => false, - originalUrl: "/system-admin-dashboard", - session: {} + originalUrl: "/admin-dashboard" } as unknown as Request; - const res = { - redirect: vi.fn() - } as unknown as Response; + const res = {} as Response; const next = vi.fn(); middleware(req, res, next); - expect(res.redirect).toHaveBeenCalledWith("/login"); + expect(redirectUnauthenticated).toHaveBeenCalledWith(req, res); expect(next).not.toHaveBeenCalled(); - expect(req.session.returnTo).toBe("/system-admin-dashboard"); }); }); diff --git a/libs/auth/src/middleware/authenticate.ts b/libs/auth/src/middleware/authenticate.ts index 6026fdfd..3b01bcda 100644 --- a/libs/auth/src/middleware/authenticate.ts +++ b/libs/auth/src/middleware/authenticate.ts @@ -1,8 +1,11 @@ import type { NextFunction, Request, RequestHandler, Response } from "express"; +import { redirectUnauthenticated } from "./redirect-helpers.js"; /** * Middleware to require authentication for protected routes - * Redirects unauthenticated users to the login page + * Redirects unauthenticated users to the appropriate login page + * - Admin pages redirect to SSO login when configured + * - Other pages redirect to account selection page * Saves the original requested URL for redirect after login */ export function requireAuth(): RequestHandler { @@ -11,10 +14,6 @@ export function requireAuth(): RequestHandler { return next(); } - // Save the original URL to redirect after login - req.session.returnTo = req.originalUrl; - - // Redirect to login page - res.redirect("/login"); + redirectUnauthenticated(req, res); }; } diff --git a/libs/auth/src/middleware/authorise.test.ts b/libs/auth/src/middleware/authorise.test.ts index c59bbec2..b44c672e 100644 --- a/libs/auth/src/middleware/authorise.test.ts +++ b/libs/auth/src/middleware/authorise.test.ts @@ -1,9 +1,18 @@ import { USER_ROLES } from "@hmcts/account"; import type { Request, Response } from "express"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { blockUserAccess, requireRole } from "./authorise.js"; +vi.mock("./redirect-helpers.js", () => ({ + redirectUnauthenticated: vi.fn() +})); + +import { redirectUnauthenticated } from "./redirect-helpers.js"; + describe("requireRole middleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); it("should call next for authenticated user with required role", () => { const middleware = requireRole([USER_ROLES.SYSTEM_ADMIN]); @@ -22,49 +31,40 @@ describe("requireRole middleware", () => { expect(next).toHaveBeenCalled(); }); - it("should redirect to /auth/login for unauthenticated user", () => { + it("should call redirectUnauthenticated for unauthenticated user", () => { const middleware = requireRole([USER_ROLES.SYSTEM_ADMIN]); const req = { isAuthenticated: () => false, user: undefined, - originalUrl: "/system-admin-dashboard", - session: {} + originalUrl: "/system-admin-dashboard" } as unknown as Request; - const res = { - redirect: vi.fn() - } as unknown as Response; - + const res = {} as Response; const next = vi.fn(); middleware(req, res, next); - expect(res.redirect).toHaveBeenCalledWith("/login"); - expect(req.session.returnTo).toBe("/system-admin-dashboard"); + expect(redirectUnauthenticated).toHaveBeenCalledWith(req, res); expect(next).not.toHaveBeenCalled(); }); - it("should redirect to /auth/login when user object is missing", () => { + it("should call redirectUnauthenticated when user object is missing", () => { const middleware = requireRole([USER_ROLES.SYSTEM_ADMIN]); const req = { isAuthenticated: () => true, user: null, - originalUrl: "/system-admin-dashboard", - session: {} + originalUrl: "/system-admin-dashboard" } as unknown as Request; - const res = { - redirect: vi.fn() - } as unknown as Response; - + const res = {} as Response; const next = vi.fn(); middleware(req, res, next); - expect(res.redirect).toHaveBeenCalledWith("/login"); - expect(req.session.returnTo).toBe("/system-admin-dashboard"); + expect(redirectUnauthenticated).toHaveBeenCalledWith(req, res); + expect(next).not.toHaveBeenCalled(); }); it("should redirect system admin to /system-admin-dashboard when lacking required role", () => { @@ -133,7 +133,7 @@ describe("requireRole middleware", () => { expect(next).not.toHaveBeenCalled(); }); - it("should redirect to /auth/login when user has no role", () => { + it("should redirect to /sign-in when user has no role", () => { const middleware = requireRole([USER_ROLES.SYSTEM_ADMIN]); const req = { @@ -151,7 +151,7 @@ describe("requireRole middleware", () => { middleware(req, res, next); - expect(res.redirect).toHaveBeenCalledWith("/login"); + expect(res.redirect).toHaveBeenCalledWith("/sign-in"); expect(req.session.returnTo).toBe("/system-admin-dashboard"); expect(next).not.toHaveBeenCalled(); }); diff --git a/libs/auth/src/middleware/authorise.ts b/libs/auth/src/middleware/authorise.ts index 1a89751f..c085007f 100644 --- a/libs/auth/src/middleware/authorise.ts +++ b/libs/auth/src/middleware/authorise.ts @@ -1,6 +1,7 @@ import { USER_ROLES } from "@hmcts/account"; import type { NextFunction, Request, RequestHandler, Response } from "express"; import { hasRole } from "../role-service/index.js"; +import { redirectUnauthenticated } from "./redirect-helpers.js"; /** * Middleware to require specific roles for protected routes @@ -12,8 +13,7 @@ export function requireRole(allowedRoles: string[]): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { // First check if user is authenticated if (!req.isAuthenticated() || !req.user) { - req.session.returnTo = req.originalUrl; - return res.redirect("/login"); + return redirectUnauthenticated(req, res); } const userRole = req.user.role; @@ -32,9 +32,9 @@ export function requireRole(allowedRoles: string[]): RequestHandler { return res.redirect("/admin-dashboard"); } - // User has no role - redirect to login + // User has no role - redirect to sign-in req.session.returnTo = req.originalUrl; - return res.redirect("/login"); + return res.redirect("/sign-in"); }; } diff --git a/libs/auth/src/middleware/redirect-helpers.test.ts b/libs/auth/src/middleware/redirect-helpers.test.ts new file mode 100644 index 00000000..2e134f1d --- /dev/null +++ b/libs/auth/src/middleware/redirect-helpers.test.ts @@ -0,0 +1,200 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { redirectUnauthenticated } from "./redirect-helpers.js"; + +vi.mock("../config/sso-config.js", () => ({ + isSsoConfigured: vi.fn() +})); + +import { isSsoConfigured } from "../config/sso-config.js"; + +describe("redirectUnauthenticated", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when SSO is configured", () => { + beforeEach(() => { + vi.mocked(isSsoConfigured).mockReturnValue(true); + }); + + it("should redirect to /login for /admin-dashboard", () => { + const req = { + originalUrl: "/admin-dashboard", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/login"); + expect(req.session.returnTo).toBe("/admin-dashboard"); + }); + + it("should redirect to /login for /system-admin-dashboard", () => { + const req = { + originalUrl: "/system-admin-dashboard", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/login"); + expect(req.session.returnTo).toBe("/system-admin-dashboard"); + }); + + it("should redirect to /login for /admin-dashboard with query params", () => { + const req = { + originalUrl: "/admin-dashboard?foo=bar", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/login"); + expect(req.session.returnTo).toBe("/admin-dashboard?foo=bar"); + }); + + it("should redirect to /sign-in for non-admin pages", () => { + const req = { + originalUrl: "/account-home", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/sign-in"); + expect(req.session.returnTo).toBe("/account-home"); + }); + + it("should redirect to /sign-in for public pages", () => { + const req = { + originalUrl: "/search", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/sign-in"); + expect(req.session.returnTo).toBe("/search"); + }); + }); + + describe("when SSO is not configured", () => { + beforeEach(() => { + vi.mocked(isSsoConfigured).mockReturnValue(false); + }); + + it("should redirect to /sign-in for /admin-dashboard", () => { + const req = { + originalUrl: "/admin-dashboard", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/sign-in"); + expect(req.session.returnTo).toBe("/admin-dashboard"); + }); + + it("should redirect to /sign-in for /system-admin-dashboard", () => { + const req = { + originalUrl: "/system-admin-dashboard", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/sign-in"); + expect(req.session.returnTo).toBe("/system-admin-dashboard"); + }); + + it("should redirect to /sign-in for non-admin pages", () => { + const req = { + originalUrl: "/account-home", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/sign-in"); + expect(req.session.returnTo).toBe("/account-home"); + }); + }); + + describe("edge cases", () => { + it("should handle URLs with trailing slashes", () => { + vi.mocked(isSsoConfigured).mockReturnValue(true); + + const req = { + originalUrl: "/admin-dashboard/", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/login"); + expect(req.session.returnTo).toBe("/admin-dashboard/"); + }); + + it("should handle URLs with hash fragments", () => { + vi.mocked(isSsoConfigured).mockReturnValue(true); + + const req = { + originalUrl: "/admin-dashboard#section", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/login"); + expect(req.session.returnTo).toBe("/admin-dashboard#section"); + }); + + it("should not match URLs that contain admin-dashboard as substring", () => { + vi.mocked(isSsoConfigured).mockReturnValue(true); + + const req = { + originalUrl: "/not-admin-dashboard", + session: {} + } as unknown as Request; + const res = { + redirect: vi.fn() + } as unknown as Response; + + redirectUnauthenticated(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/sign-in"); + expect(req.session.returnTo).toBe("/not-admin-dashboard"); + }); + }); +}); diff --git a/libs/auth/src/middleware/redirect-helpers.ts b/libs/auth/src/middleware/redirect-helpers.ts new file mode 100644 index 00000000..1d3be658 --- /dev/null +++ b/libs/auth/src/middleware/redirect-helpers.ts @@ -0,0 +1,28 @@ +import type { Request, Response } from "express"; +import { isSsoConfigured } from "../config/sso-config.js"; + +/** + * Redirects unauthenticated users to the appropriate login page + * - Admin pages redirect to SSO login when configured + * - Other pages redirect to account selection page + * Saves the original requested URL for redirect after login + * + * @param req - Express request object + * @param res - Express response object + */ +export function redirectUnauthenticated(req: Request, res: Response): void { + // Save the original URL to redirect after login + req.session.returnTo = req.originalUrl; + + // Admin/internal pages should go directly to SSO when configured + const isAdminPage = req.originalUrl.startsWith("/admin-dashboard") || req.originalUrl.startsWith("/system-admin-dashboard"); + + if (isAdminPage && isSsoConfigured()) { + // Redirect directly to SSO login for admin pages + res.redirect("/login"); + return; + } + + // Redirect to sign-in page (account selection) for other pages + res.redirect("/sign-in"); +} diff --git a/libs/verified-pages/src/pages/account-home/index.test.ts b/libs/verified-pages/src/pages/account-home/index.test.ts index f3a47640..d36078f7 100644 --- a/libs/verified-pages/src/pages/account-home/index.test.ts +++ b/libs/verified-pages/src/pages/account-home/index.test.ts @@ -1,7 +1,7 @@ import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// Mock the buildVerifiedUserNavigation and blockUserAccess functions from auth package +// Mock the buildVerifiedUserNavigation, requireAuth, and blockUserAccess functions from auth package vi.mock("@hmcts/auth", () => ({ buildVerifiedUserNavigation: vi.fn((currentPath: string, locale: string = "en") => { const translations = { @@ -14,6 +14,7 @@ vi.mock("@hmcts/auth", () => ({ { text: t.emailSubscriptions, href: "/", current: currentPath === "/", attributes: { "data-test": "email-subscriptions-link" } } ]; }), + requireAuth: vi.fn(() => (_req: any, _res: any, next: any) => next()), blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) })); diff --git a/libs/verified-pages/src/pages/account-home/index.ts b/libs/verified-pages/src/pages/account-home/index.ts index 3d271295..c7f4153d 100644 --- a/libs/verified-pages/src/pages/account-home/index.ts +++ b/libs/verified-pages/src/pages/account-home/index.ts @@ -1,4 +1,4 @@ -import { blockUserAccess, buildVerifiedUserNavigation } from "@hmcts/auth"; +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -15,4 +15,4 @@ const getHandler = async (req: Request, res: Response) => { res.render("account-home/index", { en, cy }); }; -export const GET: RequestHandler[] = [blockUserAccess(), getHandler]; +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];