feat(cropper): save with the session token — no OTP, domain from a2loft

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.
This commit is contained in:
2026-10-01 12:37:25 +01:00
parent 88d71ffec0
commit 796094f61b
9 changed files with 229 additions and 67 deletions
+32 -32
View File
@@ -56,19 +56,27 @@ async function startFakeA2loft() {
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 });
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: 'ucth',
domain: 'core',
domainCode: 'ucth',
raw_file_id: 'raw-1',
},
});
@@ -241,6 +249,7 @@ test('crop gate: permitted operator loads the page; denied operator does not', a
// 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();
@@ -336,7 +345,7 @@ test('save verifies the gate live and proxies to a2loft with session identity',
},
},
};
const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
const res = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
@@ -348,9 +357,11 @@ test('save verifies the gate live and proxies to a2loft with session identity',
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.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity');
assert.equal(u.searchParams.get('otp'), '778899');
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);
@@ -359,7 +370,7 @@ test('save verifies the gate live and proxies to a2loft with session identity',
}
});
test('save refuses without an OTP and without the crop permission', async () => {
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);
@@ -372,14 +383,7 @@ test('save refuses without an OTP and without the crop permission', async () =>
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`, {
const denied = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
@@ -437,7 +441,7 @@ 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?otp=1']) {
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' },
@@ -467,11 +471,11 @@ test('a malformed queue item does not hang the request', async () => {
}
});
test('save rejects an unknown domain without contacting a2loft', async () => {
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?otp=778899`, {
const res = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify({
@@ -480,12 +484,14 @@ test('save rejects an unknown domain without contacting a2loft', async () => {
message: {
pop_receipt: 'custom',
message_id: 'm-1',
message: { upload_id: 'up-1', domain: '../../admin', agentDeviceHash: 'a' },
// 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.length, 0, 'a2loft never contacted');
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'no save attempted');
} finally {
await stop();
}
@@ -513,18 +519,11 @@ test('picture fetches reject object keys outside the id-domains prefix', async (
});
test('a queue domain of core is saved against the uc code (legacy alias)', async () => {
const legacy = JSON.stringify({
upload_id: 'up-core',
file_name: 'id-domains/uc/raw-pictures/x/img.jpg',
caption: '08068287222',
domain: 'core',
raw_file_id: 'raw-1',
});
const { base, a2loft, stop } = await boot({ queue: fakeQueue(legacy) });
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?otp=778899`, {
const res = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify({
@@ -532,16 +531,17 @@ test('a queue domain of core is saved against the uc code (legacy alias)', async
phone: '08068287222',
requiresCleaning: false,
message: {
pop_receipt: 'pop-1',
pop_receipt: 'custom',
message_id: 'm-1',
message: { upload_id: 'up-core', domain: 'core', agentDeviceHash: 'a' },
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'));
assert.equal(decodeURIComponent(new URL(save.url, 'http://x').pathname), '/identity/uc/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();
}