|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0131 D3/D4] The security plugin's third permission-set source — the |
| 5 | + * catalog loader (`SecurityPlugin.catalogSetLoader`) — pinned the way the row |
| 6 | + * loader it replaced was pinned (`engine-find-bare-array.pin.test.ts` block 7, |
| 7 | + * #15598), against the post-cutover read. |
| 8 | + * |
| 9 | + * The loader answers the names the metadata list and the bootstrap sets did |
| 10 | + * not: each name's definition in the security catalog the plugin binds to its |
| 11 | + * engine. Three facts must never leave it as one value: |
| 12 | + * |
| 13 | + * - the catalog holds the set → the definition, WHOLE; |
| 14 | + * - the catalog holds no set of that name (or no catalog is bound) → nothing; |
| 15 | + * - the catalog read did NOT answer → a THROW (`AuthzStoreUnavailableError`), |
| 16 | + * never "no such set". `PermissionEvaluator.resolvePermissionSets` catches it |
| 17 | + * one frame up: the unresolved sets grant nothing (fail closed) and the |
| 18 | + * #2565 warn names the failure, once. |
| 19 | + * |
| 20 | + * And it reads no `sys_permission_set` row at all: a set only a row carries |
| 21 | + * resolves nothing, whatever the row says. |
| 22 | + */ |
| 23 | + |
| 24 | +import { describe, it, expect, vi } from 'vitest'; |
| 25 | +import { |
| 26 | + AUTHZ_STORE_UNAVAILABLE_CODE, |
| 27 | + AUTHZ_STORE_UNAVAILABLE_STATUS, |
| 28 | + AuthzStoreUnavailableError, |
| 29 | +} from '@objectstack/core'; |
| 30 | +import type { PermissionSet } from '@objectstack/spec/security'; |
| 31 | +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; |
| 32 | + |
| 33 | +import { SecurityPlugin } from './security-plugin.js'; |
| 34 | +import { PermissionEvaluator } from './permission-evaluator.js'; |
| 35 | + |
| 36 | +/** An environment-authored set, as the engine registry holds it. */ |
| 37 | +const CATALOG_SET: PermissionSet = { |
| 38 | + name: 'ps_catalog', |
| 39 | + label: 'Catalog authored', |
| 40 | + objects: { deal: { allowRead: true, allowEdit: true } }, |
| 41 | + fields: { 'deal.amount': { readable: true, editable: false } }, |
| 42 | + systemPermissions: ['setup.access'], |
| 43 | + tabPermissions: { app_crm: 'visible' }, |
| 44 | +} as any; |
| 45 | + |
| 46 | +interface Fixture { |
| 47 | + /** What the registry's by-name read does: answer from `CATALOG_SET`, or throw. */ |
| 48 | + registryThrows?: Error; |
| 49 | + /** `sys_permission_set` rows — present to prove they are never read. */ |
| 50 | + rows?: Array<Record<string, unknown>>; |
| 51 | + /** Boot with no registry on the engine: no catalog gets bound. */ |
| 52 | + noRegistry?: boolean; |
| 53 | +} |
| 54 | + |
| 55 | +/** Boot the real plugin over a double engine and hand back its private catalog loader. */ |
| 56 | +async function loaderOver(fixture: Fixture = {}) { |
| 57 | + let rowReads = 0; |
| 58 | + const ql: any = { |
| 59 | + registerMiddleware: () => {}, |
| 60 | + getSchema: () => null, |
| 61 | + findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }, |
| 62 | + // No table answers here: every set is the catalog's. Only a read that |
| 63 | + // asks for a set the loader was asked about counts — the boot's own |
| 64 | + // background passes (the org-admin backfill, the baseline binder) read |
| 65 | + // their own names and race the call. |
| 66 | + find: async (object: string, query?: unknown) => { |
| 67 | + if (object === 'sys_permission_set' && /ps_catalog|ps_row_only/.test(JSON.stringify(query ?? {}))) rowReads += 1; |
| 68 | + return []; |
| 69 | + }, |
| 70 | + }; |
| 71 | + if (!fixture.noRegistry) { |
| 72 | + ql.registry = { |
| 73 | + getItem: (type: string, name: string) => { |
| 74 | + if (fixture.registryThrows) throw fixture.registryThrows; |
| 75 | + return type === 'permission' && name === CATALOG_SET.name ? CATALOG_SET : undefined; |
| 76 | + }, |
| 77 | + listItems: (type: string) => (type === 'permission' ? [CATALOG_SET] : []), |
| 78 | + isPackageDisabled: () => false, |
| 79 | + }; |
| 80 | + } |
| 81 | + const warn = vi.fn(); |
| 82 | + const ctx: any = { |
| 83 | + logger: { info: vi.fn(), warn, error: vi.fn(), debug: vi.fn() }, |
| 84 | + registerService: vi.fn(), |
| 85 | + getService: (name: string) => { |
| 86 | + if (name === 'objectql') return ql; |
| 87 | + if (name === 'metadata') return { get: async () => null, list: async () => [] }; |
| 88 | + if (name === 'manifest') return { register: vi.fn() }; |
| 89 | + throw new Error(`service not registered: ${name}`); |
| 90 | + }, |
| 91 | + }; |
| 92 | + const plugin = new SecurityPlugin(); |
| 93 | + await plugin.init(ctx); |
| 94 | + await plugin.start(ctx); |
| 95 | + const loader = (plugin as any).catalogSetLoader as ((names: string[]) => Promise<PermissionSet[]>) | undefined; |
| 96 | + expect(typeof loader, 'the catalog loader was never built — the boot bailed out').toBe('function'); |
| 97 | + rowReads = 0; // boot-time reads are not the loader's |
| 98 | + return { loader: loader!, rowReads: () => rowReads }; |
| 99 | +} |
| 100 | + |
| 101 | +describe('[ADR-0131 D3/D4] the catalog loader — what it answers', () => { |
| 102 | + it('a set the catalog holds comes back WHOLE, and no sys_permission_set row is read', async () => { |
| 103 | + const { loader, rowReads } = await loaderOver(); |
| 104 | + const sets = await loader(['ps_catalog']); |
| 105 | + expect(sets).toEqual([CATALOG_SET]); |
| 106 | + expect(rowReads()).toBe(0); |
| 107 | + }); |
| 108 | + |
| 109 | + it('a name the catalog does not hold answers nothing — an EMPTY answer is a real answer', async () => { |
| 110 | + const { loader } = await loaderOver(); |
| 111 | + await expect(loader(['ps_nowhere'])).resolves.toEqual([]); |
| 112 | + }); |
| 113 | + |
| 114 | + it('a set only a sys_permission_set ROW carries resolves nothing, and the row is never read', async () => { |
| 115 | + const { loader, rowReads } = await loaderOver({ |
| 116 | + rows: [{ name: 'ps_row_only', object_permissions: JSON.stringify({ deal: { allowDelete: true } }) }], |
| 117 | + }); |
| 118 | + await expect(loader(['ps_row_only'])).resolves.toEqual([]); |
| 119 | + expect(rowReads()).toBe(0); |
| 120 | + }); |
| 121 | + |
| 122 | + it('a name asked twice is answered once', async () => { |
| 123 | + const { loader } = await loaderOver(); |
| 124 | + expect((await loader(['ps_catalog', 'ps_catalog'])).map((s) => s.name)).toEqual(['ps_catalog']); |
| 125 | + }); |
| 126 | + |
| 127 | + it('an engine with no catalog bound answers nothing, never a guess', async () => { |
| 128 | + const { loader } = await loaderOver({ noRegistry: true }); |
| 129 | + await expect(loader(['ps_catalog'])).resolves.toEqual([]); |
| 130 | + }); |
| 131 | +}); |
| 132 | + |
| 133 | +describe('[ADR-0131 D3/D4, #2565] a catalog read that did not answer', () => { |
| 134 | + it('PROPAGATES as the reader\'s own loud failure instead of reading as "no such set"', async () => { |
| 135 | + const { loader } = await loaderOver({ registryThrows: new Error("Datasource 'primary' is declared but not connected") }); |
| 136 | + // ⛔ Not `toThrow()` alone: the envelope is the assertion. |
| 137 | + const err = await loader(['ps_catalog']).then(() => null, (e: unknown) => e); |
| 138 | + expect(err).toBeInstanceOf(AuthzStoreUnavailableError); |
| 139 | + expect(err).toMatchObject({ code: AUTHZ_STORE_UNAVAILABLE_CODE, status: AUTHZ_STORE_UNAVAILABLE_STATUS }); |
| 140 | + }); |
| 141 | + |
| 142 | + it('stays FAIL-CLOSED through the evaluator, and its #2565 warn fires exactly once', async () => { |
| 143 | + const { loader } = await loaderOver({ registryThrows: new Error('registry unreadable') }); |
| 144 | + const warns: Array<{ msg: string; meta: any }> = []; |
| 145 | + const resolved = await new PermissionEvaluator().resolvePermissionSets( |
| 146 | + ['ps_catalog'], |
| 147 | + { list: async () => [] }, |
| 148 | + [], |
| 149 | + loader, |
| 150 | + { logger: { warn: (msg: string, meta?: any) => { warns.push({ msg, meta }); } } }, |
| 151 | + ); |
| 152 | + // The unresolved set grants nothing — fail closed, never a widening. |
| 153 | + expect(resolved).toEqual([]); |
| 154 | + // The loss is sayable: one warn, naming the catalog and the names it could not answer. |
| 155 | + expect(warns).toHaveLength(1); |
| 156 | + expect(warns[0].msg).toContain('catalog lookup failed'); |
| 157 | + expect(warns[0].meta?.unresolved).toEqual(['ps_catalog']); |
| 158 | + }); |
| 159 | + |
| 160 | + it('CONTROL: the same request with a catalog that answers resolves the set and warns nothing', async () => { |
| 161 | + const { loader } = await loaderOver(); |
| 162 | + const warns: string[] = []; |
| 163 | + const resolved = await new PermissionEvaluator().resolvePermissionSets( |
| 164 | + ['ps_catalog'], |
| 165 | + { list: async () => [] }, |
| 166 | + [], |
| 167 | + loader, |
| 168 | + { logger: { warn: (msg: string) => { warns.push(msg); } } }, |
| 169 | + ); |
| 170 | + expect(resolved.map((s) => s.name)).toEqual(['ps_catalog']); |
| 171 | + expect(warns).toEqual([]); |
| 172 | + }); |
| 173 | +}); |
0 commit comments