From 9980cd4fe51b6e7c3b288352d3cbe6ae764cdde4 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Fri, 20 Mar 2026 22:37:17 +0100 Subject: [PATCH] test(auth): add admin auth split unit+e2e coverage --- package-lock.json | 66 +++++++++++++++++++++++++ package.json | 8 ++- playwright.config.ts | 25 ++++++++++ src/components/AdminShell.tsx | 47 +++++++++++++++--- src/lib/admin-auth.test.ts | 37 ++++++++++++++ src/lib/admin-auth.ts | 31 ++++++++++++ src/lib/admin-session.ts | 5 +- src/routes/index.tsx | 11 +---- src/routes/login.tsx | 45 +++++++++++++++++ tests/e2e/admin-auth.spec.ts | 93 +++++++++++++++++++++++++++++++++++ 10 files changed, 348 insertions(+), 20 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/lib/admin-auth.test.ts create mode 100644 src/lib/admin-auth.ts create mode 100644 tests/e2e/admin-auth.spec.ts diff --git a/package-lock.json b/package-lock.json index 6b3c85b..de28e60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "solid-js": "^1.9.5", "vinxi": "^0.5.7" }, + "devDependencies": { + "@playwright/test": "^1.58.2" + }, "engines": { "node": ">=20" } @@ -1362,6 +1365,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -6307,6 +6326,53 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/package.json b/package.json index 10914b5..52c761c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "scripts": { "dev": "vinxi dev", "build": "vinxi build", - "start": "vinxi start" + "start": "vinxi start", + "test": "node --test --experimental-strip-types src/lib/**/*.test.ts", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed" }, "dependencies": { "@solidjs/meta": "^0.29.4", @@ -15,5 +18,8 @@ }, "engines": { "node": ">=20" + }, + "devDependencies": { + "@playwright/test": "^1.58.2" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0b34c79 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + reporter: [['list']], + webServer: { + command: 'npm run build && PORT=3102 npm run start', + url: 'http://127.0.0.1:3102/', + reuseExistingServer: true, + timeout: 300_000, + }, + use: { + baseURL: 'http://127.0.0.1:3102', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + viewport: { width: 1440, height: 900 }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/src/components/AdminShell.tsx b/src/components/AdminShell.tsx index 3d2ea04..53d7ef3 100644 --- a/src/components/AdminShell.tsx +++ b/src/components/AdminShell.tsx @@ -1,6 +1,7 @@ import { A, useLocation, useNavigate } from '@solidjs/router'; import { createMemo, createSignal, onMount, type JSX } from 'solid-js'; import AdminSidebar from './AdminSidebar'; +import { isExternalIdentity } from '~/lib/admin-auth'; import { clearAdminSession, hasAdminSession } from '~/lib/admin-session'; import { sidebarCollapsed } from '~/lib/sidebar-state'; @@ -115,15 +116,47 @@ export default function AdminShell(props: { children: JSX.Element }) { }); onMount(() => { - if (!hasAdminSession()) { - const from = encodeURIComponent(location.pathname + location.search); - navigate(`/login?from=${from}`, { replace: true }); - return; - } - setCheckedSession(true); + const verify = async () => { + if (!hasAdminSession()) { + const from = encodeURIComponent(location.pathname + location.search); + navigate(`/login?from=${from}`, { replace: true }); + return; + } + + try { + const response = await fetch('/api/gateway/users/auth/me', { + method: 'GET', + headers: { + Accept: 'application/json', + 'x-portal-target': 'admin', + }, + credentials: 'include', + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || isExternalIdentity(payload)) { + throw new Error('Unauthorized'); + } + setCheckedSession(true); + } catch { + clearAdminSession(); + const from = encodeURIComponent(location.pathname + location.search); + navigate(`/login?from=${from}`, { replace: true }); + } + }; + + void verify(); }); - const onLogout = () => { + const onLogout = async () => { + await fetch('/api/gateway/users/auth/logout', { + method: 'POST', + headers: { + Accept: 'application/json', + 'x-portal-target': 'admin', + }, + credentials: 'include', + }).catch(() => {}); + clearAdminSession(); navigate('/login', { replace: true }); }; diff --git a/src/lib/admin-auth.test.ts b/src/lib/admin-auth.test.ts new file mode 100644 index 0000000..a3be657 --- /dev/null +++ b/src/lib/admin-auth.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isExternalIdentity, pickManagementLoginError } from './admin-auth.ts'; + +test('pickManagementLoginError prefers wrong-portal message', () => { + const message = pickManagementLoginError({ error_code: 'WRONG_PORTAL', message: 'Ignored' }); + assert.equal( + message, + 'This login is only for internal management users. External users must use the public login page.', + ); +}); + +test('pickManagementLoginError uses payload message when available', () => { + const message = pickManagementLoginError({ message: 'Invalid credentials' }); + assert.equal(message, 'Invalid credentials'); +}); + +test('pickManagementLoginError falls back when payload has no message', () => { + const message = pickManagementLoginError({}); + assert.equal(message, 'Sign in failed.'); +}); + +test('isExternalIdentity returns true for public audience', () => { + assert.equal(isExternalIdentity({ audience: 'public' }), true); +}); + +test('isExternalIdentity returns true for external user type', () => { + assert.equal(isExternalIdentity({ user: { user_type: 'external_user' } }), true); +}); + +test('isExternalIdentity returns true for external account type', () => { + assert.equal(isExternalIdentity({ user: { accountType: 'external' } }), true); +}); + +test('isExternalIdentity returns false for internal/admin identity', () => { + assert.equal(isExternalIdentity({ audience: 'admin', user: { userType: 'employee' } }), false); +}); diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts new file mode 100644 index 0000000..20c2ef2 --- /dev/null +++ b/src/lib/admin-auth.ts @@ -0,0 +1,31 @@ +export function pickManagementLoginError(payload: any): string { + const message = String(payload?.message || payload?.error || '').trim(); + if (payload?.error_code === 'WRONG_PORTAL') { + return 'This login is only for internal management users. External users must use the public login page.'; + } + if (message) return message; + return 'Sign in failed.'; +} + +export function isExternalIdentity(payload: any): boolean { + const audience = String(payload?.audience || payload?.user?.audience || '').trim().toLowerCase(); + if (audience === 'public' || audience === 'external') return true; + + const userType = String( + payload?.userType || + payload?.user_type || + payload?.user?.userType || + payload?.user?.user_type || + '', + ).trim().toLowerCase(); + if (userType === 'external_user' || userType === 'external') return true; + + const accountType = String( + payload?.accountType || + payload?.account_type || + payload?.user?.accountType || + payload?.user?.account_type || + '', + ).trim().toLowerCase(); + return accountType === 'external' || accountType === 'public'; +} diff --git a/src/lib/admin-session.ts b/src/lib/admin-session.ts index 404f1eb..8fcbbcb 100644 --- a/src/lib/admin-session.ts +++ b/src/lib/admin-session.ts @@ -1,14 +1,15 @@ const SESSION_COOKIE = 'nxtgauge_admin_session'; +const SESSION_VALUE = 'internal_management'; const SESSION_TTL_SECONDS = 60 * 60 * 12; export function hasAdminSession(): boolean { if (typeof document === 'undefined') return false; - return document.cookie.split(';').some((entry) => entry.trim().startsWith(`${SESSION_COOKIE}=`)); + return document.cookie.split(';').some((entry) => entry.trim() === `${SESSION_COOKIE}=${SESSION_VALUE}`); } export function setAdminSession(): void { if (typeof document === 'undefined') return; - document.cookie = `${SESSION_COOKIE}=1; Path=/; Max-Age=${SESSION_TTL_SECONDS}; SameSite=Lax`; + document.cookie = `${SESSION_COOKIE}=${SESSION_VALUE}; Path=/; Max-Age=${SESSION_TTL_SECONDS}; SameSite=Lax`; } export function clearAdminSession(): void { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index c584ccd..16286bb 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,14 +1,6 @@ -import { A, useNavigate } from '@solidjs/router'; -import { setAdminSession } from '~/lib/admin-session'; +import { A } from '@solidjs/router'; export default function Home() { - const navigate = useNavigate(); - - const skipToAdmin = () => { - setAdminSession(); - navigate('/admin', { replace: true }); - }; - return (
@@ -18,7 +10,6 @@ export default function Home() {

Secure sign-in and runtime control center for NXTGAUGE operations.

Sign In -
diff --git a/src/routes/login.tsx b/src/routes/login.tsx index c490ce0..e100b93 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,5 +1,6 @@ import { useNavigate } from '@solidjs/router'; import { createMemo, createSignal, onMount } from 'solid-js'; +import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth'; import { hasAdminSession, setAdminSession } from '~/lib/admin-session'; type AuthMode = 'login' | 'reset'; @@ -97,6 +98,50 @@ export default function LoginPage() { setIsSubmitting(true); try { + const loginPayload = { + email: email().trim().toLowerCase(), + password: password(), + loginTarget: 'admin', + }; + + const attempts: string[] = [ + '/api/gateway/users/auth/internal/login', + '/api/gateway/auth/internal/login', + ]; + + let payload: any = {}; + let status = 500; + let success = false; + for (const url of attempts) { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-portal-target': 'admin', + }, + credentials: 'include', + body: JSON.stringify(loginPayload), + }); + status = response.status; + payload = await response.json().catch(() => ({})); + if (response.ok) { + success = true; + break; + } + } + + if (!success) { + const fallback = status === 502 + ? 'Auth service is temporarily unavailable (502). Please retry in 1-2 minutes.' + : 'Sign in failed.'; + throw new Error(pickManagementLoginError(payload) || fallback); + } + + if (isExternalIdentity(payload)) { + throw new Error('External users are not allowed on management login. Please use the external user login.'); + } + completeAdminLogin(); } catch (nextError: any) { setError(String(nextError?.message || 'Sign in failed.')); diff --git a/tests/e2e/admin-auth.spec.ts b/tests/e2e/admin-auth.spec.ts new file mode 100644 index 0000000..7fe8682 --- /dev/null +++ b/tests/e2e/admin-auth.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Admin Auth Split', () => { + test('blocks external identities on internal management login', async ({ page }) => { + await page.route('**/api/gateway/users/auth/internal/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + audience: 'public', + user: { + audience: 'public', + user_type: 'external_user', + }, + }), + }); + }); + + await page.goto('/login'); + await expect(page.getByRole('heading', { name: 'Employee Login' })).toBeVisible(); + await page.getByPlaceholder('Enter your email').fill('external.user@example.com'); + await page.getByPlaceholder('Enter your password').fill('StrongPass123!'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page.getByText('External users are not allowed on management login. Please use the external user login.')).toBeVisible(); + await expect(page).toHaveURL(/\/login/); + }); + + test('allows internal identities and lands on admin shell', async ({ page }) => { + await page.route('**/api/gateway/users/auth/internal/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + audience: 'admin', + user: { + audience: 'admin', + user_type: 'employee', + }, + }), + }); + }); + + await page.route('**/api/gateway/users/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'admin-1', + audience: 'admin', + userType: 'employee', + role: { name: 'Super Admin' }, + }), + }); + }); + + await page.goto('/login'); + await expect(page.getByRole('heading', { name: 'Employee Login' })).toBeVisible(); + await page.getByPlaceholder('Enter your email').fill('admin@nxtgauge.com'); + await page.getByPlaceholder('Enter your password').fill('StrongPass123!'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page).toHaveURL(/\/admin/); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + }); + + test('redirects back to login if admin session resolves as external identity', async ({ page }) => { + await page.context().addCookies([ + { + name: 'nxtgauge_admin_session', + value: 'internal_management', + domain: '127.0.0.1', + path: '/', + }, + ]); + + await page.route('**/api/gateway/users/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + audience: 'public', + user_type: 'external_user', + }), + }); + }); + + await page.goto('/admin'); + await expect(page).toHaveURL(/\/login\?from=%2Fadmin/); + }); +});