|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [commit 712e185db] What provenance a REAL `bootstrapPlatformAdmin` run leaves on the |
| 5 | + * platform default permission sets, and what `os meta resync` then does with it. |
| 6 | + * |
| 7 | + * ## Why this file exists at all |
| 8 | + * |
| 9 | + * `bootstrap-platform-admin.test.ts` covers the resync branch three ways — |
| 10 | + * `managed_by` hand-seeded to `null`, to `'user'`, to `'package'` — and every |
| 11 | + * one of them hand-builds the pre-image. That is correct unit coverage of the |
| 12 | + * BRANCH, and it is deliberately left untouched here. What none of them can |
| 13 | + * answer is which pre-image the seeder itself produces, because a fake `ql` |
| 14 | + * stores exactly the columns it is handed and a declared field `defaultValue` |
| 15 | + * never runs. So the one row shape the platform actually creates was the one |
| 16 | + * row shape nothing exercised, and the drift was invisible in both directions. |
| 17 | + * |
| 18 | + * This file closes that by seeding through the REAL shipped |
| 19 | + * `defaultPermissionSets` against a real `ObjectQL` engine over a real |
| 20 | + * better-sqlite3 `SqlDriver` — the same wiring `security-plugin.ts` and |
| 21 | + * `os meta resync` use, which hand the bare engine straight to the seeder. |
| 22 | + * |
| 23 | + * ## The ruling these assertions now pin (2026-08-15, Option A) |
| 24 | + * |
| 25 | + * The original version of this file recorded a MEASUREMENT and explicitly |
| 26 | + * refused to endorse it: a seeded row stored `'admin'` (the declaration's |
| 27 | + * `defaultValue`), so `os meta resync` returned `resynced 0 / resyncSkipped 8` |
| 28 | + * and skipped every platform default set — the inverse of what #2705 built the |
| 29 | + * flag for. It left the product question open: |
| 30 | + * |
| 31 | + * > are the platform default permission sets meant to be platform-owned |
| 32 | + * > (and therefore resyncable), or env-authored (and therefore deliberately |
| 33 | + * > left alone)? |
| 34 | + * |
| 35 | + * ⛔ That question is CLOSED. The maintainer ruled **Option A** on 2026-08-15: |
| 36 | + * the platform default sets are platform-owned and resyncable, and the seeder |
| 37 | + * stamps `managed_by: 'platform'` on its insert. This file no longer records an |
| 38 | + * undecided state — it pins the ruling, on BOTH sides of the line the ruling |
| 39 | + * drew: |
| 40 | + * |
| 41 | + * 1. **Forward (section 1)** — a fresh install stores `'platform'` and a real |
| 42 | + * resync reconciles every default set. |
| 43 | + * 2. **Legacy (section 2)** — a row from a PRE-ruling install still carries |
| 44 | + * `'admin'` and is still SKIPPED, forever and on purpose. The ruling |
| 45 | + * forbids a migration: a stored `'admin'` cannot be told apart from a |
| 46 | + * genuine Setup takeover, so restamping could silently overwrite a real |
| 47 | + * admin's edits on the next resync. Report, don't rewrite. |
| 48 | + * |
| 49 | + * Section 2 is the half that is easy to lose. Deleting it would leave the |
| 50 | + * upgrade path — the one every existing deployment is actually on — with no |
| 51 | + * coverage at all, and a later "cleanup" that restamps legacy rows would then |
| 52 | + * go green. |
| 53 | + */ |
| 54 | + |
| 55 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 56 | +import { ObjectQL } from '@objectstack/objectql'; |
| 57 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 58 | +// `sys_user` is what the promotion half enumerates. Registered as the REAL |
| 59 | +// declaration so its verdict ("no human users yet") is a genuine reading of an |
| 60 | +// empty table rather than a table-missing error `tryFind` swallowed into `[]` — |
| 61 | +// the two are indistinguishable from the outside and only one of them is the |
| 62 | +// first-boot ordering this file means to reproduce. |
| 63 | +import { SysUser } from '@objectstack/platform-objects/identity'; |
| 64 | +// The REAL shipped seeder and the REAL shipped declarations. A local |
| 65 | +// re-implementation of either would make this a test of the copy, which is |
| 66 | +// precisely the gap the file exists to close. |
| 67 | +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; |
| 68 | +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; |
| 69 | +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; |
| 70 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 71 | + |
| 72 | +const SYSTEM_CTX = { isSystem: true }; |
| 73 | + |
| 74 | +const engines: ObjectQL[] = []; |
| 75 | + |
| 76 | +afterEach(async () => { |
| 77 | + while (engines.length) { |
| 78 | + try { |
| 79 | + await engines.pop()?.destroy(); |
| 80 | + } catch { |
| 81 | + /* noop */ |
| 82 | + } |
| 83 | + } |
| 84 | +}); |
| 85 | + |
| 86 | +/** |
| 87 | + * A fresh engine on its own `:memory:` database, carrying the permission-set |
| 88 | + * declaration passed in. `declaration` exists for section 3's counterfactual; |
| 89 | + * every case in sections 1 and 2 boots the shipped declaration unmodified. |
| 90 | + */ |
| 91 | +async function boot(declaration: any = SysPermissionSet): Promise<ObjectQL> { |
| 92 | + const engine = new ObjectQL(); |
| 93 | + engine.registerDriver( |
| 94 | + new SqlDriver({ |
| 95 | + client: 'better-sqlite3', |
| 96 | + connection: { filename: ':memory:' }, |
| 97 | + useNullAsDefault: true, |
| 98 | + }), |
| 99 | + true, |
| 100 | + ); |
| 101 | + await engine.init(); |
| 102 | + engine.registerApp({ |
| 103 | + id: 'com.objectstack.security-objects', |
| 104 | + name: 'Security Objects', |
| 105 | + version: '1.0.0', |
| 106 | + type: 'plugin', |
| 107 | + scope: 'system', |
| 108 | + objects: [declaration, SysUserPermissionSet, SysUser], |
| 109 | + } as any); |
| 110 | + await engine.syncSchemas(); |
| 111 | + engines.push(engine); |
| 112 | + return engine; |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * The row as it is physically STORED, read at the driver past every engine-side |
| 117 | + * projection. The resync branch reads the engine's view, so both are asserted |
| 118 | + * where it matters — a default applied only on read and a value written on |
| 119 | + * insert are different facts about the database, and only one of them survives |
| 120 | + * a restart. |
| 121 | + */ |
| 122 | +async function storedRow(engine: ObjectQL, name: string): Promise<any> { |
| 123 | + const driver: any = (engine as any).getDriver('sys_permission_set'); |
| 124 | + const rows: any[] = await driver.knex('sys_permission_set').select('*').where({ name }); |
| 125 | + return rows[0]; |
| 126 | +} |
| 127 | + |
| 128 | +/** The row as the seeder's own `tryFind` sees it — the value resync branches on. */ |
| 129 | +async function rowViaEngine(engine: ObjectQL, name: string): Promise<any> { |
| 130 | + const rows: any[] = await (engine as any).find( |
| 131 | + 'sys_permission_set', |
| 132 | + { where: { name }, limit: 1 }, |
| 133 | + { context: SYSTEM_CTX }, |
| 134 | + ); |
| 135 | + return rows[0]; |
| 136 | +} |
| 137 | + |
| 138 | +/** |
| 139 | + * A row exactly as an install before commit 712e185db holds it — written the way the old |
| 140 | + * seeder wrote it, which is to say WITHOUT `managed_by`, so the value comes |
| 141 | + * from the declaration's `defaultValue: 'admin'` by the very mechanism that |
| 142 | + * produced it on every install created before the ruling. |
| 143 | + * |
| 144 | + * Reproducing the legacy pre-image through its original mechanism rather than |
| 145 | + * hand-writing `'admin'` is deliberate: it keeps the case anchored to the real |
| 146 | + * declaration, and each case asserts the resulting value before relying on it, |
| 147 | + * so a moved default shows up as a failure here instead of quietly turning the |
| 148 | + * legacy pin into a test of something else. |
| 149 | + */ |
| 150 | +async function seedLegacyRow(engine: ObjectQL, ps: any): Promise<void> { |
| 151 | + await (engine as any).insert( |
| 152 | + 'sys_permission_set', |
| 153 | + { |
| 154 | + id: `ps_legacy_${ps.name}`, |
| 155 | + name: ps.name, |
| 156 | + label: ps.label ?? ps.name, |
| 157 | + // A deliberately STALE payload: the resync would rewrite these if it ever |
| 158 | + // touched the row, so section 2 can prove "left untouched" by content and |
| 159 | + // not merely by a counter. |
| 160 | + object_permissions: '{}', |
| 161 | + field_permissions: '{}', |
| 162 | + system_permissions: '[]', |
| 163 | + row_level_security: '[]', |
| 164 | + tab_permissions: '{}', |
| 165 | + active: true, |
| 166 | + }, |
| 167 | + { context: SYSTEM_CTX }, |
| 168 | + ); |
| 169 | +} |
| 170 | + |
| 171 | +function collectingLogger() { |
| 172 | + const info: string[] = []; |
| 173 | + const warn: string[] = []; |
| 174 | + return { |
| 175 | + info, |
| 176 | + warn, |
| 177 | + logger: { |
| 178 | + info: (m: string) => info.push(m), |
| 179 | + warn: (m: string) => warn.push(m), |
| 180 | + }, |
| 181 | + }; |
| 182 | +} |
| 183 | + |
| 184 | +describe('#8692 — provenance of a seeder-created default permission set (measured, real engine)', () => { |
| 185 | + // ─────────────────────────────────────────────────────────────────────────── |
| 186 | + // 1. FORWARD — a fresh install after the ruling |
| 187 | + // ─────────────────────────────────────────────────────────────────────────── |
| 188 | + |
| 189 | + it('ANTI-VACUITY: the shipped defaults are non-empty and really get seeded', async () => { |
| 190 | + // Every count below is relative to this set. If it were empty, "resync |
| 191 | + // reconciled all of them" would be trivially true of nothing — the shape in |
| 192 | + // which this whole file could pass while measuring air. |
| 193 | + expect(defaultPermissionSets.length).toBeGreaterThan(0); |
| 194 | + |
| 195 | + const engine = await boot(); |
| 196 | + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 197 | + expect(out.seeded).toBe(defaultPermissionSets.length); |
| 198 | + }); |
| 199 | + |
| 200 | + it('a seeder-created row is stored `managed_by: "platform"` — stamped, not defaulted', async () => { |
| 201 | + // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored |
| 202 | + // `'admin'` — the declaration's `defaultValue` — because the insert omitted |
| 203 | + // `managed_by` entirely. The seeder now stamps provenance explicitly, in |
| 204 | + // line with `bootstrap-builtin-positions.ts`, which always has. |
| 205 | + const engine = await boot(); |
| 206 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 207 | + |
| 208 | + // Physically stored, not merely projected on read: the value survives a |
| 209 | + // restart, so every later boot reads it too. |
| 210 | + expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); |
| 211 | + // …and the same value is what the seeder's own read sees, which is the one |
| 212 | + // the resync condition branches on. |
| 213 | + expect((await rowViaEngine(engine, 'admin_full_access')).managed_by).toBe('platform'); |
| 214 | + }); |
| 215 | + |
| 216 | + it('so `os meta resync` now reconciles EVERY platform default set', async () => { |
| 217 | + // The behaviour #2705 built the flag for, finally reachable: resynced == the |
| 218 | + // whole shipped set, resyncSkipped 0. Asserted against the live length so |
| 219 | + // the pin stays true if a default set is added or removed. |
| 220 | + const engine = await boot(); |
| 221 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 222 | + |
| 223 | + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); |
| 224 | + |
| 225 | + expect(out.resynced).toBe(defaultPermissionSets.length); |
| 226 | + expect(out.resyncSkipped).toBe(0); |
| 227 | + }); |
| 228 | + |
| 229 | + it('a resync of a fresh install warns about nothing', async () => { |
| 230 | + // The counterpart to the old measurement, which emitted one bogus |
| 231 | + // "intentional override" warn per shipped set. Nothing is skipped now, so |
| 232 | + // nothing is warned about. |
| 233 | + const engine = await boot(); |
| 234 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 235 | + |
| 236 | + const { warn, logger } = collectingLogger(); |
| 237 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); |
| 238 | + |
| 239 | + expect(warn).toEqual([]); |
| 240 | + }); |
| 241 | + |
| 242 | + it('resync reconciles a STALE platform-owned row to the shipped declaration', async () => { |
| 243 | + // Counts alone cannot show the reconcile actually landed. Blank the payload |
| 244 | + // on a seeded row, resync, and read the declaration back out of storage. |
| 245 | + const engine = await boot(); |
| 246 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 247 | + |
| 248 | + const driver: any = (engine as any).getDriver('sys_permission_set'); |
| 249 | + await driver.knex('sys_permission_set') |
| 250 | + .where({ name: 'admin_full_access' }) |
| 251 | + .update({ system_permissions: '[]' }); |
| 252 | + expect((await storedRow(engine, 'admin_full_access')).system_permissions).toBe('[]'); |
| 253 | + |
| 254 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); |
| 255 | + |
| 256 | + const shipped = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!; |
| 257 | + expect(JSON.parse((await storedRow(engine, 'admin_full_access')).system_permissions)) |
| 258 | + .toEqual(shipped.systemPermissions ?? []); |
| 259 | + // …and the reconcile did NOT touch provenance: `platformOwnedFields` carries |
| 260 | + // no `managed_by`, which is what keeps a resync from restamping any row. |
| 261 | + expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); |
| 262 | + }); |
| 263 | + |
| 264 | + // ─────────────────────────────────────────────────────────────────────────── |
| 265 | + // 2. LEGACY — a pre-ruling install, which keeps the skip forever |
| 266 | + // ─────────────────────────────────────────────────────────────────────────── |
| 267 | + |
| 268 | + it('LEGACY: a pre-ruling `admin`-stamped row is still skipped, and its content survives', async () => { |
| 269 | + // ⛔ The ruling's load-bearing half: forward-stamp only, NO migration. A |
| 270 | + // stored `'admin'` is indistinguishable between "the old seeder's field |
| 271 | + // default" and "an administrator took this set over in Setup", so it must |
| 272 | + // never be reconciled on the strength of a guess. |
| 273 | + const engine = await boot(); |
| 274 | + const legacy = defaultPermissionSets[0]; |
| 275 | + await seedLegacyRow(engine, legacy); |
| 276 | + |
| 277 | + // The pre-image really is what a pre-ruling install holds — asserted, not |
| 278 | + // assumed, so this case cannot pass by testing some other row shape. |
| 279 | + expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); |
| 280 | + |
| 281 | + // An upgraded install: the seeder runs, finds the legacy row and leaves it, |
| 282 | + // and inserts the remaining sets freshly stamped `'platform'`. |
| 283 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 284 | + expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); |
| 285 | + |
| 286 | + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); |
| 287 | + |
| 288 | + // The mixed shape an upgrade produces: everything the platform stamped is |
| 289 | + // reconciled, the one legacy row is not. |
| 290 | + expect(out.resyncSkipped).toBe(1); |
| 291 | + expect(out.resynced).toBe(defaultPermissionSets.length - 1); |
| 292 | + |
| 293 | + // "Left untouched" proven by CONTENT, not just by a counter: the stale |
| 294 | + // payload seeded above is still stale, and provenance was not restamped. |
| 295 | + const row = await storedRow(engine, legacy.name); |
| 296 | + expect(row.system_permissions).toBe('[]'); |
| 297 | + expect(row.object_permissions).toBe('{}'); |
| 298 | + expect(row.managed_by).toBe('admin'); |
| 299 | + }); |
| 300 | + |
| 301 | + it('LEGACY: the skip is logged neutrally — no claim of intent', async () => { |
| 302 | + // The warn used to end "(intentional override)", which is false for exactly |
| 303 | + // these rows: on a pre-ruling install the only writer may have been the |
| 304 | + // seeder itself one call earlier. The reworded line states the provenance |
| 305 | + // and the action and asserts nothing about anybody's intent. |
| 306 | + const engine = await boot(); |
| 307 | + const legacy = defaultPermissionSets[0]; |
| 308 | + await seedLegacyRow(engine, legacy); |
| 309 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 310 | + |
| 311 | + const { warn, logger } = collectingLogger(); |
| 312 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); |
| 313 | + |
| 314 | + expect(warn).toEqual([ |
| 315 | + `[security] resync left ${legacy.name} untouched — row is admin-owned`, |
| 316 | + ]); |
| 317 | + // Pinned as a substring too, so a future reword cannot quietly reintroduce |
| 318 | + // the intent claim in some other sentence shape. |
| 319 | + expect(warn.join('\n')).not.toContain('intentional override'); |
| 320 | + }); |
| 321 | + |
| 322 | + // ─────────────────────────────────────────────────────────────────────────── |
| 323 | + // 3. ATTRIBUTION — which link actually supplies the value |
| 324 | + // ─────────────────────────────────────────────────────────────────────────── |
| 325 | + |
| 326 | + /** |
| 327 | + * A counterfactual, and note it runs the OPPOSITE way round from the one this |
| 328 | + * file carried before the ruling. Back then the seeder wrote no `managed_by`, |
| 329 | + * so respelling the declared default flipped the stored value — which is what |
| 330 | + * proved the default was the source. Now the seeder stamps explicitly, so the |
| 331 | + * declared default must no longer be able to move the value at all. |
| 332 | + * |
| 333 | + * Predicted direction, written down before running: boot a CLONE of the |
| 334 | + * declaration whose `managed_by` default is respelled to `'package'`, seed |
| 335 | + * with the same unmodified seeder, and the row must STILL store `'platform'`. |
| 336 | + * |
| 337 | + * That one reading establishes what section 1 cannot on its own: the value |
| 338 | + * comes from the seeder's stamp and not from a declaration that merely |
| 339 | + * happens to agree with it. Without this case, respelling |
| 340 | + * `defaultValue: 'admin'` to `'platform'` in the object file would make every |
| 341 | + * assertion in section 1 pass with the stamp deleted. |
| 342 | + */ |
| 343 | + it('ATTRIBUTION: the seed stamp beats the declared default, so the default cannot move it', async () => { |
| 344 | + const respelled = { |
| 345 | + ...SysPermissionSet, |
| 346 | + fields: { |
| 347 | + ...(SysPermissionSet as any).fields, |
| 348 | + managed_by: { ...(SysPermissionSet as any).fields.managed_by, defaultValue: 'package' }, |
| 349 | + }, |
| 350 | + }; |
| 351 | + // Guard: the clone is a copy, and the shipped declaration is untouched by |
| 352 | + // building it. Without this the case could be measuring a mutation it made. |
| 353 | + // The shipped default stays `'admin'` deliberately — it is the right default |
| 354 | + // for a set an admin creates in Setup, and the ruling changed the SEEDER, |
| 355 | + // not the declaration. |
| 356 | + expect((SysPermissionSet as any).fields.managed_by.defaultValue).toBe('admin'); |
| 357 | + |
| 358 | + const engine = await boot(respelled); |
| 359 | + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); |
| 360 | + |
| 361 | + expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); |
| 362 | + |
| 363 | + // And the consequence still holds under a default that would otherwise have |
| 364 | + // caused a skip: resync reconciles everything. |
| 365 | + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); |
| 366 | + expect(out.resynced).toBe(defaultPermissionSets.length); |
| 367 | + expect(out.resyncSkipped).toBe(0); |
| 368 | + }); |
| 369 | +}); |
0 commit comments