feat(cropper): move the Image Cropper into the tools app at /cropper
- Smart Photo Editor ported from id-image-bot: queue/by-ID picture loading, vue-croppa editing, carvekit background cleanup, nodered review flow, a2loft save — restyled to the tools design language - unified login: YOUreka session identity, phone prefilled, OTP only at save (a2loft's requirement); artist-login step removed - crop_image @ domain:idid OpenFGA gate on every cropper route: cached 5-min on reads, live on save, fail closed - credentials (minio/azure/nodered/carvekit) via env, sole exception to hardcoded config; 503 when unconfigured; native FormData replaces form-data; new deps minio + azure-storage - 10 new tests (35 total) with fake minio/queue/nodered/removebg/a2loft
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
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(), /not permitted/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();
|
||||
}
|
||||
});
|
||||
@@ -124,8 +124,8 @@ test('landing page links both tools', async () => {
|
||||
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/);
|
||||
assert.match(html, /href="\/cropper"/);
|
||||
assert.match(html, /href="\/fingerprint"/);
|
||||
} finally {
|
||||
await stop();
|
||||
}
|
||||
@@ -148,7 +148,7 @@ test('auth callback rejects unredeemable sessions without setting cookies', asyn
|
||||
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(res.status, 401);
|
||||
assert.equal(cookieValue(res, 'session'), undefined);
|
||||
const html = await res.text();
|
||||
assert.match(html, /timed out/i);
|
||||
|
||||
Reference in New Issue
Block a user