|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#30319] The two doors that copy a permission set into a new package, on a |
| 5 | + * deployment without an optional plugin whose object a platform set grants on |
| 6 | + * (`sys_comment`, which `@objectstack/plugin-audit` registers). |
| 7 | + * |
| 8 | + * Measured on `origin/main` (870ebce3) before this file, on the harness below: |
| 9 | + * - `duplicatePackage` answered `success: false`, the object copied and the |
| 10 | + * set in `failed[]` with the unknown-object refusal at |
| 11 | + * `objects.sys_comment` — the same 422 the permission-set clone door had |
| 12 | + * (#30255, fixed by PR #30310 through `inheritedGrantObjects`); |
| 13 | + * - `importUnmanagedPackage` answered `422 INVALID_METADATA` at |
| 14 | + * `objects.sys_comment` and rolled the whole import back. |
| 15 | + * |
| 16 | + * Pinned here: |
| 17 | + * - the DUPLICATE door copies a stored set's grants the source row holds: |
| 18 | + * the copy is saved, granting on `sys_comment`. Keys come from the stored |
| 19 | + * row, never the request. A key the copy's rewrite renames (the source's |
| 20 | + * own namespace) is not the source's, so a renamed grant on an object that |
| 21 | + * does not exist is still refused — and only that one; |
| 22 | + * - the UNMANAGED IMPORT keeps refusing, by ruling (seat 2 on #30319, option |
| 23 | + * A): the package is the request's body, not a stored row, so its sets are |
| 24 | + * new writes and every grant is judged (triage 6651966154: the inherited |
| 25 | + * keys are derived from the source set as stored, never from request input). |
| 26 | + * |
| 27 | + * Harness: a stateful `sys_metadata` stub and a registry that keeps packages |
| 28 | + * and namespaces, after `package-unmanaged-install.test.ts`. The registry |
| 29 | + * registers no object, so an object is known only when it is stored here. |
| 30 | + */ |
| 31 | +// Pays the lint root's first load at collection, never inside a clocked case. |
| 32 | +import './lint-root-preload.testkit.js'; |
| 33 | +import { describe, expect, it } from 'vitest'; |
| 34 | +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; |
| 35 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 36 | + |
| 37 | +type Row = Record<string, any>; |
| 38 | + |
| 39 | +function matches(row: Row, where: Record<string, unknown> = {}): boolean { |
| 40 | + for (const [key, want] of Object.entries(where)) { |
| 41 | + if (want === undefined) continue; |
| 42 | + if (key === '$or') { |
| 43 | + if (!(want as Array<Record<string, unknown>>).some((c) => matches(row, c))) return false; |
| 44 | + continue; |
| 45 | + } |
| 46 | + if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); |
| 47 | + const have = row[key] ?? null; |
| 48 | + if (want !== null && typeof want === 'object' && !Array.isArray(want)) { |
| 49 | + const ops = Object.keys(want); |
| 50 | + if (ops.length !== 1 || ops[0] !== '$in') throw new Error(`stub engine: unsupported operator ${ops.join(',')}`); |
| 51 | + if (!(want as { $in: unknown[] }).$in.includes(have)) return false; |
| 52 | + continue; |
| 53 | + } |
| 54 | + if (have !== want) return false; |
| 55 | + } |
| 56 | + return true; |
| 57 | +} |
| 58 | + |
| 59 | +function makeProtocol(seed: Row[] = [], seedPackages: any[] = []) { |
| 60 | + const tables = new Map<string, Row[]>([['sys_metadata', seed.map((r) => ({ ...r }))]]); |
| 61 | + const table = (name: string) => { |
| 62 | + if (!tables.has(name)) tables.set(name, []); |
| 63 | + return tables.get(name)!; |
| 64 | + }; |
| 65 | + let nextId = 0; |
| 66 | + const packages = new Map<string, any>(seedPackages.map((m) => [m.id, { manifest: m, status: 'installed', enabled: true }])); |
| 67 | + const namespaces = new Map<string, Set<string>>(); |
| 68 | + for (const m of seedPackages) if (m.namespace) namespaces.set(m.namespace, new Set([m.id])); |
| 69 | + const engine: any = { |
| 70 | + async syncObjectSchema() { return true; }, |
| 71 | + async dropObjectSchema() { return true; }, |
| 72 | + async find(t: string, q?: { where?: Record<string, unknown>; limit?: number }) { |
| 73 | + const rows = table(t).filter((r) => matches(r, q?.where)); |
| 74 | + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; |
| 75 | + }, |
| 76 | + async findOne(t: string, q: { where: Record<string, unknown> }) { |
| 77 | + assertEngineFindOnePredicate(t, q); |
| 78 | + return table(t).find((r) => matches(r, q.where)) ?? null; |
| 79 | + }, |
| 80 | + async insert(t: string, data: Row) { |
| 81 | + nextId += 1; |
| 82 | + const row = { id: `r_${nextId}`, ...data }; |
| 83 | + table(t).push(row); |
| 84 | + return { ...row }; |
| 85 | + }, |
| 86 | + async update(t: string, data: Row, q?: Record<string, unknown>) { |
| 87 | + assertEngineUpdateDispatch(data, q); |
| 88 | + const where = (q as { where?: Record<string, unknown> } | undefined)?.where; |
| 89 | + const hit = table(t).filter((r) => matches(r, where)); |
| 90 | + for (const r of hit) Object.assign(r, data); |
| 91 | + return { id: hit[0]?.id ?? null }; |
| 92 | + }, |
| 93 | + async delete(t: string, q?: Record<string, unknown>) { |
| 94 | + assertEngineDeleteDispatch(q); |
| 95 | + const where = (q as { where?: Record<string, unknown> } | undefined)?.where; |
| 96 | + const keep = table(t).filter((r) => !matches(r, where)); |
| 97 | + const deleted = table(t).length - keep.length; |
| 98 | + tables.set(t, keep); |
| 99 | + return { deleted }; |
| 100 | + }, |
| 101 | + async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> { |
| 102 | + return cb(undefined, { owned: true }); |
| 103 | + }, |
| 104 | + registry: { |
| 105 | + registerItem: () => {}, |
| 106 | + registerObject: () => {}, |
| 107 | + unregisterItem: () => {}, |
| 108 | + listItems: () => [], |
| 109 | + getItem: () => undefined, |
| 110 | + getAllObjects: () => [], |
| 111 | + // No plugin-audit: `sys_comment` is registered by nothing here. |
| 112 | + getObject: () => undefined, |
| 113 | + getArtifactItem: () => undefined, |
| 114 | + isPackageDisabled: () => false, |
| 115 | + getPackage: (id: string) => packages.get(id), |
| 116 | + installPackage: (manifest: any) => { |
| 117 | + const pkg = { manifest, status: 'installed', enabled: true }; |
| 118 | + packages.set(manifest.id, pkg); |
| 119 | + if (manifest.namespace) { |
| 120 | + if (!namespaces.has(manifest.namespace)) namespaces.set(manifest.namespace, new Set()); |
| 121 | + namespaces.get(manifest.namespace)!.add(manifest.id); |
| 122 | + } |
| 123 | + return pkg; |
| 124 | + }, |
| 125 | + uninstallPackage: (id: string) => { |
| 126 | + const pkg = packages.get(id); |
| 127 | + packages.delete(id); |
| 128 | + if (pkg?.manifest?.namespace) namespaces.get(pkg.manifest.namespace)?.delete(id); |
| 129 | + }, |
| 130 | + unregisterNamespace: () => {}, |
| 131 | + getNamespaceOwners: (ns: string) => [...(namespaces.get(ns) ?? [])], |
| 132 | + }, |
| 133 | + }; |
| 134 | + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; |
| 135 | + return { protocol, table, packages }; |
| 136 | +} |
| 137 | + |
| 138 | +const SOURCE = 'com.example.leave'; |
| 139 | +const TARGET = 'com.example.leave-copy'; |
| 140 | +const SOURCE_MANIFEST = { id: SOURCE, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' }; |
| 141 | + |
| 142 | +const ticket = { |
| 143 | + name: 'leave_ticket', label: 'Ticket', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, |
| 144 | +}; |
| 145 | +/** A schema-valid permission-set body granting read on each named object. */ |
| 146 | +const set = (name: string, objectNames: string[]) => ({ |
| 147 | + name, |
| 148 | + label: 'Leave staff', |
| 149 | + objects: Object.fromEntries(objectNames.map((o) => [o, { allowRead: true }])), |
| 150 | +}); |
| 151 | +/** A row of the source package as it sits at rest. */ |
| 152 | +const sourceRow = (type: string, body: { name: string }) => ({ |
| 153 | + type, name: body.name, organization_id: null, package_id: SOURCE, state: 'active', metadata: JSON.stringify(body), |
| 154 | +}); |
| 155 | + |
| 156 | +/** The finding's first sentence, spelled out (`unknownGrantObjectSaveRefusal`). */ |
| 157 | +const unknownFinding = (setName: string, objectName: string) => |
| 158 | + `Permission '${setName}' grants on object '${objectName}' which is not defined in objects: no package registers ` |
| 159 | + + 'an object of that name and none is saved here, so the grant applies to nothing.'; |
| 160 | + |
| 161 | +/** The stored grant keys of one permission set of the target package. */ |
| 162 | +const copiedGrants = (rows: Row[], name: string) => |
| 163 | + rows.filter((r) => r.type === 'permission' && r.name === name && r.package_id === TARGET) |
| 164 | + .map((r) => Object.keys(JSON.parse(r.metadata).objects ?? {})); |
| 165 | + |
| 166 | +describe('[#30319] duplicatePackage — a copied permission set keeps the grants its source row holds', () => { |
| 167 | + it('a set granting on an optional plugin\'s object not installed here is copied, the grant with it', async () => { |
| 168 | + const { protocol, table } = makeProtocol( |
| 169 | + [sourceRow('object', ticket), sourceRow('permission', set('leave_staff', ['leave_ticket', 'sys_comment']))], |
| 170 | + [SOURCE_MANIFEST], |
| 171 | + ); |
| 172 | + |
| 173 | + const res = await protocol.duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET }); |
| 174 | + |
| 175 | + expect(res.failed).toEqual([]); |
| 176 | + expect(res.success).toBe(true); |
| 177 | + expect(res.copied).toEqual([ |
| 178 | + { type: 'object', name: 'leave_copy_ticket' }, |
| 179 | + { type: 'permission', name: 'leave_copy_staff' }, |
| 180 | + ]); |
| 181 | + // The source's own object is rewritten to the copy's; the platform grant is kept. |
| 182 | + expect(copiedGrants(table('sys_metadata'), 'leave_copy_staff')).toEqual([['leave_copy_ticket', 'sys_comment']]); |
| 183 | + }); |
| 184 | + |
| 185 | + it('a grant the copy renames onto an object that does not exist is still refused — that grant alone', async () => { |
| 186 | + // The copy's rewrite renames a grant on the source's own object to the |
| 187 | + // copy's (`leave_ticket` → `leave_copy_ticket`): a key the source never |
| 188 | + // held. Here that object's row does not copy (unparseable), so the |
| 189 | + // renamed grant names nothing and is judged; `sys_comment` is not. |
| 190 | + const { protocol, table } = makeProtocol( |
| 191 | + [ |
| 192 | + { ...sourceRow('object', ticket), metadata: '{not json' }, |
| 193 | + sourceRow('permission', set('leave_staff', ['leave_ticket', 'sys_comment'])), |
| 194 | + ], |
| 195 | + [SOURCE_MANIFEST], |
| 196 | + ); |
| 197 | + |
| 198 | + const res = await protocol.duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET }); |
| 199 | + |
| 200 | + expect(res.success).toBe(false); |
| 201 | + expect(res.copied).toEqual([]); |
| 202 | + expect(res.failed).toEqual([ |
| 203 | + { type: 'object', name: 'leave_ticket', error: 'unparseable metadata' }, |
| 204 | + { |
| 205 | + type: 'permission', |
| 206 | + name: 'leave_staff', |
| 207 | + error: 'permission/leave_copy_staff failed spec validation: objects.leave_copy_ticket: ' |
| 208 | + + `${unknownFinding('leave_copy_staff', 'leave_copy_ticket')} ` |
| 209 | + + "Correct the name to the object's full name (e.g. 'crm_lead', not 'lead'), create the object first, or remove the grant.", |
| 210 | + }, |
| 211 | + ]); |
| 212 | + expect(copiedGrants(table('sys_metadata'), 'leave_copy_staff')).toEqual([]); |
| 213 | + }); |
| 214 | + |
| 215 | + it('a grant the source holds on an object outside its namespace that does not exist is the source\'s, and is copied', async () => { |
| 216 | + // Not renamed (no object row of the source carries it), so it is the |
| 217 | + // source's grant as stored — the clone door's rule for a copy. |
| 218 | + const { protocol, table } = makeProtocol( |
| 219 | + [sourceRow('object', ticket), sourceRow('permission', set('leave_staff', ['leave_ticket', 'other_gone']))], |
| 220 | + [SOURCE_MANIFEST], |
| 221 | + ); |
| 222 | + |
| 223 | + const res = await protocol.duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET }); |
| 224 | + |
| 225 | + expect(res.failed).toEqual([]); |
| 226 | + expect(copiedGrants(table('sys_metadata'), 'leave_copy_staff')).toEqual([['leave_copy_ticket', 'other_gone']]); |
| 227 | + }); |
| 228 | +}); |
| 229 | + |
| 230 | +describe('[#30319] importUnmanagedPackage — the package is the request\'s body, so every grant is judged', () => { |
| 231 | + it('a set granting on an object not installed here is refused 422 at `objects.<name>`, and the import is rolled back', async () => { |
| 232 | + // By ruling (seat 2 on #30319, option A): no `inheritedGrantObjects` |
| 233 | + // here. Deriving them from the body would be reading request input, |
| 234 | + // which turns the unknown-object refusal off for this door. |
| 235 | + const { protocol, table, packages } = makeProtocol(); |
| 236 | + |
| 237 | + let err: any; |
| 238 | + try { |
| 239 | + await protocol.importUnmanagedPackage({ |
| 240 | + manifest: { |
| 241 | + id: 'com.example.crm', namespace: 'crm', version: '4.0.0', type: 'app', name: 'CRM', |
| 242 | + objects: [{ name: 'crm_lead', label: 'Lead', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], |
| 243 | + permissions: [set('crm_rep', ['crm_lead', 'sys_comment'])], |
| 244 | + }, |
| 245 | + }); |
| 246 | + } catch (e) { |
| 247 | + err = e; |
| 248 | + } |
| 249 | + |
| 250 | + expect({ code: err?.code, status: err?.status }, String(err?.message)).toEqual({ code: 'INVALID_METADATA', status: 422 }); |
| 251 | + expect(err.issues.map((i: any) => i.path)).toEqual(['objects.sys_comment']); |
| 252 | + expect(String(err.issues[0].message).startsWith(unknownFinding('crm_rep', 'sys_comment')), err.issues[0].message).toBe(true); |
| 253 | + // Rolled back whole: no row, no base. |
| 254 | + expect(table('sys_metadata')).toEqual([]); |
| 255 | + expect(packages.has('com.example.crm-copy')).toBe(false); |
| 256 | + }); |
| 257 | +}); |
0 commit comments