|
| 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 | + |
| 44 | +/** The stable event token. Asserted as a LITERAL, never imported: an imported |
| 45 | + * constant renames itself along with the source and the pin never notices. */ |
| 46 | +const EVENT = 'position_name_fold_grant'; |
| 47 | + |
| 48 | +/** |
| 49 | + * The census's `NAME-FOLD DEPENDENCIES` block — grants in force with no |
| 50 | + * junction row. Position half declared by `examples/app-crm/src/security/ |
| 51 | + * sales-positions.ts`; permission-set half by the vendored HotCRM artifact. |
| 52 | + */ |
| 53 | +const CENSUS_NAME_FOLDS = ['sales_rep', 'sales_manager'] as const; |
| 54 | + |
| 55 | +/** |
| 56 | + * The census's `JUNCTION BINDINGS` block — 13 rows, the governed channel. Every |
| 57 | + * one binds a position to a DIFFERENTLY named set, which is why none of them is |
| 58 | + * a fold: the fold is about a position's own name. |
| 59 | + */ |
| 60 | +const CENSUS_JUNCTION_BINDINGS: ReadonlyArray<readonly [position: string, set: string]> = [ |
| 61 | + ['sales_rep', 'crm_sales_user'], |
| 62 | + ['sales_manager', 'crm_sales_user'], |
| 63 | + ['finance_approver', 'crm_sales_user'], |
| 64 | + ['contributor', 'showcase_contributor'], |
| 65 | + ['manager', 'showcase_manager'], |
| 66 | + ['exec', 'showcase_executive'], |
| 67 | + ['auditor', 'showcase_auditor'], |
| 68 | + ['ops', 'showcase_ops'], |
| 69 | + ['field_ops_delegate', 'showcase_field_ops_delegate'], |
| 70 | + ['client_liaison', 'showcase_client_liaison'], |
| 71 | + ['client_portal_user', 'showcase_guest_portal'], |
| 72 | + ['everyone', 'member_default'], |
| 73 | + ['everyone', 'showcase_member_default'], |
| 74 | +]; |
| 75 | + |
| 76 | +/** |
| 77 | + * The census's `INERT POSITIONS` block, printed under a heading that states the |
| 78 | + * obligation in terms: "要点 3's collision warning must NOT fire on these." |
| 79 | + */ |
| 80 | +const CENSUS_INERT_POSITIONS = [ |
| 81 | + 'platform_admin', |
| 82 | + 'org_owner', |
| 83 | + 'org_admin', |
| 84 | + 'org_member', |
| 85 | + 'guest', |
| 86 | + 'finance', |
| 87 | + 'legal', |
| 88 | +] as const; |
| 89 | + |
| 90 | +function set(name: string): PermissionSet { |
| 91 | + return { name, label: name, objects: {}, fields: {}, systemPermissions: [], tabPermissions: {} } as any; |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * The permission-set universe these cases resolve against: every junction |
| 96 | + * TARGET, the two same-name sets the HotCRM artifact contributes, the platform |
| 97 | + * baseline, and `organization_admin` — the near-miss that must not be credited |
| 98 | + * to the `org_admin` position. |
| 99 | + * |
| 100 | + * ⛔ No set is named after any inert or non-folding position, which is the |
| 101 | + * repository's own state and the reason those positions are inert. The |
| 102 | + * MUST-FIRE cases below are what stop that absence from making the MUST-NOT |
| 103 | + * cases pass trivially: the same universe, the same predicate, two verdicts. |
| 104 | + */ |
| 105 | +const UNIVERSE: PermissionSet[] = [ |
| 106 | + ...new Set([...CENSUS_JUNCTION_BINDINGS.map(([, s]) => s), ...CENSUS_NAME_FOLDS, 'member_default', 'organization_admin']), |
| 107 | +].map(set); |
| 108 | + |
| 109 | +function boot(universe: PermissionSet[] = UNIVERSE) { |
| 110 | + const ql: any = { |
| 111 | + registerMiddleware: () => {}, |
| 112 | + getSchema: () => null, |
| 113 | + find: async () => [], |
| 114 | + }; |
| 115 | + const metadata: any = { get: async () => null, list: async () => universe }; |
| 116 | + const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata }; |
| 117 | + const warn = vi.fn(); |
| 118 | + const ctx: any = { |
| 119 | + logger: { info: vi.fn(), warn, error: vi.fn() }, |
| 120 | + registerService: vi.fn(), |
| 121 | + getService: (name: string) => { |
| 122 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 123 | + return services[name]; |
| 124 | + }, |
| 125 | + }; |
| 126 | + return { plugin: new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any), ctx, warn }; |
| 127 | +} |
| 128 | + |
| 129 | +/** Resolve through the registered service handle, as every real consumer does. */ |
| 130 | +async function resolveWith(context: Record<string, unknown>, universe: PermissionSet[] = UNIVERSE) { |
| 131 | + const { plugin, ctx, warn } = boot(universe); |
| 132 | + await plugin.init(ctx); |
| 133 | + await plugin.start(ctx); |
| 134 | + const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>; |
| 135 | + const sets = await svc.resolvePermissionSetsForContext?.(context as any); |
| 136 | + const events = warn.mock.calls |
| 137 | + .filter((c) => typeof c[0] === 'string' && c[0].includes(EVENT)) |
| 138 | + .map((c) => c[1]); |
| 139 | + return { sets: (sets ?? []).map((s) => s.name), events, warn, svc }; |
| 140 | +} |
| 141 | + |
| 142 | +describe('[#13419] MUST FIRE — a position folded onto its own same-name set with no junction row', () => { |
| 143 | + for (const position of CENSUS_NAME_FOLDS) { |
| 144 | + it(`warns for '${position}', the census's own cross_scope fold`, async () => { |
| 145 | + // The measured shape: the position IS junction-bound — to `crm_sales_user`, |
| 146 | + // not to itself — so `permissions` carries that other set. The grant on |
| 147 | + // the same-name set has no junction row behind it at all. |
| 148 | + const { sets, events } = await resolveWith({ |
| 149 | + userId: 'u1', |
| 150 | + positions: [position], |
| 151 | + permissions: ['crm_sales_user'], |
| 152 | + }); |
| 153 | + |
| 154 | + // Reported once, naming both halves of the pair. |
| 155 | + expect(events).toHaveLength(1); |
| 156 | + expect(events[0]).toMatchObject({ event: EVENT, position, permissionSet: position }); |
| 157 | + |
| 158 | + // ⛔ Purely additive: the fold still grants exactly what it granted before. |
| 159 | + // A warning that also changed the answer would be the silent behaviour |
| 160 | + // change 要点 5 forbids. |
| 161 | + expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', position].sort()); |
| 162 | + }); |
| 163 | + } |
| 164 | + |
| 165 | + it('names the ungoverned grant and the two ways out, not just the collision', async () => { |
| 166 | + const { warn } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] }); |
| 167 | + const message = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes(EVENT))!; |
| 168 | + expect(message).toContain('sys_position_permission_set'); |
| 169 | + expect(message).toContain('ungoverned'); |
| 170 | + expect(message).toMatch(/rename/i); |
| 171 | + }); |
| 172 | + |
| 173 | + it('is LOUD ONCE per position, not once per request', async () => { |
| 174 | + const { plugin, ctx, warn } = boot(); |
| 175 | + await plugin.init(ctx); |
| 176 | + await plugin.start(ctx); |
| 177 | + const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>; |
| 178 | + // Distinct context OBJECTS, so the per-context memo cannot be what silences |
| 179 | + // the second call — the deduplication under test has to be the one in the |
| 180 | + // reporter. |
| 181 | + for (let i = 0; i < 3; i++) { |
| 182 | + await svc.resolvePermissionSetsForContext?.({ userId: `u${i}`, positions: ['sales_rep'] } as any); |
| 183 | + } |
| 184 | + expect(warn.mock.calls.filter((c) => String(c[0]).includes(EVENT))).toHaveLength(1); |
| 185 | + }); |
| 186 | +}); |
| 187 | + |
| 188 | +describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects', () => { |
| 189 | + it.each(CENSUS_INERT_POSITIONS.map((p) => [p]))( |
| 190 | + 'stays silent for the inert position %s (a built-in-identity false positive is the most expensive failure here)', |
| 191 | + async (position) => { |
| 192 | + const { events, sets } = await resolveWith({ userId: 'u1', positions: [position] }); |
| 193 | + expect(events).toEqual([]); |
| 194 | + // Silent for the right reason: nothing resolved off the position name, so |
| 195 | + // the caller fell back to the baseline. A pass produced by a broken |
| 196 | + // resolution would show up here as an empty set list. |
| 197 | + expect(sets).toEqual(['member_default']); |
| 198 | + }, |
| 199 | + ); |
| 200 | + |
| 201 | + it('stays silent for org_admin even though the set organization_admin exists (near-miss, not a collision)', async () => { |
| 202 | + const { events } = await resolveWith({ userId: 'u1', positions: ['org_admin'] }); |
| 203 | + expect(events).toEqual([]); |
| 204 | + }); |
| 205 | + |
| 206 | + it.each(CENSUS_JUNCTION_BINDINGS.map(([p, s]) => [p, s]))( |
| 207 | + 'stays silent for the junction binding %s -> %s', |
| 208 | + async (position, boundSet) => { |
| 209 | + const { events } = await resolveWith({ userId: 'u1', positions: [position], permissions: [boundSet] }); |
| 210 | + // ⚠️ `sales_rep` and `sales_manager` are in this list too, and they DO |
| 211 | + // warn — above, on their own name. What is pinned here is that binding a |
| 212 | + // position to some other set never warns ABOUT THAT BINDING: the reported |
| 213 | + // pair is always (position N, set N). |
| 214 | + expect(events.map((e: any) => e.position)).not.toContain(boundSet); |
| 215 | + if (!(CENSUS_NAME_FOLDS as readonly string[]).includes(position)) expect(events).toEqual([]); |
| 216 | + }, |
| 217 | + ); |
| 218 | + |
| 219 | + it('stays silent once 要点 2 materialises the pair (position N, set N)', async () => { |
| 220 | + // The exact row the ruling's 要点 2 would create. This is the forward pin: |
| 221 | + // when materialisation lands, the warning must retire itself for the pairs |
| 222 | + // it covers rather than needing a second edit. |
| 223 | + const { events, sets } = await resolveWith({ |
| 224 | + userId: 'u1', |
| 225 | + positions: ['sales_rep'], |
| 226 | + permissions: ['sales_rep', 'crm_sales_user'], |
| 227 | + }); |
| 228 | + expect(events).toEqual([]); |
| 229 | + expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', 'sales_rep'].sort()); |
| 230 | + }); |
| 231 | + |
| 232 | + it('stays silent when the same-name set IS the baseline (in force with or without the fold)', async () => { |
| 233 | + const { events } = await resolveWith({ userId: 'u1', positions: ['member_default'] }); |
| 234 | + expect(events).toEqual([]); |
| 235 | + }); |
| 236 | + |
| 237 | + it('stays silent for a context with no positions at all', async () => { |
| 238 | + const { events } = await resolveWith({ userId: 'u1', permissions: ['crm_sales_user'] }); |
| 239 | + expect(events).toEqual([]); |
| 240 | + }); |
| 241 | +}); |
0 commit comments