2026-09-30 23:12:51 +01:00
|
|
|
import test from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { createApp } from '../server.js';
|
|
|
|
|
import {
|
|
|
|
|
makeJwt,
|
|
|
|
|
makePbCookie,
|
|
|
|
|
startServer,
|
|
|
|
|
stopServer,
|
|
|
|
|
urlOf,
|
|
|
|
|
cookieValue,
|
|
|
|
|
} from './helpers.js';
|
|
|
|
|
|
|
|
|
|
// Fake YOUreka portal: web-auth session + openfga verify.
|
2026-10-01 03:28:31 +01:00
|
|
|
async function startFakePortal({ cookieStatus = 'Activated', allowed = true, authErrorMessage, firstName = 'Ada' } = {}) {
|
2026-09-30 23:12:51 +01:00
|
|
|
return startServer((req, res) => {
|
|
|
|
|
let raw = '';
|
|
|
|
|
req.on('data', (c) => (raw += c));
|
|
|
|
|
req.on('end', () => {
|
|
|
|
|
const json = (status, body) => {
|
|
|
|
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
|
|
|
res.end(JSON.stringify(body));
|
|
|
|
|
};
|
|
|
|
|
if (req.method === 'POST' && req.url === '/api/web/auth') {
|
|
|
|
|
const body = JSON.parse(raw || '{}');
|
|
|
|
|
if (!body.returnUrl) return json(200, { ok: false, message: 'returnUrl is required' });
|
2026-10-01 03:12:02 +01:00
|
|
|
if (authErrorMessage) {
|
|
|
|
|
return json(200, { ok: false, message: authErrorMessage });
|
|
|
|
|
}
|
2026-09-30 23:12:51 +01:00
|
|
|
return json(200, { ok: true, sessionId: 's1', loginUrl: 'https://portal.example/login?return=1' });
|
|
|
|
|
}
|
|
|
|
|
if (req.method === 'GET' && req.url.startsWith('/api/web/auth/s1/cookie')) {
|
|
|
|
|
return json(200, {
|
|
|
|
|
ok: true,
|
|
|
|
|
sessionId: 's1',
|
|
|
|
|
status: cookieStatus,
|
|
|
|
|
cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })),
|
|
|
|
|
phone: '+2348068287222',
|
2026-10-01 03:28:31 +01:00
|
|
|
firstName,
|
2026-09-30 23:12:51 +01:00
|
|
|
lastName: 'Obi',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (req.method === 'POST' && req.url === '/api/openfga/verify') {
|
|
|
|
|
return json(200, { ok: true, allowed });
|
|
|
|
|
}
|
|
|
|
|
json(404, { ok: false });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fake PocketBase: auth-refresh always succeeds with a fresh token.
|
|
|
|
|
async function startFakePb() {
|
|
|
|
|
return startServer((req, res) => {
|
|
|
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
|
|
|
res.end(JSON.stringify({ token: makeJwt({ expiresInSec: 3600 }), record: { id: 'u1', collectionId: 'c1', collectionName: 'users', phone: '+2348068287222' } }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fake a2loft: records requests, echoes a staff profile.
|
|
|
|
|
async function startFakeA2loft() {
|
|
|
|
|
const calls = [];
|
|
|
|
|
const server = await startServer((req, res) => {
|
|
|
|
|
let raw = '';
|
|
|
|
|
req.on('data', (c) => (raw += c));
|
|
|
|
|
req.on('end', () => {
|
|
|
|
|
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
|
|
|
|
|
const staff = {
|
|
|
|
|
ok: true,
|
|
|
|
|
data: {
|
|
|
|
|
name: 'Ada Obi',
|
|
|
|
|
reference: 'UC/0231',
|
|
|
|
|
domainReference: 'dom-1',
|
|
|
|
|
domainRankDescription: 'Director',
|
|
|
|
|
fingerPrintCaptureCount: 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
|
|
|
res.end(JSON.stringify(req.url.includes('/fingerprints') ? { ok: true, message: 'saved' } : staff));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return { server, calls, url: null, stop: () => stopServer(server) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function bootApp(appOverrides = {}, portalOpts) {
|
|
|
|
|
const portal = await startFakePortal(portalOpts);
|
|
|
|
|
const pb = await startFakePb();
|
|
|
|
|
const a2loft = await startFakeA2loft();
|
|
|
|
|
a2loft.url = urlOf(a2loft.server);
|
|
|
|
|
const app = createApp({
|
|
|
|
|
portalUrl: urlOf(portal),
|
|
|
|
|
pbUrl: urlOf(pb),
|
|
|
|
|
a2loftBase: a2loft.url,
|
|
|
|
|
...appOverrides,
|
|
|
|
|
});
|
|
|
|
|
const httpServer = await new Promise((resolve) => {
|
|
|
|
|
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
|
|
|
});
|
|
|
|
|
const base = urlOf(httpServer);
|
|
|
|
|
const stop = async () => {
|
|
|
|
|
await new Promise((r) => httpServer.close(r));
|
|
|
|
|
await stopServer(portal);
|
|
|
|
|
await stopServer(pb);
|
|
|
|
|
await a2loft.stop();
|
|
|
|
|
};
|
|
|
|
|
return { base, a2loft, stop };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const manual = { redirect: 'manual' };
|
|
|
|
|
|
|
|
|
|
async function login(base) {
|
|
|
|
|
// exercises /auth/login → portal, then /auth/callback → cookies
|
|
|
|
|
const loginRes = await fetch(`${base}/auth/login`, manual);
|
|
|
|
|
assert.equal(loginRes.status, 302);
|
|
|
|
|
const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
|
|
|
|
|
assert.equal(callbackRes.status, 302);
|
|
|
|
|
assert.equal(callbackRes.headers.get('location'), '/fingerprint');
|
|
|
|
|
const session = cookieValue(callbackRes, 'session');
|
|
|
|
|
const profile = cookieValue(callbackRes, 'user_profile');
|
|
|
|
|
assert.ok(session, 'session cookie set');
|
|
|
|
|
assert.ok(profile, 'profile cookie set');
|
|
|
|
|
assert.match(profile, /2348068287222/);
|
|
|
|
|
return `session=${session}; user_profile=${profile}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('landing page links both tools', async () => {
|
|
|
|
|
const { base, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${base}/`);
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
assert.equal(res.status, 200);
|
2026-10-01 01:13:30 +01:00
|
|
|
assert.match(html, /href="\/cropper"/);
|
|
|
|
|
assert.match(html, /href="\/fingerprint"/);
|
2026-09-30 23:12:51 +01:00
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('anonymous /fingerprint redirects to login; session APIs 401', async () => {
|
|
|
|
|
const { base, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const page = await fetch(`${base}/fingerprint`, manual);
|
|
|
|
|
assert.equal(page.status, 302);
|
|
|
|
|
assert.equal(page.headers.get('location'), '/auth/login');
|
|
|
|
|
const api = await fetch(`${base}/auth/session`);
|
|
|
|
|
assert.equal(api.status, 401);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('auth callback rejects unredeemable sessions without setting cookies', async () => {
|
|
|
|
|
const { base, stop } = await bootApp({}, { cookieStatus: 'TimedOut' });
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
|
2026-10-01 01:13:30 +01:00
|
|
|
assert.equal(res.status, 401);
|
2026-09-30 23:12:51 +01:00
|
|
|
assert.equal(cookieValue(res, 'session'), undefined);
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
assert.match(html, /timed out/i);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('session endpoint returns token, profile, agent URL and permitted domains', async () => {
|
|
|
|
|
const { base, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const res = await fetch(`${base}/auth/session`, { headers: { cookie: cookies } });
|
|
|
|
|
assert.equal(res.status, 200);
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
assert.equal(body.ok, true);
|
|
|
|
|
assert.match(body.token, /\./); // JWT-ish
|
|
|
|
|
assert.equal(body.phone, '+2348068287222');
|
|
|
|
|
assert.equal(body.firstName, 'Ada');
|
|
|
|
|
assert.equal(body.agentUrl, 'http://127.0.0.1:9000');
|
|
|
|
|
assert.equal(body.domains.length, 5, 'all catalog domains allowed by the fake portal');
|
|
|
|
|
assert.equal(body.domains[0].code, 'uc');
|
|
|
|
|
assert.ok(body.domainsCheckedAt > 0);
|
|
|
|
|
assert.equal(body.domainCacheTtlMs, 5 * 60 * 1000);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('domain refresh refuses while the cache is fresh, succeeds after the TTL', async () => {
|
|
|
|
|
const { base, stop } = await bootApp({ ttlMs: 30 });
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const first = await fetch(`${base}/auth/session`, { headers: { cookie: cookies } });
|
|
|
|
|
assert.equal(first.status, 200); // populates the permission cache
|
|
|
|
|
const fresh = await fetch(`${base}/auth/domains/refresh`, { method: 'POST', headers: { cookie: cookies } });
|
|
|
|
|
assert.equal(fresh.status, 409);
|
|
|
|
|
const body = await fresh.json();
|
|
|
|
|
assert.equal(body.ok, false);
|
|
|
|
|
await new Promise((r) => setTimeout(r, 40));
|
|
|
|
|
const stale = await fetch(`${base}/auth/domains/refresh`, { method: 'POST', headers: { cookie: cookies } });
|
|
|
|
|
assert.equal(stale.status, 200);
|
|
|
|
|
const body2 = await stale.json();
|
|
|
|
|
assert.equal(body2.ok, true);
|
|
|
|
|
assert.ok(body2.domainsCheckedAt > 0);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('staff lookup proxy injects the session phone and forwards a2loft responses', async () => {
|
|
|
|
|
const { base, a2loft, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const res = await fetch(`${base}/api/uc/staff/UC%2F0231?otp=4455`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { cookie: cookies },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(res.status, 200);
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
assert.equal(body.ok, true);
|
|
|
|
|
assert.equal(body.data.name, 'Ada Obi');
|
|
|
|
|
assert.equal(a2loft.calls.length, 1);
|
|
|
|
|
const call = a2loft.calls[0];
|
|
|
|
|
assert.equal(call.method, 'GET');
|
|
|
|
|
const u = new URL(call.url, 'http://x');
|
|
|
|
|
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
|
|
|
|
|
assert.equal(u.searchParams.get('phone'), '+2348068287222');
|
|
|
|
|
assert.equal(u.searchParams.get('otp'), '4455');
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('fingerprint save proxy forwards the body unchanged', async () => {
|
|
|
|
|
const { base, a2loft, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
|
|
|
|
|
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints?otp=99`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(res.status, 200);
|
|
|
|
|
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
|
|
|
|
|
const call = a2loft.calls[0];
|
|
|
|
|
assert.equal(call.method, 'POST');
|
|
|
|
|
assert.ok(call.url.startsWith('/identity/uc/fingerprints/UC0231?'), `save URL shape, got: ${call.url}`);
|
|
|
|
|
assert.deepEqual(call.body, payload);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('proxy validation: auth, otp, and domain are enforced before contacting a2loft', async () => {
|
|
|
|
|
const { base, a2loft, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const anon = await fetch(`${base}/api/uc/staff/REF?otp=1`, { method: 'POST' });
|
|
|
|
|
assert.equal(anon.status, 401);
|
|
|
|
|
const noOtp = await fetch(`${base}/api/uc/staff/REF`, {
|
|
|
|
|
method: 'POST', headers: { cookie: cookies },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(noOtp.status, 400);
|
|
|
|
|
const badDomain = await fetch(`${base}/api/nope/staff/REF?otp=1`, {
|
|
|
|
|
method: 'POST', headers: { cookie: cookies },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(badDomain.status, 400);
|
|
|
|
|
assert.equal(a2loft.calls.length, 0, 'a2loft never contacted for rejected calls');
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-10-01 03:12:02 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
test('upstream error text is HTML-escaped on the error page', async () => {
|
|
|
|
|
const { base, stop } = await bootApp({}, { authErrorMessage: '<script>alert(1)</script>portal said no' });
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${base}/auth/login`, manual);
|
|
|
|
|
assert.equal(res.status, 502);
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
assert.ok(!html.includes('<script>alert(1)</script>'), 'script tag must not be interpolated raw');
|
|
|
|
|
assert.match(html, /<script>alert\(1\)<\/script>/);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-10-01 03:28:31 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
test('pages receive the session injected server-side (no client fetch, no-store)', async () => {
|
|
|
|
|
const { base, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const res = await fetch(`${base}/fingerprint`, { headers: { cookie: cookies } });
|
|
|
|
|
assert.equal(res.status, 200);
|
|
|
|
|
assert.match(res.headers.get('cache-control') || '', /no-store/);
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
assert.match(html, /window\.__SESSION__ = \{/);
|
|
|
|
|
assert.match(html, /2348068287222/);
|
|
|
|
|
assert.match(html, /"domains"/, 'permitted domains injected too');
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('injected session values cannot break out of the script tag', async () => {
|
|
|
|
|
const { base, stop } = await bootApp({}, { firstName: 'Ada</script><script>alert(1)</script>' });
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const html = await (await fetch(`${base}/fingerprint`, { headers: { cookie: cookies } })).text();
|
|
|
|
|
const injected = html.slice(html.indexOf('window.__SESSION__'), html.indexOf('</head>'));
|
|
|
|
|
const payload = injected.slice(injected.indexOf('= ') + 2, injected.indexOf(';</script>'));
|
|
|
|
|
assert.ok(!payload.includes('</script>'), 'raw </script> must not appear inside the injected JSON');
|
|
|
|
|
assert.match(payload, /\\u003c/, 'markup is escaped in the payload');
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-01 04:08:09 +01:00
|
|
|
test('the active tool is marked on both navigation surfaces', async () => {
|
|
|
|
|
const { base, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const cropper = await (await fetch(`${base}/cropper`, { headers: { cookie: cookies } })).text();
|
|
|
|
|
assert.match(cropper, /cds-header-nav-item href="\/cropper" aria-current="page"/);
|
|
|
|
|
assert.match(cropper, /cds-side-nav-link href="\/cropper" aria-current="page"/);
|
|
|
|
|
assert.ok(
|
|
|
|
|
!/cds-header-nav-item href="\/fingerprint" aria-current/.test(cropper),
|
|
|
|
|
'the other tool is not marked current'
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-01 04:01:44 +01:00
|
|
|
test('all pages render on the g100 dark theme', async () => {
|
|
|
|
|
const { base, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
for (const path of ['/', '/fingerprint']) {
|
|
|
|
|
const html = await (await fetch(`${base}${path}`, { headers: { cookie: cookies } })).text();
|
|
|
|
|
assert.match(html, /<body class="app cds--g100">/, `${path} uses g100`);
|
|
|
|
|
}
|
|
|
|
|
// error pages too
|
|
|
|
|
const errorHtml = await (await fetch(`${base}/auth/callback?sessionId=nope`, manual)).text();
|
|
|
|
|
assert.match(errorHtml, /<body class="app cds--g100">/);
|
|
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-01 03:28:31 +01:00
|
|
|
test('landing header is server-rendered for the session', async () => {
|
|
|
|
|
const { base, stop } = await bootApp();
|
|
|
|
|
try {
|
|
|
|
|
const anon = await (await fetch(`${base}/`)).text();
|
|
|
|
|
assert.match(anon, /href="\/auth\/login">Sign in</);
|
2026-10-01 03:53:24 +01:00
|
|
|
assert.ok(!anon.includes('<!--app-header-->'), 'header marker is replaced');
|
|
|
|
|
// tools live in the header nav and in the mobile side nav
|
|
|
|
|
assert.match(anon, /cds-header-nav-item href="\/cropper"/);
|
|
|
|
|
assert.match(anon, /cds-header-nav-item href="\/fingerprint"/);
|
|
|
|
|
assert.match(anon, /cds-side-nav-link href="\/cropper"/);
|
|
|
|
|
assert.match(anon, /cds-side-nav-divider/);
|
|
|
|
|
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"/);
|
|
|
|
|
assert.match(anon, /youreka-logo\.png/);
|
|
|
|
|
assert.match(anon, /header-menu-button/);
|
2026-10-01 04:08:09 +01:00
|
|
|
// favicon is the Youreka logo; sign-out is confirmed via a Carbon modal
|
|
|
|
|
assert.match(anon, /rel="icon"[^>]*youreka-favicon\.png/);
|
|
|
|
|
assert.match(anon, /data-signout/);
|
|
|
|
|
assert.match(anon, /cds-modal id="signout-modal"/);
|
|
|
|
|
assert.match(anon, /id="signout-confirm"/);
|
2026-10-01 03:28:31 +01:00
|
|
|
|
|
|
|
|
const cookies = await login(base);
|
|
|
|
|
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
|
|
|
|
|
assert.match(authed, /Ada/);
|
2026-10-01 04:08:09 +01:00
|
|
|
assert.match(authed, /href="\/auth\/logout"[^>]*>Sign out</);
|
2026-10-01 03:28:31 +01:00
|
|
|
} finally {
|
|
|
|
|
await stop();
|
|
|
|
|
}
|
|
|
|
|
});
|