test(auth): add admin auth split unit+e2e coverage
This commit is contained in:
parent
95af9c2788
commit
9980cd4fe5
10 changed files with 348 additions and 20 deletions
66
package-lock.json
generated
66
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
25
playwright.config.ts
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
37
src/lib/admin-auth.test.ts
Normal file
37
src/lib/admin-auth.test.ts
Normal 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
31
src/lib/admin-auth.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
|
|
|
|||
93
tests/e2e/admin-auth.spec.ts
Normal file
93
tests/e2e/admin-auth.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue