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:
+32
-32
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user