test(auth): add admin auth split unit+e2e coverage

This commit is contained in:
Ashwin Kumar 2026-03-20 22:37:17 +01:00
parent 95af9c2788
commit 9980cd4fe5
10 changed files with 348 additions and 20 deletions

66
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

25
playwright.config.ts Normal file
View file

@ -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'] },
},
],
});

View file

@ -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 });
};

View file

@ -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);
});

31
src/lib/admin-auth.ts Normal file
View file

@ -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';
}

View file

@ -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 {

View file

@ -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 (
<main class="auth-page">
<div class="auth-bg" />
@ -18,7 +10,6 @@ export default function Home() {
<p class="auth-copy">Secure sign-in and runtime control center for NXTGAUGE operations.</p>
<div class="actions">
<A class="btn primary" href="/login">Sign In</A>
<button type="button" class="btn" onClick={skipToAdmin}>Skip to Admin</button>
</div>
</section>
</main>

View file

@ -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.'));

View file

@ -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/);
});
});