|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 | import { describe, it, expect } from 'vitest'; |
| 3 | +import { |
| 4 | + AssembledPackageBodySchema, |
| 5 | + ObjectStackDefinitionSchema, |
| 6 | + composeStacks, |
| 7 | + defineStack, |
| 8 | +} from '@objectstack/spec'; |
3 | 9 | import { appDefaultPermissionSetName, appSecurityPluginOptions } from './app-default-permission-set'; |
4 | 10 | import { SecurityPlugin } from './security-plugin'; |
5 | 11 |
|
@@ -100,3 +106,192 @@ describe('the resolved options reach the constructed plugin (#7001)', () => { |
100 | 106 | .resolves.toBe('member_default'); |
101 | 107 | }); |
102 | 108 | }); |
| 109 | + |
| 110 | +/** |
| 111 | + * [ADR-0130 D4, #15007] The reader resolves `packages[]`. |
| 112 | + * |
| 113 | + * Reader card 4/4 of the option-B program ruled on #14512. A multi-package |
| 114 | + * artifact carries each definition twice today — flattened at the top level and |
| 115 | + * again under `packages[]` — and option B removes the flattened copy. Every |
| 116 | + * assertion below is about the SAME declaration read out of both shapes, which |
| 117 | + * is what "the artifact stays additive while the readers learn" means. |
| 118 | + * |
| 119 | + * The two shapes are built by the REAL composer (`composeStacks`, the one |
| 120 | + * `examples/app-multi-package` uses) rather than hand-written, so a package |
| 121 | + * entry that stopped looking the way this file assumes fails here instead of |
| 122 | + * passing against a shape the platform never emits. The option-B shape is |
| 123 | + * derived from it by stripping the package-owned keys — and that key set is |
| 124 | + * read off the two schemas, never transcribed, so a collection family added to |
| 125 | + * the stack schema next month is stripped too. |
| 126 | + */ |
| 127 | +describe('appSecurityPluginOptions over `packages[]` (ADR-0130 D4, #15007)', () => { |
| 128 | + const CORE_ID = 'com.example.security.core'; |
| 129 | + const ADDON_ID = 'com.example.security.addon'; |
| 130 | + const CORE_PROFILE = 'core_member_default'; |
| 131 | + const ADDON_PROFILE = 'addon_member_default'; |
| 132 | + |
| 133 | + const shapeKeys = (schema: unknown): string[] => |
| 134 | + Object.keys((schema as { shape: Record<string, unknown> }).shape); |
| 135 | + |
| 136 | + /** Exactly the keys an option-B artifact no longer carries at the top level. */ |
| 137 | + const PACKAGE_OWNED_KEYS: readonly string[] = (() => { |
| 138 | + const body = new Set(shapeKeys(AssembledPackageBodySchema)); |
| 139 | + return shapeKeys(ObjectStackDefinitionSchema).filter((k) => body.has(k)); |
| 140 | + })(); |
| 141 | + |
| 142 | + const permissionSet = (name: string) => ({ |
| 143 | + name, |
| 144 | + label: name, |
| 145 | + isDefault: true, |
| 146 | + objects: {}, |
| 147 | + }); |
| 148 | + |
| 149 | + const coreStack = () => |
| 150 | + defineStack({ |
| 151 | + manifest: { |
| 152 | + id: CORE_ID, name: 'Security Probe Core', namespace: 'secprobe', |
| 153 | + version: '1.0.0', type: 'app', |
| 154 | + }, |
| 155 | + permissions: [permissionSet(CORE_PROFILE)], |
| 156 | + }); |
| 157 | + |
| 158 | + /** Declared SECOND in composition order, and depends on the app package. */ |
| 159 | + const addonStack = () => |
| 160 | + defineStack({ |
| 161 | + manifest: { |
| 162 | + id: ADDON_ID, name: 'Security Probe Addon', namespace: 'secprobe', |
| 163 | + version: '1.0.0', type: 'module', |
| 164 | + dependencies: { [CORE_ID]: '^1.0.0' }, |
| 165 | + }, |
| 166 | + }); |
| 167 | + |
| 168 | + /** Today's emitted shape: flattened top level PLUS `packages[]`. */ |
| 169 | + const additive = () => composeStacks([addonStack(), coreStack()], { manifest: 'preserve' }); |
| 170 | + |
| 171 | + /** The ruled shape: `packages[]` only. */ |
| 172 | + const optionB = () => { |
| 173 | + const composed = additive() as unknown as Record<string, unknown>; |
| 174 | + const owned = new Set(PACKAGE_OWNED_KEYS); |
| 175 | + const out: Record<string, unknown> = {}; |
| 176 | + for (const [key, value] of Object.entries(composed)) if (!owned.has(key)) out[key] = value; |
| 177 | + return out; |
| 178 | + }; |
| 179 | + |
| 180 | + it('CONTROL — the additive shape really does carry the flattened copy', () => { |
| 181 | + // Without this, the option-B case below could pass because the fixture |
| 182 | + // never had a flattened level to lose. |
| 183 | + const composed = additive() as unknown as Record<string, unknown>; |
| 184 | + expect(Array.isArray(composed.permissions)).toBe(true); |
| 185 | + expect((composed.permissions as unknown[]).length).toBeGreaterThan(0); |
| 186 | + expect((composed.packages as unknown[]).length).toBe(2); |
| 187 | + expect(PACKAGE_OWNED_KEYS).toContain('permissions'); |
| 188 | + }); |
| 189 | + |
| 190 | + it('the additive shape answers exactly what it answered before this card', () => { |
| 191 | + expect(appSecurityPluginOptions(additive())).toEqual({ fallbackPermissionSet: CORE_PROFILE }); |
| 192 | + }); |
| 193 | + |
| 194 | + it('OPTION B — the flattened level is gone and the packaged declaration is still resolved', () => { |
| 195 | + const stripped = optionB(); |
| 196 | + expect(stripped.permissions).toBeUndefined(); |
| 197 | + expect((stripped.packages as unknown[]).length).toBe(2); |
| 198 | + |
| 199 | + // The pre-#15007 reader returned `undefined` here — no throw, no log, and |
| 200 | + // every member of the app silently down to the platform floor alone. |
| 201 | + expect(appSecurityPluginOptions(stripped)).toEqual({ fallbackPermissionSet: CORE_PROFILE }); |
| 202 | + }); |
| 203 | + |
| 204 | + it('the flattened level still answers FIRST when both shapes carry a set', () => { |
| 205 | + // The reader half lands while the artifact is still additive, so this |
| 206 | + // function must be a superset of the old read and never a replacement: |
| 207 | + // whatever the top level said, it still says. |
| 208 | + expect( |
| 209 | + appSecurityPluginOptions({ |
| 210 | + permissions: [permissionSet('flattened_wins')], |
| 211 | + packages: [{ manifest: { id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] } }], |
| 212 | + }), |
| 213 | + ).toEqual({ fallbackPermissionSet: 'flattened_wins' }); |
| 214 | + }); |
| 215 | + |
| 216 | + it('package order is `resolveArtifactPackageOrder`\'s, not the array\'s', () => { |
| 217 | + // Both packages declare an `isDefault` set and the DEPENDENT one is listed |
| 218 | + // first. "The first isDefault set" has to mean the same thing here as at |
| 219 | + // every other artifact reader, so the depended-upon package answers — |
| 220 | + // dependency-topological order (ADR-0130 D5), not authoring accident. |
| 221 | + expect( |
| 222 | + appSecurityPluginOptions({ |
| 223 | + packages: [ |
| 224 | + { manifest: { id: ADDON_ID, name: 'Addon', version: '1.0.0', type: 'module', dependencies: { [CORE_ID]: '^1.0.0' }, permissions: [permissionSet(ADDON_PROFILE)] } }, |
| 225 | + { manifest: { id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] } }, |
| 226 | + ], |
| 227 | + }), |
| 228 | + ).toEqual({ fallbackPermissionSet: CORE_PROFILE }); |
| 229 | + |
| 230 | + // …and with the dependency edge removed, declared order is what is left. |
| 231 | + expect( |
| 232 | + appSecurityPluginOptions({ |
| 233 | + packages: [ |
| 234 | + { manifest: { id: ADDON_ID, name: 'Addon', version: '1.0.0', type: 'module', permissions: [permissionSet(ADDON_PROFILE)] } }, |
| 235 | + { manifest: { id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] } }, |
| 236 | + ], |
| 237 | + }), |
| 238 | + ).toEqual({ fallbackPermissionSet: ADDON_PROFILE }); |
| 239 | + }); |
| 240 | + |
| 241 | + it('a package that declares no default does not shadow one that does', () => { |
| 242 | + expect( |
| 243 | + appSecurityPluginOptions({ |
| 244 | + packages: [ |
| 245 | + { manifest: { id: ADDON_ID, name: 'Addon', version: '1.0.0', type: 'module', permissions: [{ name: 'addon_read_only', label: 'RO', objects: {} }] } }, |
| 246 | + { manifest: { id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] } }, |
| 247 | + ], |
| 248 | + }), |
| 249 | + ).toEqual({ fallbackPermissionSet: CORE_PROFILE }); |
| 250 | + }); |
| 251 | + |
| 252 | + it('an artifact with no `packages` key still reads the top level and NOTHING else', () => { |
| 253 | + // D4's second branch hands `resolveArtifactPackageOrder` the caller's own |
| 254 | + // object back as the single package body, so this path is the pre-#15007 |
| 255 | + // read exactly — including its refusal to look inside the singular |
| 256 | + // `manifest` (#7001, pinned above). |
| 257 | + expect(appSecurityPluginOptions({ manifest: { permissions: [permissionSet('buried')] } })).toBeUndefined(); |
| 258 | + expect(appSecurityPluginOptions({ packages: [] })).toBeUndefined(); |
| 259 | + expect(appSecurityPluginOptions({ permissions: [permissionSet('top')] })).toEqual({ fallbackPermissionSet: 'top' }); |
| 260 | + }); |
| 261 | + |
| 262 | + /** |
| 263 | + * The gate travels with the read: `resolveArtifactPackageOrder` refuses a |
| 264 | + * malformed `packages` with an ADR-0112 envelope, and this reader does not |
| 265 | + * catch it. Swallowing it would resolve a permission surface out of an |
| 266 | + * artifact the loader refuses to load. |
| 267 | + */ |
| 268 | + describe('a malformed `packages` is refused, not silently skipped', () => { |
| 269 | + const refusalOf = (config: unknown): { code?: string; status?: number; message?: string } => { |
| 270 | + try { |
| 271 | + appSecurityPluginOptions(config); |
| 272 | + return {}; |
| 273 | + } catch (e) { |
| 274 | + return e as { code?: string; status?: number; message?: string }; |
| 275 | + } |
| 276 | + }; |
| 277 | + |
| 278 | + it('`packages` that is not an array', () => { |
| 279 | + const err = refusalOf({ packages: 'nope' }); |
| 280 | + expect(err.code).toBe('INVALID_ARTIFACT_PACKAGES'); |
| 281 | + expect(err.status).toBe(422); |
| 282 | + }); |
| 283 | + |
| 284 | + it('an entry inlined instead of wrapped under `manifest:`', () => { |
| 285 | + const err = refusalOf({ packages: [{ id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] }] }); |
| 286 | + expect(err.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); |
| 287 | + expect(err.status).toBe(422); |
| 288 | + }); |
| 289 | + |
| 290 | + it('the same package id twice', () => { |
| 291 | + const entry = { manifest: { id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] } }; |
| 292 | + const err = refusalOf({ packages: [entry, entry] }); |
| 293 | + expect(err.code).toBe('DUPLICATE_ARTIFACT_PACKAGE'); |
| 294 | + expect(err.status).toBe(422); |
| 295 | + }); |
| 296 | + }); |
| 297 | +}); |
0 commit comments