|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { |
| 5 | + annotateEffectiveApiOperations, |
| 6 | + seedSuperUserRestrictedObjects, |
| 7 | + type ApiExposureSchemaLike, |
| 8 | +} from './hono-plugin.js'; |
| 9 | + |
| 10 | +/** |
| 11 | + * #3391 — the `/me/permissions` per-object map carries the server-resolved |
| 12 | + * effective API operation set (`apiOperations`). These pin the two pure helpers |
| 13 | + * (parallel to fold/clamp): the super-user seed and the annotation pass. |
| 14 | + */ |
| 15 | +describe('annotateEffectiveApiOperations (#3391)', () => { |
| 16 | + const schemaOf = (map: Record<string, ApiExposureSchemaLike>) => (name: string) => map[name]; |
| 17 | + |
| 18 | + it('annotates a restricting object with its effective operation set', () => { |
| 19 | + const objects: Record<string, any> = { widget: { allowRead: true } }; |
| 20 | + annotateEffectiveApiOperations(objects, schemaOf({ |
| 21 | + widget: { name: 'widget', enable: { apiMethods: ['get', 'list'] } }, |
| 22 | + })); |
| 23 | + // list-class reads derive aggregate/search/export; enum-ordered. |
| 24 | + expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); |
| 25 | + }); |
| 26 | + |
| 27 | + it('does NOT annotate an unrestricted object (client keeps default-allow)', () => { |
| 28 | + const objects: Record<string, any> = { widget: { allowRead: true } }; |
| 29 | + annotateEffectiveApiOperations(objects, schemaOf({ |
| 30 | + widget: { name: 'widget', enable: {} }, // no apiMethods → unrestricted |
| 31 | + })); |
| 32 | + expect('apiOperations' in objects.widget).toBe(false); |
| 33 | + }); |
| 34 | + |
| 35 | + it('annotates a deny-all object with an empty array', () => { |
| 36 | + const objects: Record<string, any> = { locked: { allowRead: true } }; |
| 37 | + annotateEffectiveApiOperations(objects, schemaOf({ |
| 38 | + locked: { name: 'locked', enable: { apiMethods: [] } }, |
| 39 | + })); |
| 40 | + expect(objects.locked.apiOperations).toEqual([]); |
| 41 | + }); |
| 42 | + |
| 43 | + it('skips the wildcard entry', () => { |
| 44 | + const objects: Record<string, any> = { '*': { modifyAllRecords: true }, widget: { allowRead: true } }; |
| 45 | + annotateEffectiveApiOperations(objects, schemaOf({ |
| 46 | + widget: { name: 'widget', enable: { apiMethods: ['create', 'bulk'] } }, |
| 47 | + })); |
| 48 | + expect('apiOperations' in objects['*']).toBe(false); |
| 49 | + expect(objects.widget.apiOperations).toContain('create'); |
| 50 | + expect(objects.widget.apiOperations).toContain('bulk'); |
| 51 | + }); |
| 52 | + |
| 53 | + it('skips when the schema is missing (client falls back)', () => { |
| 54 | + const objects: Record<string, any> = { widget: { allowRead: true } }; |
| 55 | + annotateEffectiveApiOperations(objects, () => undefined); |
| 56 | + expect('apiOperations' in objects.widget).toBe(false); |
| 57 | + }); |
| 58 | + |
| 59 | + it('reverse-derived import/export appear in the effective set for a CRUD whitelist', () => { |
| 60 | + const objects: Record<string, any> = { deal: { allowRead: true } }; |
| 61 | + annotateEffectiveApiOperations(objects, schemaOf({ |
| 62 | + deal: { name: 'deal', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete'] } }, |
| 63 | + })); |
| 64 | + expect(objects.deal.apiOperations).toContain('import'); |
| 65 | + expect(objects.deal.apiOperations).toContain('export'); |
| 66 | + expect(objects.deal.apiOperations).toContain('upsert'); |
| 67 | + expect(objects.deal.apiOperations).not.toContain('restore'); |
| 68 | + }); |
| 69 | +}); |
| 70 | + |
| 71 | +describe('seedSuperUserRestrictedObjects (#3391)', () => { |
| 72 | + const schemas: ApiExposureSchemaLike[] = [ |
| 73 | + { name: 'widget', enable: { apiMethods: ['get', 'list'] } }, // restricting |
| 74 | + { name: 'open_obj', enable: {} }, // unrestricted |
| 75 | + { name: 'locked', enable: { apiMethods: [] } }, // deny-all (restricting) |
| 76 | + ]; |
| 77 | + |
| 78 | + it('for a modify-all super-user, seeds false-init entries for restricting objects only', () => { |
| 79 | + const objects: Record<string, any> = { '*': { modifyAllRecords: true, viewAllRecords: true } }; |
| 80 | + seedSuperUserRestrictedObjects(objects, schemas); |
| 81 | + expect(objects.widget).toEqual({ allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false }); |
| 82 | + expect(objects.locked).toBeDefined(); |
| 83 | + // unrestricted objects are NOT seeded (no annotation to attach) |
| 84 | + expect(objects.open_obj).toBeUndefined(); |
| 85 | + }); |
| 86 | + |
| 87 | + it('does not seed for a viewAll-only wildcard (avoids flipping check() to explicit deny)', () => { |
| 88 | + const objects: Record<string, any> = { '*': { viewAllRecords: true } }; // no modifyAllRecords |
| 89 | + seedSuperUserRestrictedObjects(objects, schemas); |
| 90 | + expect(objects.widget).toBeUndefined(); |
| 91 | + }); |
| 92 | + |
| 93 | + it('does not clobber an object already present in the map', () => { |
| 94 | + const objects: Record<string, any> = { |
| 95 | + '*': { modifyAllRecords: true }, |
| 96 | + widget: { allowRead: true, allowEdit: true }, // pre-existing explicit entry |
| 97 | + }; |
| 98 | + seedSuperUserRestrictedObjects(objects, schemas); |
| 99 | + expect(objects.widget).toEqual({ allowRead: true, allowEdit: true }); |
| 100 | + }); |
| 101 | + |
| 102 | + it('end-to-end: seed → annotate yields apiOperations for a super-user on a restricting object', () => { |
| 103 | + // A super-user whose only grant is the wildcard, with no explicit widget entry. |
| 104 | + const objects: Record<string, any> = { '*': { modifyAllRecords: true } }; |
| 105 | + seedSuperUserRestrictedObjects(objects, schemas); |
| 106 | + annotateEffectiveApiOperations(objects, (name) => schemas.find((s) => s.name === name)); |
| 107 | + expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']); |
| 108 | + expect(objects.locked.apiOperations).toEqual([]); |
| 109 | + }); |
| 110 | +}); |
0 commit comments