Consumes the a2loft cropper-token-auth change: the save now sends the
session's PocketBase token as a bearer credential and no phone/otp. The
backend authorizes with OpenFGA crop_image (domain:<code> -> domain:idid),
so an operator no longer needs super-user rights in the profile's domain —
which is what made saves fail with a blanket 'Invalid OTP specified.'.
- the save's domain comes from a2loft's own record of the picture
(dims/picture/{uploadId} -> domainCode), falling back to the queue label,
guarded by a path-safety pattern instead of the catalog check
- the 'core' -> 'uc' alias is deleted: 'core' is a2loft's own pseudo-domain
for a profile picture (special-cased there), so rewriting it changed the
attribution and the ID-card notification
- queue loads are enriched with the profile's domainCode; the panel shows
the profile's domain and, when it differs, the picture target
- the cropper's OTP field, state and parameters are gone; the fingerprint
wizard is untouched (its own OTP flow on its own endpoints)
- tests updated: bearer + no query auth, resolved domain wins, unsafe values
refused, no OTP field served (46 pass)
Requires the a2loft change deployed first.
549 lines
20 KiB
JavaScript
549 lines
20 KiB
JavaScript
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 portal: web-auth + openfga verify with a configurable crop_image verdict.
|
|
async function startFakePortal({ cropAllowed = true } = {}) {
|
|
const state = { verifyCalls: 0 };
|
|
const server = await 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') {
|
|
return json(200, { ok: true, sessionId: 's1', loginUrl: 'https://portal.example/login' });
|
|
}
|
|
if (req.url.startsWith('/api/web/auth/s1/cookie')) {
|
|
return json(200, {
|
|
ok: true,
|
|
sessionId: 's1',
|
|
status: 'Activated',
|
|
cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })),
|
|
phone: '+2348068287222',
|
|
firstName: 'Ada',
|
|
lastName: 'Obi',
|
|
});
|
|
}
|
|
if (req.method === 'POST' && req.url === '/api/openfga/verify') {
|
|
state.verifyCalls += 1;
|
|
const body = JSON.parse(raw);
|
|
// capture_biometric checks (session endpoint) always pass; crop_image
|
|
// follows the configured verdict.
|
|
const allowed = body.relation === 'crop_image' ? cropAllowed : true;
|
|
return json(200, { ok: true, allowed });
|
|
}
|
|
json(404, { ok: false });
|
|
});
|
|
});
|
|
return { server, state, stop: () => stopServer(server) };
|
|
}
|
|
|
|
// Fake a2loft: dims load, save echo; records calls.
|
|
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,
|
|
authorization: req.headers.authorization,
|
|
body: raw ? JSON.parse(raw) : null,
|
|
});
|
|
const json = (body) => {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
if (req.url.startsWith('/identity/dims/picture/')) {
|
|
// 'no-record' lets a test exercise the fallback to the queue's own label
|
|
if (req.url.includes('no-record')) return json({ ok: false, message: 'No upload.' });
|
|
return json({
|
|
ok: true,
|
|
data: {
|
|
upload_id: 'up-1',
|
|
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
|
caption: '08068287222',
|
|
domain: 'core',
|
|
domainCode: 'ucth',
|
|
raw_file_id: 'raw-1',
|
|
},
|
|
});
|
|
}
|
|
if (req.url.includes('/profile/picture')) {
|
|
return json({ ok: true, message: 'Picture uploaded.' });
|
|
}
|
|
json({ ok: true });
|
|
});
|
|
});
|
|
return { calls, server, stop: () => stopServer(server) };
|
|
}
|
|
|
|
// Fake minio + azure queue — plain objects exposing just what the router uses.
|
|
const fakeMinio = () => ({
|
|
presignedGetObject: async (_bucket, name) => `https://storage.example/${name}`,
|
|
});
|
|
const fakeQueue = (messageText) => {
|
|
const calls = { getMessage: 0, deleteMessage: 0 };
|
|
const item = {
|
|
messageId: 'm-1',
|
|
popReceipt: 'pop-1',
|
|
dequeueCount: 1,
|
|
messageText:
|
|
messageText ??
|
|
JSON.stringify({
|
|
upload_id: 'up-1',
|
|
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
|
caption: '08068287222',
|
|
domain: 'ucth',
|
|
raw_file_id: 'raw-1',
|
|
}),
|
|
};
|
|
return {
|
|
calls,
|
|
// azure-storage callback signatures
|
|
getMessage: (_queue, _opts, cb) => {
|
|
calls.getMessage += 1;
|
|
cb(null, item);
|
|
},
|
|
deleteMessage: (_queue, _id, _pop, cb) => {
|
|
calls.deleteMessage += 1;
|
|
cb(null, { isSuccessful: true });
|
|
},
|
|
};
|
|
};
|
|
|
|
// Fake removebg + nodered upstreams.
|
|
async function startFakeRemovebg() {
|
|
const calls = [];
|
|
const server = await startServer((req, res) => {
|
|
let raw = '';
|
|
req.on('data', (c) => (raw += c));
|
|
req.on('end', () => {
|
|
calls.push({ url: req.url, key: req.headers['x-api-key'] });
|
|
res.writeHead(200, { 'content-type': 'image/png' });
|
|
res.end('PNGDATA');
|
|
});
|
|
});
|
|
return { calls, server, stop: () => stopServer(server) };
|
|
}
|
|
|
|
async function startFakeNodered() {
|
|
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, auth: req.headers.authorization });
|
|
const json = (body) => {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
if (req.url === '/idid/picture/review/up-1') {
|
|
return json({ ok: true, data: { agentPhoneHash: '12345' } });
|
|
}
|
|
if (req.url === '/unical/agents') {
|
|
return json([{ phoneHashes: [12345], name: 'Agent A', phone: '+2340000000000' }]);
|
|
}
|
|
if (req.url === '/zeebe/picture/review') {
|
|
return json({ ok: true });
|
|
}
|
|
json({ ok: false });
|
|
});
|
|
});
|
|
return { calls, server, stop: () => stopServer(server) };
|
|
}
|
|
|
|
async function boot({ cropAllowed = true, cropperClients, queue } = {}) {
|
|
const portal = await startFakePortal({ cropAllowed });
|
|
const a2loft = await startFakeA2loft();
|
|
const removebg = await startFakeRemovebg();
|
|
const nodered = await startFakeNodered();
|
|
// presigned URLs from the fake minio must not hit the real network
|
|
const testFetch = async (url, init) => {
|
|
if (String(url).startsWith('https://storage.example/')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: { get: () => 'image/png' },
|
|
arrayBuffer: async () => Buffer.from('PNGDATA'),
|
|
json: async () => ({}),
|
|
text: async () => 'PNGDATA',
|
|
};
|
|
}
|
|
return fetch(url, init);
|
|
};
|
|
const app = createApp({
|
|
portalUrl: urlOf(portal.server),
|
|
a2loftBase: urlOf(a2loft.server),
|
|
removebgUrl: urlOf(removebg.server),
|
|
noderedBase: urlOf(nodered.server),
|
|
fetch: testFetch,
|
|
cropperClients: cropperClients !== undefined ? cropperClients : {
|
|
minio: fakeMinio(),
|
|
queue: queue || fakeQueue(),
|
|
noderedAuth: { username: 'nr', password: 'nr-secret' },
|
|
carvekitKey: 'carve-key',
|
|
},
|
|
});
|
|
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 portal.stop();
|
|
await a2loft.stop();
|
|
await removebg.stop();
|
|
await nodered.stop();
|
|
};
|
|
return { base, a2loft, removebg, nodered, portal, stop };
|
|
}
|
|
|
|
const manual = { redirect: 'manual' };
|
|
|
|
async function login(base) {
|
|
const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
|
|
assert.equal(res.status, 302);
|
|
return `session=${cookieValue(res, 'session')}; user_profile=${cookieValue(res, 'user_profile')}`;
|
|
}
|
|
|
|
test('unconfigured deployment: cropper answers 503, other tools unaffected', async () => {
|
|
const { base, stop } = await boot({ cropperClients: null });
|
|
try {
|
|
const cookies = await login(base);
|
|
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
|
|
assert.equal(res.status, 503);
|
|
// the page says so too, rather than rendering actions that all fail
|
|
const page = await fetch(`${base}/cropper`, { headers: { cookie: cookies } });
|
|
assert.equal(page.status, 503);
|
|
assert.match(await page.text(), /not configured/i);
|
|
const landing = await fetch(`${base}/`);
|
|
assert.equal(landing.status, 200);
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('crop gate: permitted operator loads the page; denied operator does not', async () => {
|
|
const ok = await boot({ cropAllowed: true });
|
|
try {
|
|
const cookies = await login(ok.base);
|
|
const page = await fetch(`${ok.base}/cropper`, { headers: { cookie: cookies } });
|
|
assert.equal(page.status, 200);
|
|
const html = await page.text();
|
|
assert.match(html, /Smart Photo Editor|Image Cropper/);
|
|
assert.match(html, /window\.__SESSION__ = \{/, 'session injected server-side');
|
|
assert.match(html, /cds-header-nav-item href="\/cropper" aria-current="page"/);
|
|
// notifications must use a component the bundle actually defines
|
|
assert.match(html, /cds-inline-notification/);
|
|
assert.ok(!/cds-toast-item/.test(html), 'cds-toast-item is not a registered element');
|
|
assert.ok(!/otpSave|otpLookup/.test(html), 'the cropper has no OTP fields');
|
|
assert.match(html, /cds-side-nav-link href="\/cropper" aria-current="page"/);
|
|
} finally {
|
|
await ok.stop();
|
|
}
|
|
const denied = await boot({ cropAllowed: false });
|
|
try {
|
|
const cookies = await login(denied.base);
|
|
const page = await fetch(`${denied.base}/cropper`, { headers: { cookie: cookies } });
|
|
assert.equal(page.status, 403);
|
|
assert.match(await page.text(), /permission to crop images/i);
|
|
} finally {
|
|
await denied.stop();
|
|
}
|
|
});
|
|
|
|
test('picture routes are gated and proxied; reads use the cached gate', async () => {
|
|
const { base, portal, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
const before = portal.state.verifyCalls;
|
|
const next = await fetch(`${base}/cropper/picture/next`, { headers: { cookie: cookies } });
|
|
assert.equal(next.status, 200);
|
|
const body = await next.json();
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.data.message.upload_id, 'up-1');
|
|
|
|
// load-by-id goes to a2loft dims; image fetch returns a data URL
|
|
const loaded = await fetch(`${base}/cropper/picture/load/up-1`, { headers: { cookie: cookies } });
|
|
assert.equal((await loaded.json()).ok, true);
|
|
const image = await fetch(`${base}/cropper/picture/id-domains_ucth_raw-pictures_x_img.jpg`, {
|
|
headers: { cookie: cookies },
|
|
});
|
|
const imageBody = await image.json();
|
|
assert.equal(imageBody.ok, true);
|
|
assert.match(imageBody.data, /^data:image\/png;base64,/);
|
|
|
|
// several reads, but only one crop verify within the TTL
|
|
assert.equal(portal.state.verifyCalls - before, 1);
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('removebg is gated and proxies the cleanup service', async () => {
|
|
const denied = await boot({ cropAllowed: false });
|
|
try {
|
|
const cookies = await login(denied.base);
|
|
const res = await fetch(`${denied.base}/cropper/api/removebg`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ image_file_b64: 'QUJD' }),
|
|
});
|
|
assert.equal(res.status, 403);
|
|
assert.equal(denied.removebg.calls.length, 0, 'cleanup service not contacted');
|
|
} finally {
|
|
await denied.stop();
|
|
}
|
|
const ok = await boot({ cropAllowed: true });
|
|
try {
|
|
const cookies = await login(ok.base);
|
|
const res = await fetch(`${ok.base}/cropper/api/removebg`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ image_file_b64: 'QUJD' }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(await res.text(), 'PNGDATA');
|
|
assert.equal(ok.removebg.calls[0].key, 'carve-key');
|
|
} finally {
|
|
await ok.stop();
|
|
}
|
|
});
|
|
|
|
test('save verifies the gate live and proxies to a2loft with session identity', async () => {
|
|
const { base, a2loft, portal, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
// prime the cache with a denied verdict for the reads, then flip the
|
|
// portal to allowed — the live save check must see the new verdict.
|
|
const payload = {
|
|
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
|
|
phone: '08068287222',
|
|
requiresCleaning: true,
|
|
message: {
|
|
pop_receipt: 'pop-1',
|
|
message_id: 'm-1',
|
|
message: {
|
|
upload_id: 'up-1',
|
|
domain: 'ucth',
|
|
agentDeviceHash: 'agent-1',
|
|
caption: '08068287222',
|
|
file_url: null,
|
|
},
|
|
},
|
|
};
|
|
const res = await fetch(`${base}/cropper/profile/picture`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.equal(body.ok, true);
|
|
|
|
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
|
|
assert.ok(save, 'a2loft save contacted');
|
|
const u = new URL(save.url, 'http://x');
|
|
// the profile's own domain comes from a2loft's record ('ucth'), not the
|
|
// queue label the browser echoed ('ucth' here) — and no OTP is involved
|
|
assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
|
|
assert.equal(u.search, '', 'no phone/otp query parameters');
|
|
assert.match(save.authorization, /^Bearer \S+/, 'the session token proves the operator');
|
|
assert.equal(save.body.phone, '08068287222', 'profile phone from the picture');
|
|
assert.equal(save.body.uploadId, 'up-1');
|
|
assert.ok(portal.state.verifyCalls > 0);
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('save requires the live crop permission (no OTP involved)', async () => {
|
|
const { base, a2loft, stop } = await boot({ cropAllowed: false });
|
|
try {
|
|
const cookies = await login(base);
|
|
const payload = {
|
|
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
|
|
phone: '08068287222',
|
|
message: {
|
|
pop_receipt: 'custom',
|
|
message_id: 'm-1',
|
|
message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null },
|
|
},
|
|
};
|
|
const denied = await fetch(`${base}/cropper/profile/picture`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
assert.equal(denied.status, 403);
|
|
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'a2loft never contacted');
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('review and dequeue proxy with the queued message', async () => {
|
|
const { base, nodered, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
const agent = await fetch(`${base}/cropper/picture/review/up-1`, { headers: { cookie: cookies } });
|
|
const agentBody = await agent.json();
|
|
assert.equal(agentBody.ok, true, JSON.stringify(agentBody));
|
|
assert.equal(agentBody.data.name, 'Agent A');
|
|
|
|
const review = await fetch(`${base}/cropper/picture/review`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deleteFromQueue: true,
|
|
message: { message_id: 'm-1', pop_receipt: 'pop-1', message: { domain: 'ucth' } },
|
|
mode: 'raw',
|
|
raw: { phone: '08068287222', reviewRejectMessage: 'blurry' },
|
|
}),
|
|
});
|
|
const reviewBody = await review.json();
|
|
assert.equal(reviewBody.ok, true, JSON.stringify(reviewBody));
|
|
assert.ok(nodered.calls.find((c) => c.url === '/zeebe/picture/review'));
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('baseUrl exposes the identity-prefixed a2loft base', async () => {
|
|
const { base, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
|
|
const body = await res.json();
|
|
assert.equal(body.ok, true);
|
|
assert.match(body.data, /\/identity$/);
|
|
assert.ok(body.data.startsWith('http://127.0.0.1:'));
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
|
|
test('malformed request bodies are rejected, never hang the request', async () => {
|
|
const { base, a2loft, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture']) {
|
|
const res = await fetch(`${base}${path}`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
assert.equal(res.status, 400, `${path} → ${res.status}`);
|
|
const body = await res.json();
|
|
assert.equal(body.ok, false);
|
|
}
|
|
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0);
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('a malformed queue item does not hang the request', async () => {
|
|
const { base, stop } = await boot({ queue: fakeQueue('not-json') });
|
|
try {
|
|
const cookies = await login(base);
|
|
const res = await fetch(`${base}/cropper/picture/next`, { headers: { cookie: cookies } });
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.equal(body.ok, false);
|
|
assert.match(body.message, /malformed/i);
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('save rejects an unsafe domain and contacts no one', async () => {
|
|
const { base, a2loft, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
const res = await fetch(`${base}/cropper/profile/picture`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
|
|
phone: '08068287222',
|
|
message: {
|
|
pop_receipt: 'custom',
|
|
message_id: 'm-1',
|
|
// a2loft has no record for this upload, so the queue's label is used
|
|
// and the path-safety check rejects it
|
|
message: { upload_id: 'no-record', domain: '../../admin', agentDeviceHash: 'a' },
|
|
},
|
|
}),
|
|
});
|
|
assert.equal(res.status, 400);
|
|
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'no save attempted');
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
test('picture fetches reject object keys outside the id-domains prefix', async () => {
|
|
const { base, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
// encoded traversal survives as a single path segment; a plain ../ is
|
|
// normalised away by the HTTP client before the route ever sees it
|
|
const traversal = await fetch(`${base}/cropper/picture/..%2F..%2Fsecret`, { headers: { cookie: cookies } });
|
|
assert.equal(traversal.status, 400, `got ${traversal.status}`);
|
|
const escapeWithinPrefix = await fetch(`${base}/cropper/picture/id-domains_.._.._secret`, {
|
|
headers: { cookie: cookies },
|
|
});
|
|
assert.equal(escapeWithinPrefix.status, 400, `got ${escapeWithinPrefix.status}`);
|
|
const ok = await fetch(`${base}/cropper/picture/id-domains_ucth_raw-pictures_x_img.jpg`, {
|
|
headers: { cookie: cookies },
|
|
});
|
|
assert.equal(ok.status, 200);
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|
|
|
|
|
|
test("the picture's own target is forwarded when a2loft has no record", async () => {
|
|
const { base, a2loft, stop } = await boot();
|
|
try {
|
|
const cookies = await login(base);
|
|
const res = await fetch(`${base}/cropper/profile/picture`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
|
|
phone: '08068287222',
|
|
requiresCleaning: false,
|
|
message: {
|
|
pop_receipt: 'custom',
|
|
message_id: 'm-1',
|
|
message: { upload_id: 'no-record', domain: 'core', agentDeviceHash: 'a' },
|
|
},
|
|
}),
|
|
});
|
|
const body = await res.json();
|
|
assert.equal(res.status, 200, JSON.stringify(body));
|
|
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
|
|
// 'core' is a2loft's own pseudo-domain for a profile picture — no rewriting
|
|
assert.equal(decodeURIComponent(new URL(save.url, 'http://x').pathname), '/identity/core/profile/picture');
|
|
} finally {
|
|
await stop();
|
|
}
|
|
});
|