From 078ea87115dd121264c7a34fb7c92f4115119c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:16:26 +0000 Subject: [PATCH 1/4] fix(app-shell,console): an approval request's record page reads the request from the approvals service, never the data API's page payload The record page payload is the data API's read of a record, and the data API serves an approval request without its per-caller viewer block, with pending_approvers as the stored comma-joined string, and refuses the object to an ordinary approver. RecordDetailView now asks no payload for a request's own page, so every part reads on its own through the adapter, whose findOne the console routes to GET /approvals/requests/:id. Both routed sources refuse a request's getRecordPage and drop its prefetch, so a page drawn from the data API's row of a request cannot come back through that door. A drawer open that asks no payload keeps the related count warm-up. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw276TncPo635XS3B --- .../services/approvalRequestsDataSource.ts | 51 +++++++++++++++++-- .../app-shell/src/views/RecordDetailView.tsx | 23 +++++++-- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.ts b/ui/apps/console/src/services/approvalRequestsDataSource.ts index 70725e1230b..ce736840cb0 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.ts +++ b/ui/apps/console/src/services/approvalRequestsDataSource.ts @@ -111,7 +111,20 @@ * and the timeline's label read of its `request_id` column, which asks for * `{ id: { $in: [...] } }`) stays on the data API, which serves each caller * the requests the approvals door would (objectstack's request read gate, - * objectstack-ai/objectstack#22559). Every other member is the host's. + * objectstack-ai/objectstack#22559). Every other member is the host's, but one. + * + * ## A request's record page payload is refused (objectstack#28546) + * + * The host's record page payload (`getRecordPage`, objectstack#27627) is the + * data API's one read of a record page, `POST /data/:object/:id/page`. For a + * request it would read the data API's row: no `viewer` block, so the decision + * actions fail closed, and `pending_approvers` as the stored comma-joined + * string; and an ordinary approver is refused the object there outright. So + * `RecordDetailView` asks no payload for a request, and both sources here + * refuse one if asked: `getRecordPage` of `sys_approval_request` rejects with + * the `UNSUPPORTED_QUERY_PARAM` refusal before any request goes out, and + * `prefetchRecordPage` of one sends nothing. ⛔ It is never forwarded: a page + * drawn from the data API's row of a request is the defect this guards. */ import { ApiDataSource } from '@object-ui/core'; import { createAuthenticatedFetch } from '@object-ui/auth'; @@ -157,6 +170,9 @@ const DROPPED = new Set(['$select']); /** Host methods that read rows of a resource; refused for approval requests. */ const REFUSED_HOST_READS = new Set(['aggregate', 'queryGroupHeaders', 'exportDownload']); +/** The host's record page reads, never forwarded for a request (objectstack#28546). */ +const RECORD_PAGE_READS = new Set(['getRecordPage', 'prefetchRecordPage']); + /** The refusal `ListView` classifies as a rejected query (its error panel). */ function refusal(what: string): Error & { code: string } { const err = new Error( @@ -167,6 +183,32 @@ function refusal(what: string): Error & { code: string } { return err; } +/** The refusal a request's record page payload rejects with: the same code, its own reason. */ +function recordPageRefusal(): Error & { code: string } { + const err = new Error( + `${APPROVAL_REQUEST_OBJECT}: a request's record page is not read through the data API's page payload. ` + + 'Read the request with findOne, which the approvals service answers with its viewer.', + ) as Error & { code: string }; + err.code = 'UNSUPPORTED_QUERY_PARAM'; + return err; +} + +/** + * A host record page read, wrapped so a request's payload is refused + * (`getRecordPage`) and a request's prefetch sends nothing + * (`prefetchRecordPage`); `undefined` for every other member. + */ +function guardRecordPageRead( + prop: string, + method: (...args: unknown[]) => unknown, +): ((...args: unknown[]) => unknown) | undefined { + if (!RECORD_PAGE_READS.has(prop)) return undefined; + return (resource: unknown, ...rest: unknown[]) => { + if (resource !== APPROVAL_REQUEST_OBJECT) return method(resource, ...rest); + return prop === 'getRecordPage' ? Promise.reject(recordPageRefusal()) : undefined; + }; +} + /** An absent value, as the door's own query flattening reads one. */ function isAbsent(value: unknown): boolean { if (value === undefined || value === null) return true; @@ -386,7 +428,7 @@ export function createApprovalRequestsDataSource( REFUSED_HOST_READS.has(prop) ? (resource: unknown, ...rest: unknown[]) => resource === APPROVAL_REQUEST_OBJECT ? Promise.reject(refusal(prop)) : method(resource, ...rest) - : undefined, + : guardRecordPageRead(prop, method), ); } @@ -395,7 +437,8 @@ export function createApprovalRequestsDataSource( * to the approvals routes on every surface (objectui#12045): a `findOne` of * `sys_approval_request` and one request's `sys_approval_action` timeline. * Every other read, a `find` of `sys_approval_request` included, and every - * other member are the host's. See the module doc for why lists stay on the + * other member are the host's, except a request's record page payload, which + * is refused (objectstack#28546). See the module doc for why lists stay on the * data API. * * Returns the host's own type: the result IS the host, with two reads @@ -415,5 +458,5 @@ export function routeApprovalRecordReads( const findOne = async (resource: string, id: string, params?: QueryParams): Promise => resource === APPROVAL_REQUEST_OBJECT ? doors.findRequest(id) : host.findOne(resource, id, params); - return forwardToHost(host, { find, findOne }); + return forwardToHost(host, { find, findOne }, guardRecordPageRead); } diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index f9d94b9c484..7e0804502e9 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -2208,8 +2208,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // (`seedRecordPageReaders`) instead of asking. The record-load effect above // takes the record from it. Keyed on the plan's CONTENT (AGENTS.md #10). // An adapter without `getRecordPage` leaves every reader asking on its own. + // + // objectstack#28546 — so does an approval request's OWN page. The payload is + // the data API's read (`POST /data/{object}/{id}/page`), and a request is not + // a data-API record: the approvals service serves it + // (`GET /approvals/requests/:id`), with the per-caller `viewer` block the + // decision actions' `visible` predicates read and `pending_approvers` as an + // array. The data API attaches neither, and refuses the object to an ordinary + // approver. So a request's page asks each part on its own through the + // adapter, whose `findOne` the console routes to the approvals service + // (`routeApprovalRecordReads`); the page never widens what the data API reads. const recordPagePlan = useMemo(() => { if (!hasPage || !objectName || !pureRecordId || typeof dataSource?.getRecordPage !== 'function') return ''; + if (objectName === SYS_APPROVAL_REQUEST_OBJECT) return ''; const explain: RecordPageOperation[] = []; if (objectDef?.name && !editOffByObject) explain.push('update'); if (objectDef?.name && !deleteOffByObject) explain.push('delete'); @@ -2221,7 +2232,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri related: recordPageRelatedLists(effectivePage, assignedPage ? undefined : childRelations), explain, fieldAccess: typeof dataSource.getRecordFieldAccess === 'function', - approvals: objectName !== SYS_APPROVAL_REQUEST_OBJECT, + approvals: true, }); }, [hasPage, objectName, pureRecordId, dataSource, objectDef, editOffByObject, deleteOffByObject, pageRecordExpand, effectivePage, assignedPage, childRelations]); const recordPagePrincipal = perms.userId ?? null; @@ -4931,14 +4942,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // of the drawer its related rows and lookups open over it // (objectstack#26679): the same in-place stack, drawn by this same view. // In a drawer, the record ↑ ↓ shows next has its related counts warmed - // once this one has loaded (objectstack#27056, Target 4). An adapter that - // answers the record page payload asks that record's whole payload instead - // (above), so the counts are not asked twice (objectstack#27627). + // once this one has loaded (objectstack#27056, Target 4). An open that asks + // the record page payload from an adapter that prefetches it asks that + // record's whole payload instead (above), so the counts are not asked twice + // (objectstack#27627). An open that asks no payload (an approval request's, + // objectstack#28546) keeps the warm-up. if (embedded) { return ( <> {pageTree} - {typeof dataSource?.prefetchRecordPage === 'function' ? null : ( + {recordPagePlan && typeof dataSource?.prefetchRecordPage === 'function' ? null : ( Date: Sun, 1 Nov 2026 07:20:50 +0000 Subject: [PATCH 2/4] test(app-shell,console): pin an approval request's page reading the approvals get on the real adapter The account app's inbox row and ?request= deep link, a cold record route load, the admin approver's decisions and one chip per approver, all over the real ObjectStackAdapter under ApprovalRecordReads; the routed sources' refusal of a request's record page payload; and on app-shell, that a request's own page asks no payload while an ordinary record still does. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw276TncPo635XS3B --- ...ataSource.recordPagePayload-28546.test.tsx | 496 ++++++++++++++++++ ...ailView.approvalRequestPage-28546.test.tsx | 174 ++++++ 2 files changed, 670 insertions(+) create mode 100644 ui/apps/console/src/services/approvalRequestsDataSource.recordPagePayload-28546.test.tsx create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.recordPagePayload-28546.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.recordPagePayload-28546.test.tsx new file mode 100644 index 00000000000..2714bd696a8 --- /dev/null +++ b/ui/apps/console/src/services/approvalRequestsDataSource.recordPagePayload-28546.test.tsx @@ -0,0 +1,496 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectstack#28546 — an approval request's record page reads the request from + * the approvals service, never through the data API's record page payload. + * + * Measured by the card's two QA runs at objectstack `eeec88d1`: the record page + * took its record from `DataSource.getRecordPage` (objectstack#27627), which + * `routeApprovalRecordReads` forwarded to the console's adapter, so a request's + * page read `POST /data/sys_approval_request/:id/page`. An ordinary approver is + * refused that object on the data API (403 `PERMISSION_DENIED`), and the page + * drew the access-denied screen. An admin approver was answered the data API's + * row: no `viewer` block, so no Approve or Reject, and `pending_approvers` as + * the stored comma-joined string, so the "Waiting on" strip drew one chip per + * character. + * + * The harness is the console's own composition: the REAL `ObjectStackAdapter` + * (the adapter the console runs, record page payload included) under + * `ApprovalRecordReads`, the REAL inbox page and the REAL `RecordDetailView` + * drawing the page objectstack serves for a request. Only `fetch` and the + * inbox's queue read are doubled. The transport answers as the server does, + * for two personas: + * + * - `member`: a non-admin pending approver. The data API refuses every read + * of `sys_approval_request`, its page route included; the approvals get + * serves the request with `viewer`. + * - `admin`: a platform admin who is a pending approver. The data API's page + * route answers 200 with the stored row; the approvals get serves the + * request with `viewer` and its approvers as an array. + * + * Pinned: `approvals.account-app-entry` acceptance[3] (a row click opens the + * request's page, whose decision panel draws and moves the request) and + * acceptance[4] (`?request=` on the account app's component route lands on + * that request's page), the admin approver's decisions and its chip strip, and + * that the page sends no read of `sys_approval_request` to the data API at all. + * The routed sources' own refusal of a request's payload is pinned at the end. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; + +const { approvalsApiStub, INBOX_ROW, AUTH } = vi.hoisted(() => { + const INBOX_ROW = { + id: 'req_28546', + process_name: 'flow:showcase_invoice_signoff', + process_label: 'Invoice Sign-off', + object_name: 'showcase_invoice', + object_label: 'Invoice', + record_id: 'inv_1', + record_title: 'INV-1', + status: 'pending', + current_step: 'finance_review', + current_step_label: 'Finance Review', + step_label: 'Finance Review', + pending_approvers: ['u1'], + submitter_id: 'u9', + submitter_name: 'Sam Submitter', + submitted_at: '2026-11-01T05:00:00.000Z', + updated_at: '2026-11-01T05:00:00.000Z', + }; + // The inbox's own queue read. The request page never reads through it. + const approvalsApiStub = { + listRequests: vi.fn(async () => ({ data: [INBOX_ROW], total: 1 })), + getRequest: vi.fn(async () => ({ data: INBOX_ROW })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: INBOX_ROW, finalized: true })), + reject: vi.fn(async () => ({ data: INBOX_ROW, finalized: true })), + }; + // A STABLE singleton: a fresh user per render re-runs the inbox's load effect. + const AUTH = { user: { id: 'u1', name: 'Xavier Member', image: null }, activeOrganization: null }; + return { approvalsApiStub, INBOX_ROW, AUTH }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => AUTH, +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); +vi.mock('./approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +import { AdapterCtx, MetadataCtx, SchemaRendererProvider, useAdapter } from '@object-ui/react'; +import { I18nProvider, type I18nConfig } from '@object-ui/i18n'; +import { RecordDetailView } from '@object-ui/app-shell'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import type { DataSource } from '@object-ui/types'; +import { + APPROVAL_ACTION_OBJECT, + APPROVAL_REQUEST_OBJECT, + createApprovalRequestsDataSource, + routeApprovalRecordReads, +} from './approvalRequestsDataSource'; +import { ApprovalRecordReads } from '../AppContent'; +import { ApprovalsInboxPage } from '../pages/system/ApprovalsInboxPage'; + +const BASE = 'http://test.local'; +const REQ = INBOX_ROW.id; +/** The account app's package id, the segment its Approvals nav entry lands on. */ +const APP = 'com.objectstack.account'; +/** The account app's Approvals entry: the `approvals:inbox` component route. */ +const INBOX = `/apps/${APP}/component/approvals/inbox`; +const REQUEST_PAGE = `/apps/${APP}/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`; +const APPROVE_DOOR = `/api/v1/actions/${APPROVAL_REQUEST_OBJECT}/approval_approve`; + +/** The served decision gate: the leaf-guarded form the object declares. */ +const CAN_ACT = { + dialect: 'cel', + source: 'has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true', +}; +const DECISION_PARAMS = [{ name: 'comment', label: 'Comment', type: 'textarea', required: false }]; +const REQUEST_DEF = { + name: APPROVAL_REQUEST_OBJECT, + label: 'Approval Request', + pluralLabel: 'Approval Requests', + nameField: 'process_label', + titleFormat: '{process_name}', + highlightFields: ['process_label', 'record_id', 'status', 'current_step_label', 'submitter_id', 'updated_at'], + fields: { + id: { label: 'Request ID', type: 'text', readonly: true, group: 'System' }, + process_name: { label: 'Source', type: 'text', group: 'Target' }, + process_label: { label: 'Flow', type: 'text', readonly: true, group: 'Target' }, + object_name: { label: 'Object', type: 'text', group: 'Target' }, + record_id: { label: 'Record ID', type: 'text', referenceVia: 'object_name', group: 'Target' }, + submitter_id: { label: 'Submitter', type: 'lookup', reference: 'sys_user', group: 'Target' }, + status: { + label: 'Status', type: 'select', group: 'State', + options: [{ value: 'pending', label: 'Pending' }, { value: 'approved', label: 'Approved' }], + }, + current_step: { label: 'Current Step', type: 'text', group: 'State' }, + current_step_label: { label: 'Approval Step', type: 'text', readonly: true, group: 'State' }, + pending_approvers: { label: 'Pending Approvers', type: 'textarea', group: 'State' }, + updated_at: { label: 'Updated At', type: 'datetime', group: 'System' }, + }, + actions: [ + { + name: 'approval_approve', label: 'Approve', type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/approve', params: DECISION_PARAMS, + visible: CAN_ACT, locations: ['record_section', 'list_item'], refreshAfter: true, + }, + { + name: 'approval_reject', label: 'Reject', type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/reject', params: DECISION_PARAMS, + visible: CAN_ACT, locations: ['record_section', 'list_item'], refreshAfter: true, + }, + ], + enable: { apiMethods: ['get', 'list'] }, +}; +const ACTION_DEF = { + name: APPROVAL_ACTION_OBJECT, + label: 'Approval Action', + pluralLabel: 'Approval Actions', + nameField: 'display_title', + fields: { + id: { label: 'Action ID', type: 'text', readonly: true }, + request_id: { label: 'Request', type: 'lookup', reference: APPROVAL_REQUEST_OBJECT }, + action: { label: 'Action', type: 'text' }, + comment: { label: 'Comment', type: 'textarea' }, + created_at: { label: 'Created At', type: 'datetime' }, + }, + enable: { apiMethods: ['get', 'list'] }, +}; +const USER_DEF = { name: 'sys_user', label: 'User', nameField: 'name', fields: { name: { label: 'Name', type: 'text' } } }; +const INVOICE_DEF = { name: 'showcase_invoice', label: 'Invoice', nameField: 'name', fields: { name: { label: 'Invoice', type: 'text' } } }; +const DEFS: Record = { + [APPROVAL_REQUEST_OBJECT]: REQUEST_DEF, + [APPROVAL_ACTION_OBJECT]: ACTION_DEF, + sys_user: USER_DEF, + showcase_invoice: INVOICE_DEF, +}; + +/** objectstack `plugin-approvals`' `SysApprovalRequestDetailPage`, the parts this file reads. */ +const SERVED_PAGE = { + name: 'sys_approval_request_detail', + label: 'Approval Request', + type: 'record', + object: APPROVAL_REQUEST_OBJECT, + template: 'default', + kind: 'full', + isDefault: true, + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header' }, + { type: 'record:approval_decision' }, + { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { label: 'Details', icon: 'file-text', children: [{ type: 'record:details' }] }, + { + label: 'Timeline', + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: APPROVAL_ACTION_OBJECT, + relationshipField: 'request_id', + columns: ['created_at', 'action', 'comment'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + }, + }, + ], + }, + ], + }, + }, + ], + }, + ], +}; + +type Persona = 'member' | 'admin'; +const SLATE: Record; override: boolean }> = { + member: { approvers: ['u1'], names: { u1: 'Xavier Member' }, override: false }, + admin: { approvers: ['u1', 'u2', 'u3'], names: { u1: 'Dev Admin', u2: 'Ada Auditor', u3: 'Lee Legal' }, override: true }, +}; + +/** The persona the transport answers for, and whether its decision landed. */ +let persona: Persona = 'member'; +let decided = false; + +/** The request as `GET /approvals/requests/:id` serves it to the persona. */ +function servedRequest() { + const { process_name, process_label, object_name, record_id, current_step, current_step_label, submitter_id, updated_at } = INBOX_ROW; + const slate = SLATE[persona]; + return { + id: REQ, process_name, process_label, object_name, record_id, current_step, current_step_label, submitter_id, updated_at, + status: decided ? 'approved' : 'pending', + pending_approvers: decided ? [] : slate.approvers, + pending_approver_names: decided ? {} : slate.names, + viewer: { can_act: !decided, is_submitter: false, can_override: slate.override }, + }; +} + +/** The same request as the data API stores and serves it: no `viewer`, the slate comma-joined. */ +function dataApiRow() { + const { viewer: _viewer, pending_approver_names: _names, ...row } = servedRequest(); + return { ...row, pending_approvers: SLATE[persona].approvers.join(',') }; +} + +/** The data API's refusal of the object to an ordinary approver, in the server's body shape. */ +const REFUSAL = { + error: 'You do not have permission to perform this action. Contact your administrator if you need access.', + code: 'PERMISSION_DENIED', + object: APPROVAL_REQUEST_OBJECT, +}; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +/** Every request sent over HTTP: method, path with query (decoded), body. */ +let wire: Array<{ method: string; path: string; body?: unknown }> = []; + +const transport = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = new URL(raw, BASE); + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + wire.push({ method, path: decodeURIComponent(url.pathname + url.search), body }); + if (url.pathname === '/api/v1/discovery') return json(200, { success: true, data: { version: 'v1', routes: {} } }); + const meta = /^\/api\/v1\/meta\/object\/([^/]+)$/.exec(url.pathname); + if (meta) return json(200, { success: true, data: DEFS[meta[1]] ?? null }); + if (url.pathname.startsWith(`/api/v1/data/${APPROVAL_REQUEST_OBJECT}`)) { + if (persona === 'member') return json(403, REFUSAL); + if (method === 'POST' && url.pathname.endsWith('/page')) { + return json(200, { success: true, data: { object: APPROVAL_REQUEST_OBJECT, id: REQ, record: dataApiRow(), related: [] } }); + } + return json(200, { success: true, data: { records: [dataApiRow()], total: 1 } }); + } + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}`) return json(200, servedRequest()); + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}/actions`) return json(200, { data: [] }); + // The decision goes through the action door (objectstack#27611). + if (method === 'POST' && url.pathname === APPROVE_DOOR) { + decided = true; + return json(200, { success: true, data: { request: servedRequest(), finalized: true } }); + } + if (url.pathname.startsWith('/api/v1/data/')) return json(200, { success: true, data: { records: [], total: 0 } }); + return json(200, { success: true, data: [] }); +}); +vi.stubGlobal('fetch', transport); + +const metadata = { + apps: [], objects: [REQUEST_DEF, ACTION_DEF, USER_DEF, INVOICE_DEF], dashboards: [], reports: [], + pages: [SERVED_PAGE], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem: vi.fn(async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null)), + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; +const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +/** `DefaultAppContent`'s generic record route: the view over `useAdapter()`. */ +function GenericRecordRoute() { + return {}} />; +} + +/** Where the router is, and a Back button, outside the routes. */ +function RouterProbe() { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + {`${location.pathname}${location.search}`} + + + ); +} + +/** The console's composition at `entries`, over the real adapter. */ +async function renderAt(entries: string[]) { + const adapter = new ObjectStackAdapter({ baseUrl: BASE, fetch: transport as never, autoReconnect: false }); + await adapter.connect(); + expect(typeof adapter.getRecordPage, 'the harness runs the adapter that answers the record page payload').toBe('function'); + return render( + + + + + + + + + home} /> + } /> + } /> + + + + + + + , + ); +} + +const where = () => screen.getByTestId('where').textContent; +const requestGets = () => wire.filter((w) => w.method === 'GET' && w.path === `/api/v1/approvals/requests/${REQ}`); +/** Every read of the request the page sent to the data API: the page payload, a get, a list. */ +const dataApiReadsOfRequests = () => wire.filter((w) => w.path.startsWith(`/api/v1/data/${APPROVAL_REQUEST_OBJECT}`)); + +/** The request's page has drawn its decision panel, with both decisions, for this request. */ +async function decisionPanel(): Promise { + const panel = await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 }); + expect(await within(panel).findByTestId('declared-action-approval_approve', undefined, { timeout: 8000 })).toBeTruthy(); + expect(within(panel).getByTestId('declared-action-approval_reject')).toBeTruthy(); + expect((await screen.findByRole('heading', { level: 1 }, { timeout: 8000 })).textContent).toBe(INBOX_ROW.process_label); + return panel; +} + +/** The page read the request from the approvals get alone, and drew no lock screen. */ +function readFromTheApprovalsGetOnly(): void { + expect(requestGets().length).toBeGreaterThan(0); + expect(dataApiReadsOfRequests().map((w) => `${w.method} ${w.path}`)).toEqual([]); + expect(screen.queryByTestId('record-access-denied')).toBeNull(); +} + +beforeEach(() => { + persona = 'member'; + decided = false; + wire = []; + transport.mockClear(); + for (const fn of Object.values(approvalsApiStub)) fn.mockClear(); +}); +afterEach(cleanup); + +describe("a non-admin approver opens a request from the account app's inbox (approvals.account-app-entry)", () => { + it('acceptance[3]: a row click opens the request page, whose decision panel draws, and one decision moves the request', async () => { + await renderAt([INBOX]); + await screen.findAllByText(INBOX_ROW.record_title, undefined, { timeout: 8000 }); + const row = screen.getAllByRole('row').find((r) => within(r).queryAllByText(INBOX_ROW.record_title).length > 0); + if (!row) throw new Error('no inbox row for the request'); + fireEvent.click(row); + + const panel = await decisionPanel(); + expect(where()).toBe(REQUEST_PAGE); + readFromTheApprovalsGetOnly(); + + const before = requestGets().length; + fireEvent.click(within(panel).getByTestId('declared-action-approval_approve')); + const dialog = await screen.findByRole('dialog', undefined, { timeout: 8000 }); + fireEvent.change(await within(dialog).findByRole('textbox', undefined, { timeout: 8000 }), { target: { value: 'Checked' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /Confirm/ })); + await waitFor(() => expect(wire.some((w) => w.method === 'POST' && w.path === APPROVE_DOOR)).toBe(true), { timeout: 8000 }); + expect(wire.find((w) => w.method === 'POST' && w.path === APPROVE_DOOR)?.body).toMatchObject({ recordId: REQ }); + + // The request is re-read from the approvals get, and the decisions stop + // rendering: their visible predicates read `record.viewer.can_act`, now false. + await waitFor(() => expect(requestGets().length).toBeGreaterThan(before), { timeout: 8000 }); + await waitFor( + () => expect(screen.queryByTestId('declared-action-approval_approve')).toBeNull(), + { timeout: 8000 }, + ); + expect(screen.queryByTestId('declared-action-approval_reject')).toBeNull(); + expect(dataApiReadsOfRequests()).toEqual([]); + }); + + it('acceptance[3], cold: a load of the record route on the account app draws the decision panel', async () => { + await renderAt([REQUEST_PAGE]); + await decisionPanel(); + readFromTheApprovalsGetOnly(); + }); + + it('acceptance[4]: ?request= on the component route replaces itself with the record route, which shows that request', async () => { + await renderAt([`/apps/${APP}/home`, `${INBOX}?request=${REQ}`]); + await decisionPanel(); + expect(where()).toBe(REQUEST_PAGE); + readFromTheApprovalsGetOnly(); + // Replaced, not stacked: Back leaves to where the reader came from. + fireEvent.click(screen.getByRole('button', { name: 'probe-back' })); + expect(where()).toBe(`/apps/${APP}/home`); + }); +}); + +describe('an admin who is a pending approver, whom the data API would answer (objectstack#28546)', () => { + it('the page draws Approve and Reject from the served viewer, and one chip per approver', async () => { + persona = 'admin'; + await renderAt([`/apps/com.objectstack.setup/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`]); + const panel = await decisionPanel(); + readFromTheApprovalsGetOnly(); + const strip = await within(panel).findByTestId('approval-decision-pending-approvers', undefined, { timeout: 8000 }); + const chips = within(strip).getAllByTestId('approver-chip').map((chip) => chip.textContent); + expect(chips).toEqual(['Dev Admin', 'Ada Auditor', 'Lee Legal']); + }); +}); + +describe("both routed sources refuse a request's record page payload (objectstack#28546)", () => { + function hostWithPayload() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), + getObjectSchema: vi.fn(async () => null), + getRecordPage: vi.fn(async (resource: string, id: string) => ({ object: resource, id, record: { id } })), + prefetchRecordPage: vi.fn(), + }; + } + const SOURCES: Array<[string, (host: DataSource) => DataSource]> = [ + ['routeApprovalRecordReads', (host) => routeApprovalRecordReads(host, { fetch: transport, baseUrl: `${BASE}/api/v1` })], + [ + 'createApprovalRequestsDataSource', + (host) => createApprovalRequestsDataSource({ host, scope: { kind: 'all' }, fetch: transport, baseUrl: `${BASE}/api/v1` }), + ], + ]; + + for (const [name, routed] of SOURCES) { + it(`${name}: a request's getRecordPage rejects before any request goes out; another object's is the host's`, async () => { + const host = hostWithPayload(); + const source = routed(host as unknown as DataSource); + await expect(source.getRecordPage!(APPROVAL_REQUEST_OBJECT, REQ, { approvals: true })).rejects.toMatchObject({ + code: 'UNSUPPORTED_QUERY_PARAM', + }); + expect(host.getRecordPage).not.toHaveBeenCalled(); + expect(wire).toEqual([]); + await expect(source.getRecordPage!('showcase_invoice', 'inv_1', { approvals: true })).resolves.toMatchObject({ id: 'inv_1' }); + expect(host.getRecordPage).toHaveBeenCalledWith('showcase_invoice', 'inv_1', { approvals: true }); + }); + + it(`${name}: a request's prefetch sends nothing; another object's is the host's`, () => { + const host = hostWithPayload(); + const source = routed(host as unknown as DataSource); + source.prefetchRecordPage!(APPROVAL_REQUEST_OBJECT, REQ, {}); + expect(host.prefetchRecordPage).not.toHaveBeenCalled(); + source.prefetchRecordPage!('showcase_invoice', 'inv_1', {}); + expect(host.prefetchRecordPage).toHaveBeenCalledWith('showcase_invoice', 'inv_1', {}); + expect(wire).toEqual([]); + }); + + it(`${name}: CONTROL: over a host without the payload, it offers none`, () => { + const { getRecordPage: _get, prefetchRecordPage: _prefetch, ...host } = hostWithPayload(); + const source = routed(host as unknown as DataSource); + expect(typeof source.getRecordPage).toBe('undefined'); + expect(typeof source.prefetchRecordPage).toBe('undefined'); + }); + } +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx new file mode 100644 index 00000000000..0da6f246f9a --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx @@ -0,0 +1,174 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28546 — an approval request's OWN record page asks no record + * page payload. + * + * The payload (`DataSource.getRecordPage`, objectstack#27627) is the data + * API's read, `POST /data/{object}/{id}/page`. A request is the approvals + * service's record: its get serves the per-caller `viewer` block the decision + * actions read and the approvers as an array, the data API attaches neither and + * refuses the object to an ordinary approver. So a request's page reads each + * part on its own through the adapter: the record through `findOne`, which the + * console routes to the approvals get (pinned on the console's composition in + * `apps/console/src/services/approvalRequestsDataSource.recordPagePayload-28546.test.tsx`). + * + * Pinned here on the REAL adapter, whose payload every other record page asks: + * a request's page sends no page request and reads its record by `findOne`; an + * ordinary record on the same adapter still opens with one page request; and + * in a drawer, a request's next neighbour keeps the related-count warm-up an + * open without a payload has. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; +import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals'; +import { RecordDetailView } from './RecordDetailView'; +import { RecordDrawerUpcomingContext } from './recordDrawerStack'; +import { SYS_APPROVAL_REQUEST_OBJECT } from './recordApprovalActions'; + +const BASE = 'http://test.local'; +const ACTION = 'sys_approval_action'; +const CONTACT = 'os_28546_contact'; + +const requestDef = { + name: SYS_APPROVAL_REQUEST_OBJECT, + label: 'Approval Request', + nameField: 'process_label', + fields: { + id: { label: 'Id', type: 'text' }, + process_label: { label: 'Flow', type: 'text' }, + status: { label: 'Status', type: 'text' }, + }, +}; +const actionDef = { + name: ACTION, + label: 'Approval Action', + fields: { + id: { label: 'Id', type: 'text' }, + action: { label: 'Action', type: 'text' }, + request_id: { label: 'Request', type: 'lookup', reference: SYS_APPROVAL_REQUEST_OBJECT }, + }, +}; +const contactDef = { name: CONTACT, label: 'Contact', fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Name', type: 'text' } } }; +const ALL = [requestDef, actionDef, contactDef]; + +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +/** Every request sent, as `METHOD path` with the query decoded. */ +let wire: string[] = []; + +const transport = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? 'GET').toUpperCase(); + const path = decodeURIComponent(url.replace(BASE, '')); + wire.push(`${method} ${path}`); + if (path.endsWith('/api/v1/discovery')) return json({ success: true, data: { capabilities: {}, routes: {} } }); + const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(path); + if (meta) return json({ success: true, data: ALL.find((o) => o.name === meta[1]) ?? null }); + const page = /\/api\/v1\/data\/([^/?]+)\/([^/?]+)\/page$/.exec(path); + if (page) return json({ success: true, data: { object: page[1], id: page[2], record: { id: page[2], name: `Contact ${page[2]}` }, related: [] } }); + const one = /\/api\/v1\/data\/([^/?]+)\?.*\["id","=","([^"]+)"\]/.exec(path); + if (one && one[1] === SYS_APPROVAL_REQUEST_OBJECT) { + return json({ success: true, data: { records: [{ id: one[2], process_label: `Request ${one[2]}`, status: 'pending' }], total: 1 } }); + } + return json({ success: true, data: { records: [], total: 0 } }); +}); + +beforeEach(() => { + cleanup(); + wire = []; + __clearRecordEditableCache(); + __resetRecordApprovalsStore(); + RelatedCountStore._reset(); + vi.stubGlobal('fetch', transport); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +async function open(objectName: string, recordId: string, upcoming: readonly string[] = [], embedded = false) { + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch: transport as never, autoReconnect: false }); + await ds.connect(); + expect(typeof ds.getRecordPage, 'the adapter answers the record page payload').toBe('function'); + const metadata = { + objects: ALL, pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => [], getItem: async () => null, getItemsByType: () => [], + } as never; + return render( + + + + ({ objectName, recordId: id }))}> + {}} + objectNameOverride={objectName} + recordIdOverride={recordId} + embedded={embedded} + /> + + + + , + ); +} + +const pageRequests = () => wire.filter((w) => /^POST \/api\/v1\/data\/[^/?]+\/[^/?]+\/page$/.test(w)); +const recordReads = (object: string, id: string) => + wire.filter((w) => w.startsWith(`GET /api/v1/data/${object}?`) && w.includes(`["id","=","${id}"]`)); + +describe("objectstack#28546 — an approval request's own page asks no record page payload", () => { + it('reads the request by findOne and sends no page request', async () => { + await open(SYS_APPROVAL_REQUEST_OBJECT, 'req_1'); + await waitFor(() => expect(recordReads(SYS_APPROVAL_REQUEST_OBJECT, 'req_1').length).toBeGreaterThan(0), { timeout: 8000 }); + expect(pageRequests()).toEqual([]); + }); + + it('CONTROL: an ordinary record on the same adapter opens with its page request', async () => { + await open(CONTACT, 'c1'); + await waitFor(() => expect(pageRequests()).toEqual([`POST /api/v1/data/${CONTACT}/c1/page`]), { timeout: 8000 }); + expect(recordReads(CONTACT, 'c1')).toEqual([]); + }); + + it("in a drawer, a request's next neighbour has its related counts warmed, and no page is asked for either", async () => { + await open(SYS_APPROVAL_REQUEST_OBJECT, 'req_1', ['req_2'], true); + await waitFor( + () => + expect( + wire.filter((w) => w.startsWith(`GET /api/v1/data/${ACTION}?`) && w.includes('"request_id","=","req_2"')).length, + ).toBeGreaterThan(0), + { timeout: 8000 }, + ); + expect(pageRequests()).toEqual([]); + }); +}); From d8234f0543f850941bfeb4a67e0ec86009fad74a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:26:25 +0000 Subject: [PATCH 3/4] test(app-shell): read the request page's findOne as the adapter sends it, a get by id Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw276TncPo635XS3B --- ...cordDetailView.approvalRequestPage-28546.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx index 0da6f246f9a..07b284a16c2 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.approvalRequestPage-28546.test.tsx @@ -96,9 +96,10 @@ const transport = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => if (meta) return json({ success: true, data: ALL.find((o) => o.name === meta[1]) ?? null }); const page = /\/api\/v1\/data\/([^/?]+)\/([^/?]+)\/page$/.exec(path); if (page) return json({ success: true, data: { object: page[1], id: page[2], record: { id: page[2], name: `Contact ${page[2]}` }, related: [] } }); - const one = /\/api\/v1\/data\/([^/?]+)\?.*\["id","=","([^"]+)"\]/.exec(path); + // The record read of a page that asks no payload (`findOne`), as a get by id. + const one = /^\/api\/v1\/data\/([^/?]+)\/([^/?]+)$/.exec(path); if (one && one[1] === SYS_APPROVAL_REQUEST_OBJECT) { - return json({ success: true, data: { records: [{ id: one[2], process_label: `Request ${one[2]}`, status: 'pending' }], total: 1 } }); + return json({ success: true, data: { object: one[1], id: one[2], record: { id: one[2], process_label: `Request ${one[2]}`, status: 'pending' } } }); } return json({ success: true, data: { records: [], total: 0 } }); }); @@ -144,8 +145,13 @@ async function open(objectName: string, recordId: string, upcoming: readonly str } const pageRequests = () => wire.filter((w) => /^POST \/api\/v1\/data\/[^/?]+\/[^/?]+\/page$/.test(w)); +/** The page's own read of one record (`findOne`), by id or as a one-row list. */ const recordReads = (object: string, id: string) => - wire.filter((w) => w.startsWith(`GET /api/v1/data/${object}?`) && w.includes(`["id","=","${id}"]`)); + wire.filter( + (w) => + w === `GET /api/v1/data/${object}/${id}` || + (w.startsWith(`GET /api/v1/data/${object}?`) && w.includes(`["id","=","${id}"]`)), + ); describe("objectstack#28546 — an approval request's own page asks no record page payload", () => { it('reads the request by findOne and sends no page request', async () => { From 010fbf63c632ed33b54fdd6aa81bf3ebef06d050 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:45:40 +0000 Subject: [PATCH 4/4] chore(changeset): approval request page reads the approvals service Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw276TncPo635XS3B --- .changeset/28546-approval-request-page-reads.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/28546-approval-request-page-reads.md diff --git a/.changeset/28546-approval-request-page-reads.md b/.changeset/28546-approval-request-page-reads.md new file mode 100644 index 00000000000..7ffda763ee9 --- /dev/null +++ b/.changeset/28546-approval-request-page-reads.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +fix(app-shell,console): an approver who opens an approval request sees its decision panel again, read from the approvals service + +Clause-②: no +Metadata-protocol: none + +Since the record page started opening with one request (`POST /api/v1/data/:object/:id/page`), an approval request's own page took the request from that data API read. A non-admin approver is refused `sys_approval_request` there, so opening a request from the Approvals inbox, by its `?request=` link or by a cold load of its record route drew "You don't have access to Approval Request records" with no Approve, Reject or Reassign. An admin approver was answered the stored row: it carries no per-caller `viewer` block, so the decision actions stayed hidden, and its `pending_approvers` is the comma-joined string, so the "Waiting on" strip drew one chip per character. + +A request's page now asks no record page payload. Each part reads on its own through the adapter, as before the payload, and the Console routes the request read to `GET /api/v1/approvals/requests/:id`, which serves `viewer`, the decision tally and the approvers as an array. Nothing about what the data API lets an approver read changes. Every other record page still opens with its one request. + +The Console's routed adapters (`routeApprovalRecordReads` and the inbox's scoped source) now refuse a request's `getRecordPage` with the `UNSUPPORTED_QUERY_PARAM` refusal and send nothing for a request's `prefetchRecordPage`, so a request's page can no longer be drawn from the data API's row through that door. A record shown in a drawer whose open asks no payload keeps warming the next record's related counts.