feat: YOUreka Tools express app — landing page + fpagent enrollment wizard
- tools/: express host (hardcoded config, YOUreka Auth cookie session, OpenFGA domain filtering with 5-min cache, a2loft proxies) - /fingerprint wizard: domain picker, two-finger capture via fpagent Socket.IO, OTP-validated lookup/save, domain refresh control - node --test suite (27 tests, fake portal/PocketBase/a2loft upstreams) - Dockerfile (node:22-alpine, non-root, healthcheck) - README rewritten; node_modules gitignored Field-verified end to end: portal login, permitted-domain filtering, real U.are.U 4500 captures, cancel-preserves-capture, a2loft save.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// Shared test helpers: unsigned JWTs, real PocketBase cookie envelopes, and
|
||||
// throwaway http servers for fake upstreams.
|
||||
import http from 'node:http';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
export function b64url(obj) {
|
||||
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
}
|
||||
|
||||
/** Structurally valid (unsigned) JWT with a controllable exp. */
|
||||
export function makeJwt({ expiresInSec = 3600 } = {}) {
|
||||
const payload = {
|
||||
exp: Math.floor(Date.now() / 1000) + expiresInSec,
|
||||
id: 'u1',
|
||||
collectionId: 'c1',
|
||||
collectionName: 'users',
|
||||
};
|
||||
return `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.sig`;
|
||||
}
|
||||
|
||||
export const TEST_RECORD = {
|
||||
id: 'u1',
|
||||
collectionId: 'c1',
|
||||
collectionName: 'users',
|
||||
phone: '+2348068287222',
|
||||
};
|
||||
|
||||
/** Build the real PocketBase cookie envelope (value only) via the actual SDK. */
|
||||
export function makePbCookie(token, record = TEST_RECORD) {
|
||||
const pb = new PocketBase('http://127.0.0.1:9'); // never contacted for this
|
||||
pb.authStore.save(token, record);
|
||||
const setCookie = pb.authStore.exportToCookie({}, 'pb_auth');
|
||||
const m = /pb_auth=([^;]+)/.exec(setCookie);
|
||||
if (!m) throw new Error('could not build pb cookie envelope');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
export function startServer(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
export const urlOf = (server) => `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
export function stopServer(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
/** Read the `name=value` pair out of a Set-Cookie response header list. */
|
||||
export function cookieValue(res, name) {
|
||||
for (const c of res.headers.getSetCookie()) {
|
||||
const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(c);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Scriptable fetch double that records every call. */
|
||||
export function fakeFetch(script) {
|
||||
const calls = [];
|
||||
const fn = async (url, init) => {
|
||||
const body = init?.body ? JSON.parse(init.body) : undefined;
|
||||
const out = await script({ url, body, init });
|
||||
calls.push({ url, body });
|
||||
if (out instanceof Error) throw out;
|
||||
return { status: out.status ?? 200, json: async () => out.body, text: async () => JSON.stringify(out.body ?? {}) };
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
permittedDomains,
|
||||
checkDomain,
|
||||
stripPhonePrefix,
|
||||
CACHE_FRESH,
|
||||
} from '../permit.js';
|
||||
import { fakeFetch } from './helpers.js';
|
||||
|
||||
const CATALOG = [
|
||||
{ code: 'uc', name: 'UNICAL' },
|
||||
{ code: 'mtr', name: 'Metropolitan Hotel' },
|
||||
{ code: 'ucth', name: 'UCTH' },
|
||||
];
|
||||
|
||||
const PORTAL = 'https://portal.example';
|
||||
|
||||
test('stripPhonePrefix removes a single leading +', () => {
|
||||
assert.equal(stripPhonePrefix('+2348068287222'), '2348068287222');
|
||||
assert.equal(stripPhonePrefix('2348068287222'), '2348068287222');
|
||||
assert.equal(stripPhonePrefix(undefined), '');
|
||||
});
|
||||
|
||||
test('checkDomain verifies payload shape and allows on first object', async () => {
|
||||
const fetchImpl = fakeFetch(({ body }) => ({ body: { ok: true, allowed: true } }));
|
||||
const allowed = await checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc');
|
||||
assert.equal(allowed, true);
|
||||
assert.equal(fetchImpl.calls.length, 1);
|
||||
assert.equal(fetchImpl.calls[0].url, `${PORTAL}/api/openfga/verify`);
|
||||
assert.deepEqual(fetchImpl.calls[0].body, {
|
||||
subsystem: 'idid',
|
||||
user: 'user:2348068287222',
|
||||
relation: 'capture_biometric',
|
||||
object: 'domain:uc',
|
||||
});
|
||||
});
|
||||
|
||||
test('checkDomain falls back to domain:idid and allows', async () => {
|
||||
const fetchImpl = fakeFetch(({ body }) => ({
|
||||
body: { ok: true, allowed: body.object === 'domain:idid' },
|
||||
}));
|
||||
const allowed = await checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc');
|
||||
assert.equal(allowed, true);
|
||||
assert.deepEqual(
|
||||
fetchImpl.calls.map((c) => c.body.object),
|
||||
['domain:uc', 'domain:idid']
|
||||
);
|
||||
});
|
||||
|
||||
test('checkDomain denies after both objects deny', async () => {
|
||||
const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: false } }));
|
||||
const allowed = await checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc');
|
||||
assert.equal(allowed, false);
|
||||
assert.equal(fetchImpl.calls.length, 2);
|
||||
});
|
||||
|
||||
test('checkDomain throws (fail closed) when neither object answers', async () => {
|
||||
const fetchImpl = fakeFetch(() => ({ status: 503, body: {} }));
|
||||
await assert.rejects(() => checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc'));
|
||||
});
|
||||
|
||||
test('permittedDomains keeps only allowed domains and fails closed per domain', async () => {
|
||||
const fetchImpl = fakeFetch(({ body }) => {
|
||||
if (body.object === 'domain:uc') return { body: { ok: true, allowed: true } };
|
||||
if (body.object === 'domain:mtr') return { body: { ok: true, allowed: false } };
|
||||
return { status: 500, body: {} }; // ucth: errors on both objects → excluded
|
||||
});
|
||||
const cache = new Map();
|
||||
const { codes, checkedAt } = await permittedDomains({
|
||||
domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now: () => 1000,
|
||||
});
|
||||
assert.deepEqual(codes, ['uc']);
|
||||
assert.equal(checkedAt, 1000);
|
||||
});
|
||||
|
||||
test('permittedDomains serves the cache within the TTL without portal calls', async () => {
|
||||
const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } }));
|
||||
const cache = new Map();
|
||||
let t = 1000;
|
||||
const now = () => t;
|
||||
const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now };
|
||||
const first = await permittedDomains(opts);
|
||||
const callsAfterFirst = fetchImpl.calls.length;
|
||||
t += 60 * 1000; // 1 minute later — still fresh
|
||||
const second = await permittedDomains(opts);
|
||||
assert.equal(fetchImpl.calls.length, callsAfterFirst, 'no portal calls while cached');
|
||||
assert.deepEqual(second, first);
|
||||
});
|
||||
|
||||
test('permittedDomains re-checks after the TTL expires', async () => {
|
||||
const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } }));
|
||||
const cache = new Map();
|
||||
let t = 1000;
|
||||
const now = () => t;
|
||||
const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now };
|
||||
await permittedDomains(opts);
|
||||
const callsAfterFirst = fetchImpl.calls.length;
|
||||
t += 5 * 60 * 1000 + 1;
|
||||
const { checkedAt } = await permittedDomains(opts);
|
||||
assert.ok(fetchImpl.calls.length > callsAfterFirst, 'portal re-consulted after expiry');
|
||||
assert.equal(checkedAt, t);
|
||||
});
|
||||
|
||||
test('forced refresh refuses while the cache is fresh', async () => {
|
||||
const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } }));
|
||||
const cache = new Map();
|
||||
let t = 1000;
|
||||
const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now: () => t };
|
||||
await permittedDomains(opts);
|
||||
const callsAfterFirst = fetchImpl.calls.length;
|
||||
t += 1000;
|
||||
await assert.rejects(
|
||||
() => permittedDomains({ ...opts, force: true }),
|
||||
(e) => e.code === CACHE_FRESH
|
||||
);
|
||||
assert.equal(fetchImpl.calls.length, callsAfterFirst, 'refused refresh makes no portal calls');
|
||||
});
|
||||
|
||||
test('forced refresh after expiry re-checks and replaces the cache', async () => {
|
||||
const fetchImpl = fakeFetch(({ body }) => ({
|
||||
body: { ok: true, allowed: body.object === 'domain:uc' },
|
||||
}));
|
||||
const cache = new Map();
|
||||
let t = 1000;
|
||||
const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now: () => t };
|
||||
await permittedDomains(opts); // everything allowed
|
||||
t += 5 * 60 * 1000 + 1;
|
||||
const forced = await permittedDomains({ ...opts, force: true });
|
||||
assert.deepEqual(forced.codes, ['uc']);
|
||||
// cache was replaced: a plain read now serves the forced result
|
||||
const plain = await permittedDomains(opts);
|
||||
assert.deepEqual(plain, forced);
|
||||
});
|
||||
|
||||
test('cache is per phone', async () => {
|
||||
const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } }));
|
||||
const cache = new Map();
|
||||
const opts = { domains: CATALOG, portalUrl: PORTAL, fetchImpl, cache, now: () => 1000 };
|
||||
await permittedDomains({ ...opts, phone: 'a' });
|
||||
const callsAfterA = fetchImpl.calls.length;
|
||||
await permittedDomains({ ...opts, phone: 'b' });
|
||||
assert.ok(fetchImpl.calls.length > callsAfterA, 'second phone performs its own checks');
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
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.
|
||||
async function startFakePortal({ cookieStatus = 'Activated', allowed = true } = {}) {
|
||||
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' });
|
||||
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',
|
||||
firstName: 'Ada',
|
||||
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);
|
||||
assert.match(html, /smart-pxa\.yrka\.app/);
|
||||
assert.match(html, /\/fingerprint/);
|
||||
} 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);
|
||||
assert.equal(res.status, 502);
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { loadYrkaSession, extractPbCookieValue, tokenExpiresAtMs } from '../yrka-auth.js';
|
||||
import { makeJwt, makePbCookie, startServer, stopServer, urlOf, TEST_RECORD } from './helpers.js';
|
||||
|
||||
test('extractPbCookieValue handles prefixed and bare values', () => {
|
||||
assert.equal(extractPbCookieValue('pb_auth=abc123; Path=/; HttpOnly'), 'abc123');
|
||||
assert.equal(extractPbCookieValue('bare-value'), 'bare-value');
|
||||
});
|
||||
|
||||
test('tokenExpiresAtMs reads exp from a JWT payload', () => {
|
||||
const expSec = Math.floor(Date.now() / 1000) + 60;
|
||||
const token = makeJwt({ expiresInSec: 60 });
|
||||
assert.equal(tokenExpiresAtMs(token), expSec * 1000);
|
||||
assert.equal(tokenExpiresAtMs('garbage'), 0);
|
||||
assert.equal(tokenExpiresAtMs(''), 0);
|
||||
});
|
||||
|
||||
test('loadYrkaSession returns null without a cookie', async () => {
|
||||
assert.equal(await loadYrkaSession(''), null);
|
||||
assert.equal(await loadYrkaSession(undefined), null);
|
||||
});
|
||||
|
||||
test('loadYrkaSession returns null on malformed cookie values', async () => {
|
||||
let constructed = false;
|
||||
const pb = await loadYrkaSession('not-a-valid-envelope', {
|
||||
pbUrl: 'http://127.0.0.1:9',
|
||||
createPb: (url) => {
|
||||
constructed = true;
|
||||
return new PocketBase(url);
|
||||
},
|
||||
});
|
||||
assert.equal(pb, null);
|
||||
assert.equal(constructed, true, 'a PocketBase client was constructed before parsing failed');
|
||||
});
|
||||
|
||||
// The remaining tests exercise the real SDK against a fake PocketBase server,
|
||||
// so the cookie envelope and auth-refresh handling match production exactly.
|
||||
|
||||
async function withFakePb(refreshStatus, fn) {
|
||||
const server = await startServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url.startsWith('/api/collections/users/auth-refresh')) {
|
||||
res.writeHead(refreshStatus, { 'content-type': 'application/json' });
|
||||
if (refreshStatus === 200) {
|
||||
res.end(JSON.stringify({ token: makeJwt({ expiresInSec: 3600 }), record: TEST_RECORD }));
|
||||
} else {
|
||||
res.end(JSON.stringify({ code: 401, message: 'The request is not authenticated.' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end('{}');
|
||||
});
|
||||
try {
|
||||
await fn(urlOf(server));
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
test('loadYrkaSession accepts a valid, not-nearly-expired token without refreshing', async () => {
|
||||
await withFakePb(200, async (pbUrl) => {
|
||||
const cookie = makePbCookie(makeJwt({ expiresInSec: 3600 }));
|
||||
const yrka = await loadYrkaSession(cookie, { pbUrl });
|
||||
assert.ok(yrka);
|
||||
assert.equal(yrka.refreshed, false);
|
||||
assert.ok(yrka.pb.authStore.token);
|
||||
assert.equal(yrka.pb.authStore.record.id, TEST_RECORD.id);
|
||||
assert.equal(yrka.cookie, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('loadYrkaSession refreshes proactively when expiry is within the buffer', async () => {
|
||||
await withFakePb(200, async (pbUrl) => {
|
||||
const oldToken = makeJwt({ expiresInSec: 60 }); // inside the 5-minute buffer
|
||||
const cookie = makePbCookie(oldToken);
|
||||
const yrka = await loadYrkaSession(cookie, { pbUrl });
|
||||
assert.ok(yrka, 'session recovered');
|
||||
assert.equal(yrka.refreshed, true);
|
||||
assert.notEqual(yrka.pb.authStore.token, oldToken);
|
||||
assert.ok(yrka.cookie, 'refreshed cookie value is returned for persistence');
|
||||
});
|
||||
});
|
||||
|
||||
test('loadYrkaSession returns null when the token is expired and refresh fails', async () => {
|
||||
await withFakePb(401, async (pbUrl) => {
|
||||
const cookie = makePbCookie(makeJwt({ expiresInSec: -3600 }));
|
||||
const yrka = await loadYrkaSession(cookie, { pbUrl });
|
||||
assert.equal(yrka, null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user