|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | | -import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; |
| 4 | +import { |
| 5 | + bootstrapDeclaredPermissions, |
| 6 | + upsertPackagePermissionSet, |
| 7 | + upsertEnvPermissionSet, |
| 8 | + projectEnvPermissionOnMutation, |
| 9 | + subscribeEnvPermissionProjection, |
| 10 | +} from './bootstrap-declared-permissions.js'; |
5 | 11 |
|
6 | 12 | /** Minimal in-memory ql + registry for sys_permission_set seeding. */ |
7 | 13 | function makeQl(declared: any[] = []) { |
@@ -168,3 +174,134 @@ describe('upsertPackagePermissionSet (ADR-0086 P2 — publish materialization)', |
168 | 174 | expect(warns.some((w) => w.includes('no owning package'))).toBe(true); |
169 | 175 | }); |
170 | 176 | }); |
| 177 | + |
| 178 | +// framework#2857 — the environment door. An env-scope `save('permission', …)` |
| 179 | +// writes only the sys_metadata overlay; upsertEnvPermissionSet projects the |
| 180 | +// saved facets onto the queryable sys_permission_set record so the admin/Setup |
| 181 | +// surface stops going stale. Mirror of upsertPackagePermissionSet (env rows |
| 182 | +// only; refuses package-owned records). |
| 183 | +describe('upsertEnvPermissionSet (framework#2857 — env-door projection)', () => { |
| 184 | + const envBody = (over: Record<string, any> = {}) => ({ |
| 185 | + name: 'organization_admin', |
| 186 | + label: 'Organization Administrator', |
| 187 | + objects: { crm_lead: { allowRead: true, allowEdit: true } }, |
| 188 | + fields: { 'crm_lead.amount': { readable: true, editable: false } }, |
| 189 | + systemPermissions: ['setup.access', 'manage_org_users'], |
| 190 | + rowLevelSecurity: [{ name: 'tenant', object: '*', operation: 'all', using: 'org == current_user.org', enabled: true }], |
| 191 | + tabPermissions: { crm_leads: 'visible' }, |
| 192 | + adminScope: { businessUnit: 'Sales', includeSubtree: true, assignablePermissionSets: ['member_default'] }, |
| 193 | + ...over, |
| 194 | + }); |
| 195 | + |
| 196 | + it('projects all six facets onto an existing env-authored row (update)', async () => { |
| 197 | + const ql = makeQl(); |
| 198 | + ql.rows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]' }); |
| 199 | + const r = await upsertEnvPermissionSet(ql, envBody()); |
| 200 | + expect(r.updated).toBe(1); |
| 201 | + const row = ql.rows[0]; |
| 202 | + expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); |
| 203 | + expect(JSON.parse(row.field_permissions)).toEqual({ 'crm_lead.amount': { readable: true, editable: false } }); |
| 204 | + expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']); |
| 205 | + expect(JSON.parse(row.row_level_security)[0].using).toBe('org == current_user.org'); |
| 206 | + expect(JSON.parse(row.tab_permissions)).toEqual({ crm_leads: 'visible' }); |
| 207 | + expect(JSON.parse(row.admin_scope).businessUnit).toBe('Sales'); |
| 208 | + }); |
| 209 | + |
| 210 | + it('projects onto a legacy row with ABSENT provenance (platform default)', async () => { |
| 211 | + const ql = makeQl(); |
| 212 | + ql.rows.push({ id: 'ps_legacy', name: 'organization_admin', system_permissions: '[]' }); |
| 213 | + const r = await upsertEnvPermissionSet(ql, envBody()); |
| 214 | + expect(r.updated).toBe(1); |
| 215 | + expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_org_users']); |
| 216 | + }); |
| 217 | + |
| 218 | + it('does nothing when no data record exists (creation goes through the data API)', async () => { |
| 219 | + const ql = makeQl(); |
| 220 | + const r = await upsertEnvPermissionSet(ql, envBody()); |
| 221 | + expect(r.seeded + r.updated).toBe(0); |
| 222 | + expect(ql.rows.length).toBe(0); |
| 223 | + }); |
| 224 | + |
| 225 | + it('refuses to touch a package-owned row (record stays the package baseline)', async () => { |
| 226 | + const ql = makeQl(); |
| 227 | + ql.rows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg"]' }); |
| 228 | + const warns: string[] = []; |
| 229 | + const r = await upsertEnvPermissionSet(ql, envBody(), { info: () => {}, warn: (m) => warns.push(m) }); |
| 230 | + expect(r.skippedForeign).toBe(1); |
| 231 | + expect(r.seeded + r.updated).toBe(0); |
| 232 | + expect(ql.rows[0].system_permissions).toBe('["pkg"]'); |
| 233 | + expect(warns.some((w) => w.includes('package-owned'))).toBe(true); |
| 234 | + }); |
| 235 | + |
| 236 | + it('projects an env record even when the layered body carries _packageId provenance (record decides, not the body)', async () => { |
| 237 | + // Regression for framework#2857: the layered read stamps `_packageId` on |
| 238 | + // env-authored sets too, so the body must NOT gate projection — the record's |
| 239 | + // managed_by does. |
| 240 | + const ql = makeQl(); |
| 241 | + ql.rows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]' }); |
| 242 | + const r = await upsertEnvPermissionSet(ql, envBody({ _packageId: 'com.example.app', _provenance: 'x' })); |
| 243 | + expect(r.updated).toBe(1); |
| 244 | + expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_org_users']); |
| 245 | + }); |
| 246 | +}); |
| 247 | + |
| 248 | +// framework#2857 — the mutation-driven wiring (onMetadataMutation → layered |
| 249 | +// re-read → project). Exercised without the dev server via a mock protocol. |
| 250 | +describe('projectEnvPermissionOnMutation / subscribeEnvPermissionProjection', () => { |
| 251 | + const body = () => ({ |
| 252 | + name: 'organization_admin', |
| 253 | + // the layered read stamps provenance on env sets too — must not gate. |
| 254 | + _packageId: 'com.objectstack.showcase', |
| 255 | + _provenance: { source: 'env' }, |
| 256 | + objects: { crm_lead: { allowRead: true } }, |
| 257 | + systemPermissions: ['setup.access', 'manage_metadata'], |
| 258 | + }); |
| 259 | + const evt = (over = {}) => ({ type: 'permission', state: 'active', name: 'organization_admin', ...over }); |
| 260 | + const envRow = () => ({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]' }); |
| 261 | + |
| 262 | + it('re-reads the fresh body via getMetaItemLayered (ENVELOPE shape) and projects it', async () => { |
| 263 | + const ql = makeQl(); ql.rows.push(envRow()); |
| 264 | + const protocol = { getMetaItemLayered: async () => ({ effective: body(), code: null }) }; |
| 265 | + const r = await projectEnvPermissionOnMutation(protocol, ql, evt()); |
| 266 | + expect(r?.updated).toBe(1); |
| 267 | + expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_metadata']); |
| 268 | + }); |
| 269 | + |
| 270 | + it('accepts a DIRECT body from getMetaItemLayered (no effective/code envelope)', async () => { |
| 271 | + // Regression: the layered read can return the effective body directly; a |
| 272 | + // `?? null` fallback would drop it and silently skip the projection. |
| 273 | + const ql = makeQl(); ql.rows.push(envRow()); |
| 274 | + const protocol = { getMetaItemLayered: async () => body() }; |
| 275 | + const r = await projectEnvPermissionOnMutation(protocol, ql, evt()); |
| 276 | + expect(r?.updated).toBe(1); |
| 277 | + expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_metadata']); |
| 278 | + }); |
| 279 | + |
| 280 | + it('skips draft saves and non-permission events', async () => { |
| 281 | + const ql = makeQl(); ql.rows.push(envRow()); |
| 282 | + const protocol = { getMetaItemLayered: async () => body() }; |
| 283 | + expect(await projectEnvPermissionOnMutation(protocol, ql, evt({ state: 'draft' }))).toBeNull(); |
| 284 | + expect(await projectEnvPermissionOnMutation(protocol, ql, evt({ type: 'object' }))).toBeNull(); |
| 285 | + expect(ql.rows[0].system_permissions).toBe('[]'); |
| 286 | + }); |
| 287 | + |
| 288 | + it('subscribeEnvPermissionProjection wires a listener that projects on an active permission save', async () => { |
| 289 | + const ql = makeQl(); ql.rows.push(envRow()); |
| 290 | + let listener: any = null; |
| 291 | + const protocol = { |
| 292 | + onMetadataMutation: (fn: any) => { listener = fn; return () => {}; }, |
| 293 | + getMetaItemLayered: async () => body(), |
| 294 | + }; |
| 295 | + const unsub = subscribeEnvPermissionProjection(protocol, ql); |
| 296 | + expect(typeof unsub).toBe('function'); |
| 297 | + expect(typeof listener).toBe('function'); |
| 298 | + listener(evt()); |
| 299 | + await new Promise((r) => setTimeout(r, 0)); // let the fire-and-forget settle |
| 300 | + expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_metadata']); |
| 301 | + }); |
| 302 | + |
| 303 | + it('returns null (no wiring) when the protocol lacks onMetadataMutation', () => { |
| 304 | + expect(subscribeEnvPermissionProjection({}, makeQl())).toBeNull(); |
| 305 | + expect(subscribeEnvPermissionProjection(null, makeQl())).toBeNull(); |
| 306 | + }); |
| 307 | +}); |
0 commit comments