Centered card with navbar, lock icon, sans-serif stack (error pages previously rendered in the browser default serif), and action-aware copy: permission denial offers only 'Back to Tools' instead of a pointless 'Try again'; auth failures keep Try again + Back to Tools.
421 lines
14 KiB
JavaScript
421 lines
14 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, 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/')) {
|
|
return json({
|
|
ok: true,
|
|
data: {
|
|
upload_id: 'up-1',
|
|
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
|
caption: '08068287222',
|
|
domain: '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 = () => {
|
|
const calls = { getMessage: 0, deleteMessage: 0 };
|
|
const item = {
|
|
messageId: 'm-1',
|
|
popReceipt: 'pop-1',
|
|
dequeueCount: 1,
|
|
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 } = {}) {
|
|
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: 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);
|
|
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);
|
|
assert.match(await page.text(), /Smart Photo Editor|Image Cropper/);
|
|
} 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?otp=778899`, {
|
|
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');
|
|
assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
|
|
assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity');
|
|
assert.equal(u.searchParams.get('otp'), '778899');
|
|
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 refuses without an OTP and without the crop permission', 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 noOtp = await fetch(`${base}/cropper/profile/picture`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
// permission denial dominates; 400 (missing OTP) or 403 both satisfy the spec
|
|
assert.ok([400, 403].includes(noOtp.status), `got ${noOtp.status}`);
|
|
const denied = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
|
|
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();
|
|
}
|
|
});
|