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:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-01
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See proposal.md. The a2loft `cropper-token-auth` change adds token mode to the
|
||||||
|
profile-picture endpoints: `Authorization: Bearer <pb token>`, identity from
|
||||||
|
the token's phone, authorized with OpenFGA `crop_image` on `domain:<code>` then
|
||||||
|
`domain:idid`. This change consumes it.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** the cropper saves with the session token and no OTP; the save's
|
||||||
|
domain is a2loft's own record of the picture; the panel is truthful about the
|
||||||
|
profile's domain.
|
||||||
|
|
||||||
|
**Non-Goals:** the fingerprint wizard's OTP flow; the cropper's lookup (there
|
||||||
|
is none — pictures come from the queue); removing OTP support in a2loft.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Token sent from the server**, never exposed to the browser beyond the
|
||||||
|
page's existing injected session: the proxy attaches
|
||||||
|
`Authorization: Bearer ${req.session.token}`.
|
||||||
|
2. **Domain from a2loft, not the client**: `dims/picture/{uploadId}` is the
|
||||||
|
record of the picture; its `domainCode` is the profile's identity domain and
|
||||||
|
its `domain` is the picture target (`core`). Sent as `domainCode`, displayed
|
||||||
|
as both. Falls back to the queue item's label when a2loft has no record
|
||||||
|
(custom ids), guarded by a path-safety pattern.
|
||||||
|
3. **The `core → uc` alias is deleted**: `core` is a2loft's own pseudo-domain
|
||||||
|
(special-cased in `getUserByPhoneAsync`/`updatePicture`), so rewriting it —
|
||||||
|
as the old bot did for its own calls — changed attribution and the
|
||||||
|
notification. Authorization no longer depends on the domain, so no alias is
|
||||||
|
needed.
|
||||||
|
4. **Queue loads are enriched** with `domainCode` for display (one extra
|
||||||
|
read-only a2loft call per picture).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [a2loft record unavailable for an upload] → falls back to the queue label
|
||||||
|
under the path-safety check; the save still carries the token for authz.
|
||||||
|
- [Operators outside the domain's super-users] → irrelevant now: the OpenFGA
|
||||||
|
check with the `domain:idid` fallback authorizes them.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Deploy a2loft first (dual-mode, no client change), then this change; the
|
||||||
|
cropper saves without an OTP. Rollback = revert the proxy and re-add the field.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Proposal: cropper-token-auth
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The cropper's save proves the operator with a phone number and an OTP. a2loft
|
||||||
|
resolves that phone against the domain being saved to, so an operator without
|
||||||
|
super-user rights in that domain is rejected with a blanket `Invalid OTP
|
||||||
|
specified.` — even though they are authorized to crop images for the domain.
|
||||||
|
The a2loft endpoints now accept a PocketBase token (see the a2loft change
|
||||||
|
`cropper-token-auth`), which authorizes with OpenFGA `crop_image` and takes the
|
||||||
|
actor's identity from the token.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- The cropper's save posts the **session token** to a2loft instead of `phone` +
|
||||||
|
`otp`; the OTP input is removed from the UI.
|
||||||
|
- The save's domain comes from **a2loft's own picture record**
|
||||||
|
(`dims/picture/{uploadId}` → `domainCode`), falling back to the queue item's
|
||||||
|
label, and the panel shows both (the profile's domain and the picture
|
||||||
|
target).
|
||||||
|
- The `core → uc` alias is deleted: `core` is a2loft's own pseudo-domain for a
|
||||||
|
profile picture, not an alias for `uc`.
|
||||||
|
- The fingerprint wizard's staff lookup and fingerprint save keep their OTP;
|
||||||
|
only the cropper's calls change.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `image-cropper`: the save is token-authenticated (no OTP), the domain comes
|
||||||
|
from a2loft's record, and the OTP requirement is removed.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/cropper.js`: save handler (Bearer token, no `?otp=`), domain
|
||||||
|
resolution via dims with the label as fallback, path-safety check replacing
|
||||||
|
the catalog check, queue loads enriched with the profile's domain.
|
||||||
|
- `tools/public/assets/cropper/`: the OTP field and all `otp` state removed;
|
||||||
|
the panel shows the profile's domain and the picture target.
|
||||||
|
- Requires the a2loft `cropper-token-auth` change to be deployed first.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Spec Delta: image-cropper
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Saving requires a live gate and an OTP
|
||||||
|
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
||||||
|
permission live (bypassing the cache) and SHALL authenticate the operator with
|
||||||
|
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
|
||||||
|
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
|
||||||
|
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
|
||||||
|
back to the queue item's label, and SHALL match a path-safe pattern before it
|
||||||
|
is used in the request. A denied save SHALL not reach a2loft.
|
||||||
|
|
||||||
|
#### Scenario: Successful save
|
||||||
|
- **WHEN** a permitted operator saves
|
||||||
|
- **THEN** the route verifies the live gate and forwards the picture to
|
||||||
|
a2loft with the session token as a bearer credential and the profile's
|
||||||
|
resolved domain
|
||||||
|
|
||||||
|
#### Scenario: Denied save does not reach a2loft
|
||||||
|
- **WHEN** the live crop check denies, or the request carries no message body
|
||||||
|
- **THEN** the route answers 403/400 and makes no a2loft call
|
||||||
|
|
||||||
|
#### Scenario: Unsafe domain values are refused
|
||||||
|
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
|
||||||
|
domain
|
||||||
|
- **THEN** the save is refused with 400 and a2loft is not contacted
|
||||||
|
|
||||||
|
### Requirement: Unified login — no separate artist login
|
||||||
|
The cropper SHALL NOT implement its own login. The operator's identity comes
|
||||||
|
from the YOUreka session and is presented to a2loft as the session's PocketBase
|
||||||
|
token; the picture's own metadata carries the domain. The cropper collects no
|
||||||
|
other credential — there is no save-time OTP.
|
||||||
|
|
||||||
|
#### Scenario: Phone prefilled from the session
|
||||||
|
- **WHEN** a signed-in operator opens the cropper
|
||||||
|
- **THEN** their phone is already known to the page without any login form
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 1. Save handler: `Authorization: Bearer <session token>`, no `?phone/otp`;
|
||||||
|
domain resolved from `dims/picture/{uploadId}` (`domainCode`) with the queue
|
||||||
|
label as fallback, guarded by a path-safety pattern; `requireOtp` and the
|
||||||
|
`core → uc` alias deleted.
|
||||||
|
- [x] 2. Queue loads enriched with the profile's `domainCode` for display;
|
||||||
|
load-by-id passes it through.
|
||||||
|
- [x] 3. UI: OTP field, state and call parameters removed; panel shows the
|
||||||
|
profile's domain and the picture target.
|
||||||
|
- [x] 4. Tests: save carries the bearer token and no query auth; the resolved
|
||||||
|
domain wins over the body; unsafe values are refused; no OTP field in the
|
||||||
|
served page; queue load exposes `domainCode` (46 tests pass).
|
||||||
|
- [ ] 5. Deploy a2loft and this app, then a field pass: load a queued picture →
|
||||||
|
save with no OTP → confirm the picture lands attributed to the token's phone.
|
||||||
+36
-26
@@ -13,17 +13,11 @@ const require = createRequire(import.meta.url);
|
|||||||
|
|
||||||
const QUEUE_NAME = 'id-images';
|
const QUEUE_NAME = 'id-images';
|
||||||
/**
|
/**
|
||||||
* Queue domains that differ from their a2loft identity code.
|
* Domains are forwarded as the picture record states them. The queue's label
|
||||||
*
|
* (e.g. `core` from the Telegram intake) and the profile's identity domain
|
||||||
* A queue item's `domain` comes from the Telegram intake: the operator tags a
|
* (`domainCode`, e.g. `ucth`) are both legitimate values there: a2loft
|
||||||
* photo (e.g. `#core`) and the bot stores that label verbatim, then translated
|
* authorizes the actor with OpenFGA, not with the domain's uploader list.
|
||||||
* it before every a2loft call. a2loft has no `core` domain — `GET
|
|
||||||
* {a2loft}/identity/core/units` answers "Invalid domain code specified." while
|
|
||||||
* `uc` is valid — so the label must be mapped before the save is forwarded.
|
|
||||||
* (The proper fix is on the intake side: enqueue the a2loft code, or register
|
|
||||||
* `core` upstream; until then this keeps existing queue data working.)
|
|
||||||
*/
|
*/
|
||||||
const DOMAIN_ALIASES = { core: 'uc' };
|
|
||||||
const BUCKET = 'files';
|
const BUCKET = 'files';
|
||||||
const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
|
const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
|
||||||
|
|
||||||
@@ -138,6 +132,26 @@ export function createCropperRouter(cfg) {
|
|||||||
|
|
||||||
const UPSTREAM_TIMEOUT_MS = 15000;
|
const UPSTREAM_TIMEOUT_MS = 15000;
|
||||||
const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
|
const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile's identity domain as a2loft records it (`domainCode`), falling
|
||||||
|
* back to the queue item's own label. Used for the save and for display.
|
||||||
|
*/
|
||||||
|
const resolvePictureDomain = async (uploadId, fallback) => {
|
||||||
|
if (!uploadId) return fallback;
|
||||||
|
try {
|
||||||
|
const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(uploadId)}`, {
|
||||||
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const body = await r.json().catch(() => null);
|
||||||
|
return (body && body.ok && body.data && body.data.domainCode) || fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Path-safety only: a2loft decides which codes exist or are authorized. */
|
||||||
|
const SAFE_DOMAIN = /^[A-Za-z0-9_-]+$/;
|
||||||
const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
|
const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
|
||||||
|
|
||||||
const getMessage = () =>
|
const getMessage = () =>
|
||||||
@@ -153,14 +167,6 @@ export function createCropperRouter(cfg) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const requireOtp = (req, res) => {
|
|
||||||
if (!req.query.otp) {
|
|
||||||
res.status(400).json({ ok: false, message: 'OTP is required.' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── pictures ────────────────────────────────────────────────────────────
|
// ── pictures ────────────────────────────────────────────────────────────
|
||||||
router.get('/picture/next', async (req, res) => {
|
router.get('/picture/next', async (req, res) => {
|
||||||
let message;
|
let message;
|
||||||
@@ -178,6 +184,7 @@ export function createCropperRouter(cfg) {
|
|||||||
} catch {
|
} catch {
|
||||||
return res.json({ ok: false, message: 'Queue item is malformed.' });
|
return res.json({ ok: false, message: 'Queue item is malformed.' });
|
||||||
}
|
}
|
||||||
|
data.domainCode = await resolvePictureDomain(data.upload_id, null);
|
||||||
const { messageId, popReceipt, dequeueCount } = message;
|
const { messageId, popReceipt, dequeueCount } = message;
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -193,14 +200,14 @@ export function createCropperRouter(cfg) {
|
|||||||
const body = await r.json().catch(() => null);
|
const body = await r.json().catch(() => null);
|
||||||
const data = body && body.data;
|
const data = body && body.data;
|
||||||
if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' });
|
if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' });
|
||||||
const { upload_id, file_name, caption, domain, raw_file_id } = data;
|
const { upload_id, file_name, caption, domain, raw_file_id, domainCode } = data;
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
message_id: 'custom',
|
message_id: 'custom',
|
||||||
pop_receipt: 'custom',
|
pop_receipt: 'custom',
|
||||||
dequeue_count: 0,
|
dequeue_count: 0,
|
||||||
message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false },
|
message: { upload_id, file_name, caption, domain, domainCode, raw_file_id, from_queue: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -331,7 +338,6 @@ export function createCropperRouter(cfg) {
|
|||||||
// ── save (the one a2loft call that needs the operator OTP) ──────────────
|
// ── save (the one a2loft call that needs the operator OTP) ──────────────
|
||||||
router.post('/profile/picture', async (req, res) => {
|
router.post('/profile/picture', async (req, res) => {
|
||||||
if (!requireMessageBody(req, res)) return;
|
if (!requireMessageBody(req, res)) return;
|
||||||
if (!requireOtp(req, res)) return;
|
|
||||||
let live;
|
let live;
|
||||||
try {
|
try {
|
||||||
live = await cropPermission({
|
live = await cropPermission({
|
||||||
@@ -360,18 +366,22 @@ export function createCropperRouter(cfg) {
|
|||||||
message: { upload_id, domain, agentDeviceHash } = {},
|
message: { upload_id, domain, agentDeviceHash } = {},
|
||||||
},
|
},
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const urlDomain = DOMAIN_ALIASES[domain] || domain;
|
// The profile's own domain comes from a2loft's record, not the client.
|
||||||
if (!domainCodes.has(urlDomain)) {
|
const urlDomain = await resolvePictureDomain(upload_id, domain);
|
||||||
|
if (!SAFE_DOMAIN.test(urlDomain || '')) {
|
||||||
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
|
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
|
||||||
}
|
}
|
||||||
const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
|
|
||||||
try {
|
try {
|
||||||
const r = await fetchImpl(
|
const r = await fetchImpl(
|
||||||
`${base}/${encodeURIComponent(urlDomain)}/profile/picture?${q.toString()}`,
|
`${base}/${encodeURIComponent(urlDomain)}/profile/picture`,
|
||||||
{
|
{
|
||||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
// the session token proves the operator; a2loft authorizes with OpenFGA
|
||||||
|
authorization: `Bearer ${req.session.token}`,
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pictureDataUrl,
|
pictureDataUrl,
|
||||||
phone,
|
phone,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
accepted: false,
|
accepted: false,
|
||||||
cleanedImage: null,
|
cleanedImage: null,
|
||||||
requiresCleaning: '',
|
requiresCleaning: '',
|
||||||
otp: '',
|
|
||||||
reviewOptions: [
|
reviewOptions: [
|
||||||
'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
|
'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
|
||||||
'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
|
'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
|
||||||
@@ -59,7 +58,8 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
|
return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
|
||||||
},
|
},
|
||||||
canSave: function () {
|
canSave: function () {
|
||||||
return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== '';
|
// the session token authenticates the save; no OTP is involved
|
||||||
|
return !this.busy.save && this.requiresCleaning !== '';
|
||||||
},
|
},
|
||||||
otherOptionSelected: function () {
|
otherOptionSelected: function () {
|
||||||
return this.selectedReviewOptions.indexOf('Others not specified.') >= 0;
|
return this.selectedReviewOptions.indexOf('Others not specified.') >= 0;
|
||||||
@@ -124,7 +124,6 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.selectedReviewOptions = [];
|
this.selectedReviewOptions = [];
|
||||||
this.otherReviewOption = '';
|
this.otherReviewOption = '';
|
||||||
this.requiresCleaning = '';
|
this.requiresCleaning = '';
|
||||||
this.otp = '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
loadPicture: async function (mode) {
|
loadPicture: async function (mode) {
|
||||||
@@ -268,7 +267,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.busy.save = true;
|
this.busy.save = true;
|
||||||
try {
|
try {
|
||||||
var { data } = await axios.post(
|
var { data } = await axios.post(
|
||||||
'/cropper/profile/picture?otp=' + encodeURIComponent(this.otp),
|
'/cropper/profile/picture',
|
||||||
{
|
{
|
||||||
pictureDataUrl: this.cleanedImage || this.croppaInstance.generateDataUrl('image/jpeg'),
|
pictureDataUrl: this.cleanedImage || this.croppaInstance.generateDataUrl('image/jpeg'),
|
||||||
phone: this.message.message.caption,
|
phone: this.message.message.caption,
|
||||||
|
|||||||
@@ -69,7 +69,15 @@
|
|||||||
|
|
||||||
<dl class="details">
|
<dl class="details">
|
||||||
<div><dt>Phone</dt><dd>{{ message.message.caption || '—' }}</dd></div>
|
<div><dt>Phone</dt><dd>{{ message.message.caption || '—' }}</dd></div>
|
||||||
<div><dt>Domain</dt><dd>{{ message.message.domain }}</dd></div>
|
<div>
|
||||||
|
<dt>Domain</dt>
|
||||||
|
<dd>
|
||||||
|
{{ message.message.domainCode || message.message.domain }}
|
||||||
|
<span v-if="message.message.domainCode && message.message.domainCode !== message.message.domain" class="faint">
|
||||||
|
· picture target {{ message.message.domain }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div v-if="agent"><dt>Agent</dt><dd>{{ agent.name }} <span class="faint">{{ agent.phone }}</span></dd></div>
|
<div v-if="agent"><dt>Agent</dt><dd>{{ agent.name }} <span class="faint">{{ agent.phone }}</span></dd></div>
|
||||||
<div v-if="message.message.process">
|
<div v-if="message.message.process">
|
||||||
<dt>Process</dt>
|
<dt>Process</dt>
|
||||||
@@ -119,10 +127,6 @@
|
|||||||
|
|
||||||
<div v-if="reviewed && accepted" class="mt-2">
|
<div v-if="reviewed && accepted" class="mt-2">
|
||||||
<div class="row-gap" style="align-items:flex-end;">
|
<div class="row-gap" style="align-items:flex-end;">
|
||||||
<div style="min-width:150px; max-width:200px;">
|
|
||||||
<label class="cds--label" for="otpSave">OTP</label>
|
|
||||||
<input id="otpSave" class="cds--text-input" v-model.trim="otp" autocomplete="one-time-code">
|
|
||||||
</div>
|
|
||||||
<cds-button v-bind:disabled="!canSave" v-on:click="saveChanges">
|
<cds-button v-bind:disabled="!canSave" v-on:click="saveChanges">
|
||||||
<span v-if="!busy.save">Crop and upload picture</span>
|
<span v-if="!busy.save">Crop and upload picture</span>
|
||||||
<span v-else>Saving…</span>
|
<span v-else>Saving…</span>
|
||||||
|
|||||||
+32
-32
@@ -56,19 +56,27 @@ async function startFakeA2loft() {
|
|||||||
let raw = '';
|
let raw = '';
|
||||||
req.on('data', (c) => (raw += c));
|
req.on('data', (c) => (raw += c));
|
||||||
req.on('end', () => {
|
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) => {
|
const json = (body) => {
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
};
|
};
|
||||||
if (req.url.startsWith('/identity/dims/picture/')) {
|
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({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
upload_id: 'up-1',
|
upload_id: 'up-1',
|
||||||
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
||||||
caption: '08068287222',
|
caption: '08068287222',
|
||||||
domain: 'ucth',
|
domain: 'core',
|
||||||
|
domainCode: 'ucth',
|
||||||
raw_file_id: 'raw-1',
|
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
|
// notifications must use a component the bundle actually defines
|
||||||
assert.match(html, /cds-inline-notification/);
|
assert.match(html, /cds-inline-notification/);
|
||||||
assert.ok(!/cds-toast-item/.test(html), 'cds-toast-item is not a registered element');
|
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"/);
|
assert.match(html, /cds-side-nav-link href="\/cropper" aria-current="page"/);
|
||||||
} finally {
|
} finally {
|
||||||
await ok.stop();
|
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',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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'));
|
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
|
||||||
assert.ok(save, 'a2loft save contacted');
|
assert.ok(save, 'a2loft save contacted');
|
||||||
const u = new URL(save.url, 'http://x');
|
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(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
|
||||||
assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity');
|
assert.equal(u.search, '', 'no phone/otp query parameters');
|
||||||
assert.equal(u.searchParams.get('otp'), '778899');
|
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.phone, '08068287222', 'profile phone from the picture');
|
||||||
assert.equal(save.body.uploadId, 'up-1');
|
assert.equal(save.body.uploadId, 'up-1');
|
||||||
assert.ok(portal.state.verifyCalls > 0);
|
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 });
|
const { base, a2loft, stop } = await boot({ cropAllowed: false });
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
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 },
|
message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const noOtp = await fetch(`${base}/cropper/profile/picture`, {
|
const denied = 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',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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();
|
const { base, a2loft, stop } = await boot();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
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}`, {
|
const res = await fetch(`${base}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
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();
|
const { base, a2loft, stop } = await boot();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
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',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -480,12 +484,14 @@ test('save rejects an unknown domain without contacting a2loft', async () => {
|
|||||||
message: {
|
message: {
|
||||||
pop_receipt: 'custom',
|
pop_receipt: 'custom',
|
||||||
message_id: 'm-1',
|
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(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 {
|
} finally {
|
||||||
await stop();
|
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 () => {
|
test("the picture's own target is forwarded when a2loft has no record", async () => {
|
||||||
const legacy = JSON.stringify({
|
const { base, a2loft, stop } = await boot();
|
||||||
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) });
|
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
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',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -532,16 +531,17 @@ test('a queue domain of core is saved against the uc code (legacy alias)', async
|
|||||||
phone: '08068287222',
|
phone: '08068287222',
|
||||||
requiresCleaning: false,
|
requiresCleaning: false,
|
||||||
message: {
|
message: {
|
||||||
pop_receipt: 'pop-1',
|
pop_receipt: 'custom',
|
||||||
message_id: 'm-1',
|
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();
|
const body = await res.json();
|
||||||
assert.equal(res.status, 200, JSON.stringify(body));
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
|
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 {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user