Skip to content

Commit 6e44006

Browse files
committed
test(dogfood,rest,core,objectql): classify the assigned-work family; hold the new doubles to the engine contracts
The user-work REST family arrives classified: a matrix row (anonymous-deny-user-work) with its booted proof (anonymous 401 on both routes, a member's count served and hand-over reaching its own target check). The routes answer through the shared sendOk and are declared to the route-envelope gate. The new test doubles route update through assertEngineUpdateDispatch, refuse logical operators, and hold the limit. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 458493b commit 6e44006

9 files changed

Lines changed: 99 additions & 9 deletions

‎packages/core/src/security/user-assigned-work.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
45
import {
56
countUserAssignedWork,
67
isUserBanInForce,
@@ -69,12 +70,16 @@ describe('[#30205] userAssignedWorkFields', () => {
6970

7071
type Row = Record<string, any>;
7172

73+
/** Equality and `$contains`; a logical operator is refused, never read as a field. */
74+
const match = (row: Row, where: Record<string, any>) => Object.entries(where).every(([k, v]) => {
75+
if (k.startsWith('$')) throw new Error('fake engine: unsupported logical operator ' + k);
76+
return v && typeof v === 'object' && '$contains' in v ? Array.isArray(row[k]) && row[k].includes(v.$contains) : row[k] === v;
77+
});
78+
7279
/** An in-memory engine: equality and `$contains` filters, a per-object read/write refusal. */
7380
function fakeEngine(seed: Record<string, Row[]>, opts: { unreadable?: string[]; refuseUpdate?: (object: string, id: string) => boolean; disabled?: string[] } = {}) {
7481
const tables = new Map(Object.entries(seed).map(([k, rows]) => [k, rows.map((r) => ({ ...r }))]));
7582
const calls: Array<{ op: string; object: string; context: any; where?: any }> = [];
76-
const match = (row: Row, where: Record<string, any>) => Object.entries(where).every(([k, v]) =>
77-
v && typeof v === 'object' && '$contains' in v ? Array.isArray(row[k]) && row[k].includes(v.$contains) : row[k] === v);
7883
const guard = (object: string, context: any) => {
7984
if (opts.unreadable?.includes(object) && !context?.isSystem) throw Object.assign(new Error('denied'), { code: 'PERMISSION_DENIED' });
8085
};
@@ -95,6 +100,7 @@ function fakeEngine(seed: Record<string, Row[]>, opts: { unreadable?: string[];
95100
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
96101
},
97102
async update(object, data, o) {
103+
assertEngineUpdateDispatch(data, o);
98104
calls.push({ op: 'update', object, context: o?.context, where: o?.where });
99105
const row = (tables.get(object) ?? []).find((r) => match(r, o?.where ?? {}));
100106
if (!row) return null;

‎packages/objectql/src/engine-banned-user-reference.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ function makeStubDriver() {
6666
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
6767
async execute() { return null; },
6868
async find(object: string, ast: any) {
69-
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
69+
const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
70+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
7071
},
7172
async findOne(object: string, ast: any) {
7273
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
@@ -191,7 +192,7 @@ describe('[#30205] a new assignment to a banned user is refused', () => {
191192
const row: any = await engine.insert('ban_ticket', { title: 'T', assignee: 'u_alice' }, { context: ALICE } as any);
192193
const err = await refusalOf(() => engine.update('ban_ticket', { assignee: 'u_bob' }, { where: { id: row.id }, context: ALICE } as any));
193194
expect(err.fields[0]).toMatchObject({ field: 'assignee', code: 'reference_user_banned' });
194-
const after: any = await engine.findOne('ban_ticket', { where: { id: row.id }, context: { isSystem: true } } as any);
195+
const after: any = await engine.findOne('ban_ticket', { where: { id: row.id }, context: { isSystem: true } });
195196
expect(after.assignee).toBe('u_alice');
196197
});
197198

‎packages/qa/dogfood/test/authz-conformance.matrix.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
// hand-curated regex table reaching 1 of 17 REST registrars and 4 of 17
2525
// dispatcher domain files.
2626
//
27-
// The population comes from `packages/rest/src/rest-route-ledger.ts` (18
27+
// The population comes from `packages/rest/src/rest-route-ledger.ts` (19
2828
// families) and `packages/runtime/src/route-ledger.ts` (23
2929
// domains) because those two are enumerated from a RUNNING server and guarded
3030
// in both directions by their own conformance tests — so a new family or
@@ -311,6 +311,15 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
311311
// family key needs.
312312
covers: ['rest-family:rest-route-ledger.ts:openapi'],
313313
note: 'Measured on a booted showcase before the change: an unauthenticated caller was served the document (and its viewer page, since retired) with 200, just as a signed-in caller was, while the record doors on the same boot answered it 401 — ADR-0056 D2 is default-deny and no ADR-0138 D2 door class names this endpoint. The cited proof drives the document anonymously (401, the REST flat envelope, nothing of it served) and with a signed-in member (200, the document) on one boot. The environment-scoped twin mounts only under project scoping, which the showcase does not enable; it is the same handler closure, pinned per base in rest/src/rest-api-description-anonymous-deny.test.ts beside the gated-session 403 and the permission-store-outage 503. A deployment that publishes its API description to readers without an account publishes a static copy of the document instead.' },
314+
// #30205 — a user's assigned work, counted and handed over: a family that
315+
// arrives classified, the way `/approvals/act` did.
316+
{ id: 'anonymous-deny-user-work', summary: 'anonymous-deny on the assigned-work count and hand-over', state: 'enforced',
317+
enforcement: 'rest/user-work-routes.ts registerUserWorkEndpoints — both handlers (GET assigned-work, POST assigned-work/transfer) open with enforceAuth (→ shouldDenyAnonymous) as their first act after resolving the caller, ahead of the query-parameter and body checks and of any engine read or write; an anonymous caller gets the REST seam\'s flat 401 UNAUTHENTICATED, and a signed-in caller counts and moves only what their own context can read and update',
318+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
319+
// The REST family at ledger granularity: the family is exactly these two
320+
// routes and both handlers open with the floor.
321+
covers: ['rest-family:rest-route-ledger.ts:user-work'],
322+
note: 'New in #30205. The cited proof drives both routes anonymously (401, the REST flat envelope, nothing counted or moved) and with a signed-in member on one boot (the count served 200; the hand-over reaching its own unknown-target refusal, 404, before any write). Row admission and the update of each moved record stay with the security layer behind the engine, because both routes run as the caller.' },
314323
// #22432 — the `/i18n` dispatcher domain, under the ruling recorded on #22146
315324
// (the domain gains the domain-level anonymous refusal every other dispatcher
316325
// domain has, with an objectui companion for the Console's sign-in page).

‎packages/qa/dogfood/test/authz-conformance.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,8 @@ describe('the ledger-sourced population and its baseline bite', () => {
11031103
'rest-family:rest-route-ledger.ts:metadata',
11041104
// [#22430] classified by `anonymous-deny-api-description`.
11051105
'rest-family:rest-route-ledger.ts:openapi',
1106+
// [#30205] classified by `anonymous-deny-user-work`: it arrived classified.
1107+
'rest-family:rest-route-ledger.ts:user-work',
11061108
]);
11071109
// …and the two halves partition the population exactly: nothing minted by
11081110
// a ledger probe is both, and nothing is neither.

‎packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
// authz-row: anonymous-deny-packages
5252
// authz-row: anonymous-deny-analytics
5353
// authz-row: anonymous-deny-api-description
54+
// authz-row: anonymous-deny-user-work
5455
// authz-row: anonymous-deny-i18n
5556
//
5657
// ── Why the translation faces are here (#22432) ────────────────────────────
@@ -180,6 +181,26 @@ const API_DESCRIPTION_ENDPOINTS: readonly ApiDescriptionEndpoint[] = [
180181
const RETIRED_VIEWER_PATH = '/docs';
181182
const NEVER_SERVED_PATH = '/this-route-does-not-exist';
182183

184+
// ── #30205 — a user's assigned work, counted and handed over (@objectstack/rest)
185+
//
186+
// Both handlers open with the floor, ahead of the query-parameter check and
187+
// any engine read. The ids are ones no user holds, so the member controls
188+
// read nothing and write nothing: the count answers an empty summary, and the
189+
// hand-over refuses the unknown target before any write.
190+
interface UserWorkFace {
191+
readonly face: string;
192+
readonly method: 'GET' | 'POST';
193+
readonly path: string;
194+
readonly body?: unknown;
195+
}
196+
const USER_WORK_FACES: readonly UserWorkFace[] = [
197+
{ face: 'the assigned-work count', method: 'GET', path: '/security/users/usr_nobody_30205/assigned-work' },
198+
{
199+
face: 'the assigned-work hand-over', method: 'POST', path: '/security/users/usr_nobody_30205/assigned-work/transfer',
200+
body: { toUserId: 'usr_nobody_either_30205' },
201+
},
202+
];
203+
183204
// ── #22432 — the translation faces (dispatcher-mounted; runtime domains/i18n.ts)
184205
//
185206
// One handler body serves every face, so the floor is its first statement. The
@@ -809,6 +830,40 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
809830
expect(await adminRes.text(), 'the retired path answers the admin the ordinary not-found body').toBe(controlBody);
810831
});
811832

833+
// ── a user's assigned work (@objectstack/rest) — #30205 ─────────────────
834+
//
835+
// The refusal is each handler's first act, so the anonymous body is the REST
836+
// seam's shared one, whole, and nothing is counted or moved. The member
837+
// controls are the teeth: with a session the count is served 200 and the
838+
// hand-over reaches its own target check (404), so both doors are mounted
839+
// on this boot and the 401 is the floor's answer.
840+
it.each(USER_WORK_FACES)('an anonymous caller on $face is denied (401) and served nothing', async (face) => {
841+
const r = await anon(face.method, face.path, face.body);
842+
expect(r.status, `${face.face}: an anonymous caller must be refused by the floor`).toBe(ANONYMOUS_DENY_STATUS);
843+
const body = await r.json();
844+
expect(body, `${face.face}: the refusal is the REST seam's shared body, whole`).toEqual(ANONYMOUS_DENY_BODY);
845+
expect(body, `${face.face}: nothing of the work rides on the refusal`).not.toHaveProperty('data');
846+
});
847+
848+
it('a signed-in member is served the assigned-work count (200) — the control', async () => {
849+
const r = await stack.apiAs(memberToken, 'GET', USER_WORK_FACES[0].path);
850+
expect(r.status, 'an authenticated member must clear the floor and be served the count').toBe(200);
851+
const body = await r.json();
852+
expect(body.data, 'the served body is the count of a user who holds nothing').toMatchObject({
853+
userId: 'usr_nobody_30205', total: 0, objects: [],
854+
});
855+
});
856+
857+
it('a signed-in member reaches the hand-over\'s own target check (404), and nothing moves — the control', async () => {
858+
const face = USER_WORK_FACES[1];
859+
const r = await stack.apiAs(memberToken, face.method, face.path, face.body);
860+
expect(r.status, 'an authenticated member must clear the floor and meet the target check').toBe(404);
861+
const body = await r.json();
862+
expect(body.error?.details?.fields?.[0], 'the refusal names the unknown target').toMatchObject({
863+
field: 'toUserId', code: 'reference_not_found',
864+
});
865+
});
866+
812867
// ── the translation faces (dispatcher-mounted; runtime domains/i18n.ts) — #22432
813868
//
814869
// The same domain-wide shape as analytics: the floor is the handler's first

‎packages/rest/src/user-work-routes.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// [#30205] The REST face of a user's assigned work: the counts and the hand-over.
33

44
import { describe, it, expect, vi } from 'vitest';
5+
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
56
import { RestServer } from './rest-server';
67

78
function mockServer() {
@@ -35,8 +36,12 @@ function mockEngine() {
3536
getConfigs: () => ({ ticket: { name: 'ticket', fields: { owner_id: { type: 'lookup', reference: 'sys_user' } } } }),
3637
count: vi.fn(async (_o: string, q: any) => rows.filter((r) => r.owner_id === q.where.owner_id).length),
3738
find: vi.fn(async (o: string, q: any) => (o === 'sys_user' ? users : rows).filter((r: any) =>
38-
Object.entries(q.where).every(([k, v]) => r[k] === v)).slice(0, q.limit ?? Infinity)),
39+
Object.entries(q.where).every(([k, v]) => {
40+
if (k.startsWith('$')) throw new Error('fake engine: unsupported logical operator ' + k);
41+
return r[k] === v;
42+
})).slice(0, q.limit ?? Infinity)),
3943
update: vi.fn(async (_o: string, data: any, opts: any) => {
44+
assertEngineUpdateDispatch(data, opts);
4045
const row = rows.find((r) => r.id === opts.where.id)!;
4146
Object.assign(row, data);
4247
return row;

‎packages/rest/src/user-work-routes.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
* closures and the `objectQLProvider` field as a getter, read at request time.
3131
*/
3232

33-
import { sendError, sendServerFault } from '@objectstack/types';
33+
import { sendError, sendOk, sendServerFault } from '@objectstack/types';
3434
import {
3535
countUserAssignedWork,
3636
transferUserAssignedWork,
@@ -108,7 +108,7 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str
108108
const engine = await resolveEngine(environmentId, req);
109109
if (!engine) return sendError(res, 501, 'NOT_IMPLEMENTED', 'No data engine is configured on this deployment');
110110
const summary = await countUserAssignedWork(engine, userId, context);
111-
return res.json({ success: true, data: summary });
111+
return sendOk(res, summary);
112112
} catch (err) {
113113
return sendServerFault(res, 500, 'INTERNAL_ERROR', 'Counting the user\'s assigned work failed', err);
114114
}
@@ -151,7 +151,7 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str
151151
objects,
152152
describeFailure: rowFailureAnswer,
153153
});
154-
return res.json({ success: true, data: report });
154+
return sendOk(res, report);
155155
} catch (err) {
156156
if (!(err instanceof UserWorkTransferRefusal)) throw err;
157157
return sendError(res, err.status, err.code, err.message, err.field ? {

‎scripts/check-route-envelope.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ const MODULES = {
246246
'packages/services/service-datasource/src/admin-routes.ts': { responses: 0, ok: 0, err: 0 },
247247
'packages/rest/src/external-datasource-routes.ts': { responses: 0, ok: 0, err: 0 },
248248
'packages/rest/src/package-routes.ts': { responses: 0, ok: 0, err: 0 },
249+
// [#30205] The assigned-work count and hand-over: born on the shared pair.
250+
'packages/rest/src/user-work-routes.ts': { responses: 0, ok: 0, err: 0 },
249251
// #3636 put the right envelope on this module's three read routes but built it
250252
// inline in four places, so it carried a ratchet at 5 / 4 / 1 until those
251253
// collapsed behind a local `sendOk` — and then onto the shared one.

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
"verb": "update",
3232
"pinned": 1
3333
},
34+
{
35+
"file": "packages/core/src/security/user-assigned-work.test.ts",
36+
"verb": "update",
37+
"pinned": 1
38+
},
3439
{
3540
"file": "packages/core/src/utils/metadata-activation-store.test.ts",
3641
"verb": "update",
@@ -4721,6 +4726,11 @@
47214726
"verb": "findOne",
47224727
"pinned": 1
47234728
},
4729+
{
4730+
"file": "packages/rest/src/user-work-routes.test.ts",
4731+
"verb": "update",
4732+
"pinned": 1
4733+
},
47244734
{
47254735
"file": "packages/runtime/src/action-body-write-stale-summary.test.ts",
47264736
"verb": "delete",

0 commit comments

Comments
 (0)