|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#18386] `getWritableFields` — the write-side twin of `getReadableFields`. |
| 5 | + * |
| 6 | + * The first block is an EQUIVALENCE, not a table of expected lists: for every |
| 7 | + * field of the object it drives the REAL registered middleware with a write |
| 8 | + * whose payload names only that field, and requires "the middleware admitted |
| 9 | + * it" to equal "the field is in `getWritableFields`". The two answers come from |
| 10 | + * one derivation; this is what keeps them one. |
| 11 | + * |
| 12 | + * Harness mirrors `can-write-object-admission.test.ts`. |
| 13 | + */ |
| 14 | + |
| 15 | +import { describe, it, expect, vi } from 'vitest'; |
| 16 | +import type { PermissionSet } from '@objectstack/spec/security'; |
| 17 | +import { SecurityPlugin } from './security-plugin.js'; |
| 18 | + |
| 19 | +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true }; |
| 20 | + |
| 21 | +/** The baseline every authenticated caller resolves: reads `account`, may not edit it; `secret` neither. */ |
| 22 | +const LOCKED_SET = { |
| 23 | + name: 'member_default', |
| 24 | + label: 'Writer, FLS-locked', |
| 25 | + objects: { invoice: CRUD }, |
| 26 | + fields: { |
| 27 | + 'invoice.account': { readable: true, editable: false }, |
| 28 | + 'invoice.secret': { readable: false, editable: false }, |
| 29 | + }, |
| 30 | +} as unknown as PermissionSet; |
| 31 | + |
| 32 | +/** The same grant with no field rules — every field passes step 2.5. */ |
| 33 | +const OPEN_SET = { name: 'member_default', label: 'Writer', objects: { invoice: CRUD } } as unknown as PermissionSet; |
| 34 | + |
| 35 | +/** …and with the capability `margin` requires (ADR-0066 D3). */ |
| 36 | +const CAPABLE_SET = { |
| 37 | + name: 'member_default', |
| 38 | + label: 'Writer with the margin capability', |
| 39 | + objects: { invoice: CRUD }, |
| 40 | + systemPermissions: ['view_margin'], |
| 41 | +} as unknown as PermissionSet; |
| 42 | + |
| 43 | +/** An agent's own set, which may edit `account`: the D10 case turns on the intersection alone. */ |
| 44 | +const AGENT_SET = { |
| 45 | + name: 'agent_writer', |
| 46 | + label: 'Agent', |
| 47 | + objects: { invoice: CRUD }, |
| 48 | + fields: { 'invoice.account': { readable: true, editable: true } }, |
| 49 | +} as unknown as PermissionSet; |
| 50 | + |
| 51 | +const SCHEMAS: Record<string, unknown> = { |
| 52 | + invoice: { |
| 53 | + name: 'invoice', |
| 54 | + fields: { |
| 55 | + title: { type: 'text', label: 'Title' }, |
| 56 | + account: { type: 'lookup', label: 'Account', reference: 'crm_account' }, |
| 57 | + secret: { type: 'text', label: 'Secret' }, |
| 58 | + margin: { type: 'number', label: 'Margin', requiredPermissions: ['view_margin'] }, |
| 59 | + }, |
| 60 | + }, |
| 61 | +}; |
| 62 | +/** The field universe the plugin resolves: the schema's fields plus `id`. */ |
| 63 | +const FIELDS = ['id', 'title', 'account', 'secret', 'margin']; |
| 64 | +const PAYLOAD_VALUE: Record<string, unknown> = { id: 'inv_1', title: 'x', account: 'acc_1', secret: 's', margin: 1 }; |
| 65 | + |
| 66 | +const WRITER_CTX = { userId: 'u_writer', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; |
| 67 | +const LIVE_DELEGATOR = 'u_boss'; |
| 68 | +const AGENT_CTX = { userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_writer'], posture: 'MEMBER' }; |
| 69 | +const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } }; |
| 70 | + |
| 71 | +async function boot(sets: PermissionSet[], opts: { noBaseline?: boolean } = {}) { |
| 72 | + const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = []; |
| 73 | + const services: Record<string, unknown> = { |
| 74 | + manifest: { register: vi.fn() }, |
| 75 | + objectql: { |
| 76 | + registerMiddleware: (mw: any) => middlewares.push(mw), |
| 77 | + getSchema: (name: string) => SCHEMAS[name], |
| 78 | + findOne: vi.fn(async (_object: string, query: any) => |
| 79 | + (query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } : null)), |
| 80 | + }, |
| 81 | + metadata: { |
| 82 | + get: async (_type: string, name: string) => SCHEMAS[name], |
| 83 | + list: async () => sets, |
| 84 | + }, |
| 85 | + }; |
| 86 | + const registerService = vi.fn(); |
| 87 | + const ctx: Record<string, unknown> = { |
| 88 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 89 | + registerService, |
| 90 | + getService: (name: string) => { |
| 91 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 92 | + return services[name]; |
| 93 | + }, |
| 94 | + }; |
| 95 | + const plugin = new SecurityPlugin( |
| 96 | + opts.noBaseline |
| 97 | + ? { defaultPermissionSets: [], fallbackPermissionSet: null } |
| 98 | + : { fallbackPermissionSet: 'member_default' }, |
| 99 | + ); |
| 100 | + await plugin.init(ctx as any); |
| 101 | + await plugin.start(ctx as any); |
| 102 | + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); |
| 103 | + return { plugin, middleware: middlewares[0], registerService }; |
| 104 | +} |
| 105 | + |
| 106 | +async function middlewareAdmits( |
| 107 | + middleware: (opCtx: any, next: () => Promise<void>) => Promise<void>, |
| 108 | + operation: 'insert' | 'update', |
| 109 | + context: Record<string, unknown>, |
| 110 | + data: Record<string, unknown>, |
| 111 | +): Promise<boolean> { |
| 112 | + try { |
| 113 | + await middleware( |
| 114 | + { object: 'invoice', operation, context: { ...context }, options: {}, ast: { where: {} }, data }, |
| 115 | + async () => {}, |
| 116 | + ); |
| 117 | + return true; |
| 118 | + } catch { |
| 119 | + return false; |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +describe('getWritableFields agrees with the middleware\'s write gate, field for field', () => { |
| 124 | + const CASES: Array<{ label: string; sets: PermissionSet[]; context: Record<string, unknown>; writable: string[] }> = [ |
| 125 | + { label: 'a field read but not editable, and one neither', sets: [LOCKED_SET], context: WRITER_CTX, writable: ['id', 'title'] }, |
| 126 | + { label: 'no field rules', sets: [OPEN_SET], context: WRITER_CTX, writable: ['id', 'title', 'account', 'secret'] }, |
| 127 | + { label: 'the field capability held', sets: [CAPABLE_SET], context: WRITER_CTX, writable: FIELDS }, |
| 128 | + { label: 'a delegated agent whose delegator may not edit the field', sets: [AGENT_SET, LOCKED_SET], context: DELEGATED_AGENT_CTX, writable: ['id', 'title'] }, |
| 129 | + { label: 'the same agent acting for nobody', sets: [AGENT_SET, LOCKED_SET], context: AGENT_CTX, writable: ['id', 'title', 'account'] }, |
| 130 | + ]; |
| 131 | + |
| 132 | + for (const c of CASES) { |
| 133 | + it(c.label, async () => { |
| 134 | + const { plugin, middleware } = await boot(c.sets); |
| 135 | + const writable = await plugin.getWritableFields('invoice', c.context); |
| 136 | + // The expected list keeps each case honest about what it exercises; the |
| 137 | + // equivalence below is the pin. |
| 138 | + expect(writable).toEqual(c.writable); |
| 139 | + for (const operation of ['insert', 'update'] as const) { |
| 140 | + for (const field of FIELDS) { |
| 141 | + const admitted = await middlewareAdmits(middleware, operation, c.context, { [field]: PAYLOAD_VALUE[field] }); |
| 142 | + expect(admitted, `${operation} naming ${field}`).toBe(writable!.includes(field)); |
| 143 | + } |
| 144 | + } |
| 145 | + }); |
| 146 | + } |
| 147 | +}); |
| 148 | + |
| 149 | +describe('getWritableFields — the answers the contract names', () => { |
| 150 | + it('a field the caller may read but not edit is readable and NOT writable', async () => { |
| 151 | + const { plugin } = await boot([LOCKED_SET]); |
| 152 | + expect(await plugin.getReadableFields('invoice', WRITER_CTX)).toContain('account'); |
| 153 | + expect(await plugin.getWritableFields('invoice', WRITER_CTX)).not.toContain('account'); |
| 154 | + }); |
| 155 | + |
| 156 | + it('a system context bypasses: the full field set', async () => { |
| 157 | + const { plugin } = await boot([LOCKED_SET]); |
| 158 | + expect(await plugin.getWritableFields('invoice', { isSystem: true })).toEqual(FIELDS); |
| 159 | + }); |
| 160 | + |
| 161 | + it('no permission sets resolved: the full field set, as the middleware skips its write gate', async () => { |
| 162 | + const { plugin } = await boot([], { noBaseline: true }); |
| 163 | + expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS); |
| 164 | + }); |
| 165 | + |
| 166 | + it('an unresolvable object is no answer (undefined), not an empty one', async () => { |
| 167 | + const { plugin } = await boot([LOCKED_SET]); |
| 168 | + expect(await plugin.getWritableFields('no_such_object', WRITER_CTX)).toBeUndefined(); |
| 169 | + }); |
| 170 | + |
| 171 | + it('a delegator that does not exist fails closed: []', async () => { |
| 172 | + const { plugin } = await boot([AGENT_SET, LOCKED_SET]); |
| 173 | + expect(await plugin.getWritableFields('invoice', { ...AGENT_CTX, onBehalfOf: { userId: 'u_ghost' } })).toEqual([]); |
| 174 | + }); |
| 175 | + |
| 176 | + it('is exposed on the registered "security" service', async () => { |
| 177 | + const { registerService } = await boot([LOCKED_SET]); |
| 178 | + const svc = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; |
| 179 | + expect(typeof svc?.getWritableFields).toBe('function'); |
| 180 | + expect(await svc.getWritableFields('invoice', WRITER_CTX)).toEqual(['id', 'title']); |
| 181 | + }); |
| 182 | +}); |
0 commit comments