Skip to content

Commit e6b0e84

Browse files
committed
wip: #13419 slice 2 determination gate + fold warning
1 parent 2cce3fd commit e6b0e84

3 files changed

Lines changed: 796 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13419 执行要点 3, warning half] The position-name fold, said out loud.
5+
*
6+
* ## What is pinned
7+
*
8+
* `resolvePermissionSetsForContextUnmemoized` requests
9+
* `[...positions, ...explicitPermissionSets]`, so a POSITION name resolves a
10+
* same-named PERMISSION SET with no `sys_position_permission_set` row behind
11+
* it. The maintainer ruling (2026-08-31, 「同意」) makes the junction table the
12+
* one governed channel; 要点 5 permits a warning and nothing else until the fold
13+
* itself is deleted — 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」.
14+
*
15+
* ## Where the tuples come from
16+
*
17+
* `scripts/measure-position-name-fold-census.mjs` (slice 1, merged `2cd0821cf`)
18+
* classifies all 19 declared positions into three groups, and the constants
19+
* below are that classification transcribed. This file pins the runtime
20+
* PREDICATE against that classification in BOTH directions; the census pins the
21+
* classification against the repository. Neither substitutes for the other.
22+
*
23+
* ⚠️ `sales_rep` and `sales_manager` appear in BOTH the fold list and the
24+
* junction list, and that is the finding slice 1 exists for: each is bound to
25+
* `crm_sales_user`, and is folded onto its own same-name set anyway. A
26+
* predicate that asked "is this position bound to anything?" would report
27+
* neither of the repository's two real folds while looking complete.
28+
*
29+
* ## The expensive failure mode, pinned first
30+
*
31+
* ⛔ A false positive on a built-in identity. `platform_admin`, `org_owner`,
32+
* `org_admin`, `org_member` and `guest` are positions every deployment carries;
33+
* warning on them would train operators to filter the very token this warning
34+
* exists to be found by. `org_admin` sits one underscore from the real
35+
* permission set `organization_admin`, so the near-miss is pinned explicitly
36+
* rather than assumed.
37+
*/
38+
39+
import { describe, it, expect, vi } from 'vitest';
40+
import { SecurityPlugin } from './security-plugin.js';
41+
import type { PermissionSet } from '@objectstack/spec/security';
42+
import type { ISecurityService } from '@objectstack/spec/contracts';
43+
import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
44+
45+
/** The stable event token. Asserted as a LITERAL, never imported: an imported
46+
* constant renames itself along with the source and the pin never notices. */
47+
const EVENT = 'position_name_fold_grant';
48+
49+
/**
50+
* The census's `NAME-FOLD DEPENDENCIES` block — grants in force with no
51+
* junction row. Position half declared by `examples/app-crm/src/security/
52+
* sales-positions.ts`; permission-set half by the vendored HotCRM artifact.
53+
*/
54+
const CENSUS_NAME_FOLDS = ['sales_rep', 'sales_manager'] as const;
55+
56+
/**
57+
* The census's `JUNCTION BINDINGS` block — 13 rows, the governed channel. Every
58+
* one binds a position to a DIFFERENTLY named set, which is why none of them is
59+
* a fold: the fold is about a position's own name.
60+
*/
61+
const CENSUS_JUNCTION_BINDINGS: ReadonlyArray<readonly [position: string, set: string]> = [
62+
['sales_rep', 'crm_sales_user'],
63+
['sales_manager', 'crm_sales_user'],
64+
['finance_approver', 'crm_sales_user'],
65+
['contributor', 'showcase_contributor'],
66+
['manager', 'showcase_manager'],
67+
['exec', 'showcase_executive'],
68+
['auditor', 'showcase_auditor'],
69+
['ops', 'showcase_ops'],
70+
['field_ops_delegate', 'showcase_field_ops_delegate'],
71+
['client_liaison', 'showcase_client_liaison'],
72+
['client_portal_user', 'showcase_guest_portal'],
73+
['everyone', 'member_default'],
74+
['everyone', 'showcase_member_default'],
75+
];
76+
77+
/**
78+
* The census's `INERT POSITIONS` block, printed under a heading that states the
79+
* obligation in terms: "要点 3's collision warning must NOT fire on these."
80+
*/
81+
const CENSUS_INERT_POSITIONS = [
82+
'platform_admin',
83+
'org_owner',
84+
'org_admin',
85+
'org_member',
86+
'guest',
87+
'finance',
88+
'legal',
89+
] as const;
90+
91+
function set(name: string): PermissionSet {
92+
return { name, label: name, objects: {}, fields: {}, systemPermissions: [], tabPermissions: {} } as any;
93+
}
94+
95+
/**
96+
* The permission-set universe these cases resolve against: every junction
97+
* TARGET, the two same-name sets the HotCRM artifact contributes, the platform
98+
* baseline, and `organization_admin` — the near-miss that must not be credited
99+
* to the `org_admin` position.
100+
*
101+
* ⛔ No set is named after any inert or non-folding position, which is the
102+
* repository's own state and the reason those positions are inert. The
103+
* MUST-FIRE cases below are what stop that absence from making the MUST-NOT
104+
* cases pass trivially: the same universe, the same predicate, two verdicts.
105+
*/
106+
const UNIVERSE: PermissionSet[] = [
107+
...new Set([...CENSUS_JUNCTION_BINDINGS.map(([, s]) => s), ...CENSUS_NAME_FOLDS, 'member_default', 'organization_admin']),
108+
].map(set);
109+
110+
function boot(universe: PermissionSet[] = UNIVERSE) {
111+
const ql: any = {
112+
registerMiddleware: () => {},
113+
getSchema: () => null,
114+
findOne: async (object: string, query?: EngineFindOneQueryInput) => {
115+
assertEngineFindOnePredicate(object, query);
116+
return null;
117+
},
118+
find: async () => [],
119+
};
120+
const metadata: any = { get: async () => null, list: async () => universe };
121+
const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata };
122+
const warn = vi.fn();
123+
const ctx: any = {
124+
logger: { info: vi.fn(), warn, error: vi.fn() },
125+
registerService: vi.fn(),
126+
getService: (name: string) => {
127+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
128+
return services[name];
129+
},
130+
};
131+
return { plugin: new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any), ctx, warn };
132+
}
133+
134+
/** Resolve through the registered service handle, as every real consumer does. */
135+
async function resolveWith(context: Record<string, unknown>, universe: PermissionSet[] = UNIVERSE) {
136+
const { plugin, ctx, warn } = boot(universe);
137+
await plugin.init(ctx);
138+
await plugin.start(ctx);
139+
const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>;
140+
const sets = await svc.resolvePermissionSetsForContext?.(context as any);
141+
const events = warn.mock.calls
142+
.filter((c) => typeof c[0] === 'string' && c[0].includes(EVENT))
143+
.map((c) => c[1]);
144+
return { sets: (sets ?? []).map((s) => s.name), events, warn, svc };
145+
}
146+
147+
describe('[#13419] MUST FIRE — a position folded onto its own same-name set with no junction row', () => {
148+
for (const position of CENSUS_NAME_FOLDS) {
149+
it(`warns for '${position}', the census's own cross_scope fold`, async () => {
150+
// The measured shape: the position IS junction-bound — to `crm_sales_user`,
151+
// not to itself — so `permissions` carries that other set. The grant on
152+
// the same-name set has no junction row behind it at all.
153+
const { sets, events } = await resolveWith({
154+
userId: 'u1',
155+
positions: [position],
156+
permissions: ['crm_sales_user'],
157+
});
158+
159+
// Reported once, naming both halves of the pair.
160+
expect(events).toHaveLength(1);
161+
expect(events[0]).toMatchObject({ event: EVENT, position, permissionSet: position });
162+
163+
// ⛔ Purely additive: the fold still grants exactly what it granted before.
164+
// A warning that also changed the answer would be the silent behaviour
165+
// change 要点 5 forbids.
166+
expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', position].sort());
167+
});
168+
}
169+
170+
it('names the ungoverned grant and the two ways out, not just the collision', async () => {
171+
const { warn } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] });
172+
const message = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes(EVENT))!;
173+
expect(message).toContain('sys_position_permission_set');
174+
expect(message).toContain('ungoverned');
175+
expect(message).toMatch(/rename/i);
176+
});
177+
178+
it('is LOUD ONCE per position, not once per request', async () => {
179+
const { plugin, ctx, warn } = boot();
180+
await plugin.init(ctx);
181+
await plugin.start(ctx);
182+
const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>;
183+
// Distinct context OBJECTS, so the per-context memo cannot be what silences
184+
// the second call — the deduplication under test has to be the one in the
185+
// reporter.
186+
for (let i = 0; i < 3; i++) {
187+
await svc.resolvePermissionSetsForContext?.({ userId: `u${i}`, positions: ['sales_rep'] } as any);
188+
}
189+
expect(warn.mock.calls.filter((c) => String(c[0]).includes(EVENT))).toHaveLength(1);
190+
});
191+
});
192+
193+
describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects', () => {
194+
it.each(CENSUS_INERT_POSITIONS.map((p) => [p]))(
195+
'stays silent for the inert position %s (a built-in-identity false positive is the most expensive failure here)',
196+
async (position) => {
197+
const { events, sets } = await resolveWith({ userId: 'u1', positions: [position] });
198+
expect(events).toEqual([]);
199+
// Silent for the right reason: nothing resolved off the position name, so
200+
// the caller fell back to the baseline. A pass produced by a broken
201+
// resolution would show up here as an empty set list.
202+
expect(sets).toEqual(['member_default']);
203+
},
204+
);
205+
206+
it('stays silent for org_admin even though the set organization_admin exists (near-miss, not a collision)', async () => {
207+
const { events } = await resolveWith({ userId: 'u1', positions: ['org_admin'] });
208+
expect(events).toEqual([]);
209+
});
210+
211+
it.each(CENSUS_JUNCTION_BINDINGS.map(([p, s]) => [p, s]))(
212+
'stays silent for the junction binding %s -> %s',
213+
async (position, boundSet) => {
214+
const { events } = await resolveWith({ userId: 'u1', positions: [position], permissions: [boundSet] });
215+
// ⚠️ `sales_rep` and `sales_manager` are in this list too, and they DO
216+
// warn — above, on their own name. What is pinned here is that binding a
217+
// position to some other set never warns ABOUT THAT BINDING: the reported
218+
// pair is always (position N, set N).
219+
expect(events.map((e: any) => e.position)).not.toContain(boundSet);
220+
if (!(CENSUS_NAME_FOLDS as readonly string[]).includes(position)) expect(events).toEqual([]);
221+
},
222+
);
223+
224+
it('stays silent once 要点 2 materialises the pair (position N, set N)', async () => {
225+
// The exact row the ruling's 要点 2 would create. This is the forward pin:
226+
// when materialisation lands, the warning must retire itself for the pairs
227+
// it covers rather than needing a second edit.
228+
const { events, sets } = await resolveWith({
229+
userId: 'u1',
230+
positions: ['sales_rep'],
231+
permissions: ['sales_rep', 'crm_sales_user'],
232+
});
233+
expect(events).toEqual([]);
234+
expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', 'sales_rep'].sort());
235+
});
236+
237+
it('stays silent when the same-name set IS the baseline (in force with or without the fold)', async () => {
238+
const { events } = await resolveWith({ userId: 'u1', positions: ['member_default'] });
239+
expect(events).toEqual([]);
240+
});
241+
242+
it('stays silent for a context with no positions at all', async () => {
243+
const { events } = await resolveWith({ userId: 'u1', permissions: ['crm_sales_user'] });
244+
expect(events).toEqual([]);
245+
});
246+
});

0 commit comments

Comments
 (0)