|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#29674 (ADR-0146 D3) — the package door's object scope. |
| 5 | + * |
| 6 | + * A paired `<base>.custom` package holds the permission sets FOR its base, and |
| 7 | + * declares none of the base's objects, so a set saved there is scoped to the |
| 8 | + * base's objects as well as the pair's own (`permissionObjectScopePackages`). |
| 9 | + * Every other package is scoped to itself, as before (ADR-0086 P0). |
| 10 | + * |
| 11 | + * The matrix-level pins sit beside the Studio composition |
| 12 | + * (`StudioDesignSurface.accessPairCopy-29674.test.tsx`), on a fake client that |
| 13 | + * answers each package's object list. |
| 14 | + */ |
| 15 | + |
| 16 | +import '@testing-library/jest-dom/vitest'; |
| 17 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 18 | +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 19 | +import { MemoryRouter } from 'react-router-dom'; |
| 20 | + |
| 21 | +import { permissionObjectScopePackages } from './permission-slice'; |
| 22 | + |
| 23 | +interface Server { |
| 24 | + set: Record<string, unknown>; |
| 25 | + saved: Array<Record<string, unknown>>; |
| 26 | + savedOpts: Array<Record<string, unknown> | undefined>; |
| 27 | + listed: Array<string | undefined>; |
| 28 | +} |
| 29 | + |
| 30 | +/** Objects by the package that declares them. */ |
| 31 | +const OBJECTS: Record<string, Array<Record<string, unknown>>> = { |
| 32 | + 'com.acme.crm': [ |
| 33 | + { name: 'crm_account', label: 'Account' }, |
| 34 | + { name: 'crm_deal', label: 'Deal' }, |
| 35 | + ], |
| 36 | + 'com.acme.crm.custom': [{ name: 'crm_custom_survey', label: 'Survey' }], |
| 37 | + 'app.a': [{ name: 'a_account', label: 'A account' }], |
| 38 | + 'app.b': [{ name: 'b_ledger', label: 'B ledger' }], |
| 39 | +}; |
| 40 | + |
| 41 | +function makeClient(server: Server): any { |
| 42 | + const client: any = { |
| 43 | + withPreviewDrafts: () => client, |
| 44 | + layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }), |
| 45 | + getDraft: async () => null, |
| 46 | + list: async (type: string, opts?: { packageId?: string }) => { |
| 47 | + if (type !== 'object') return []; |
| 48 | + server.listed.push(opts?.packageId); |
| 49 | + return OBJECTS[opts?.packageId ?? ''] ?? []; |
| 50 | + }, |
| 51 | + get: async () => null, |
| 52 | + save: async (_type: string, _name: string, payload: Record<string, unknown>, opts?: Record<string, unknown>) => { |
| 53 | + server.saved.push(payload); |
| 54 | + server.savedOpts.push(opts); |
| 55 | + server.set = payload; |
| 56 | + return payload; |
| 57 | + }, |
| 58 | + }; |
| 59 | + return client; |
| 60 | +} |
| 61 | + |
| 62 | +let clientImpl: any; |
| 63 | + |
| 64 | +vi.mock('./useMetadata', () => ({ |
| 65 | + useMetadataClient: () => clientImpl, |
| 66 | + useMetadataTypes: () => ({ |
| 67 | + loading: false, |
| 68 | + error: null, |
| 69 | + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }], |
| 70 | + }), |
| 71 | +})); |
| 72 | + |
| 73 | +vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null })); |
| 74 | + |
| 75 | +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; |
| 76 | +import { t } from './i18n'; |
| 77 | + |
| 78 | +afterEach(cleanup); |
| 79 | + |
| 80 | +function serverWith(set: Record<string, unknown>): Server { |
| 81 | + return { set, saved: [], savedOpts: [], listed: [] }; |
| 82 | +} |
| 83 | + |
| 84 | +function renderMatrix(packageId: string) { |
| 85 | + return render( |
| 86 | + <MemoryRouter> |
| 87 | + <PermissionMatrixEditPage type="permission" name="the_set" packageId={packageId} /> |
| 88 | + </MemoryRouter>, |
| 89 | + ); |
| 90 | +} |
| 91 | + |
| 92 | +const rowOf = (text: string) => screen.getByText(text).closest('tr')!; |
| 93 | +const readBox = (objectName: string) => screen.getByLabelText(`${objectName} ${t('perm.action.read', 'en')}`); |
| 94 | + |
| 95 | +describe('permissionObjectScopePackages (objectstack#29674)', () => { |
| 96 | + it('scopes a pair to itself and its base, the pair first', () => { |
| 97 | + expect(permissionObjectScopePackages('com.acme.crm.custom')).toEqual(['com.acme.crm.custom', 'com.acme.crm']); |
| 98 | + }); |
| 99 | + |
| 100 | + it('scopes every other package to itself', () => { |
| 101 | + expect(permissionObjectScopePackages('com.acme.crm')).toEqual(['com.acme.crm']); |
| 102 | + expect(permissionObjectScopePackages('app.a')).toEqual(['app.a']); |
| 103 | + // A pair of a pair has no base by the spec's pairing rule. |
| 104 | + expect(permissionObjectScopePackages('com.acme.crm.custom.custom')).toEqual(['com.acme.crm.custom.custom']); |
| 105 | + }); |
| 106 | +}); |
| 107 | + |
| 108 | +describe('PermissionMatrixEditPage — a pair’s set lists its base’s objects (objectstack#29674)', () => { |
| 109 | + const PAIR_SET = { |
| 110 | + name: 'the_set', |
| 111 | + label: 'Copy', |
| 112 | + objects: { crm_account: { allowRead: true }, crm_deal: { allowRead: true } }, |
| 113 | + fields: { 'crm_deal.amount': { readable: true, editable: false } }, |
| 114 | + }; |
| 115 | + |
| 116 | + it('lists the pair’s and the base’s objects, each once, and draws the grants it holds on the base’s', async () => { |
| 117 | + const server = serverWith(structuredClone(PAIR_SET)); |
| 118 | + clientImpl = makeClient(server); |
| 119 | + renderMatrix('com.acme.crm.custom'); |
| 120 | + |
| 121 | + await screen.findByText('Account'); |
| 122 | + expect(screen.getByText('Deal')).toBeInTheDocument(); |
| 123 | + expect(screen.getByText('Survey')).toBeInTheDocument(); |
| 124 | + expect(readBox('crm_account')).toBeChecked(); |
| 125 | + expect(readBox('crm_deal')).toBeChecked(); |
| 126 | + expect(readBox('crm_custom_survey')).not.toBeChecked(); |
| 127 | + expect(new Set(server.listed)).toEqual(new Set(['com.acme.crm.custom', 'com.acme.crm'])); |
| 128 | + }); |
| 129 | + |
| 130 | + it('a grant on a base object saves into the pair’s draft, with every row it held', async () => { |
| 131 | + const server = serverWith(structuredClone(PAIR_SET)); |
| 132 | + clientImpl = makeClient(server); |
| 133 | + renderMatrix('com.acme.crm.custom'); |
| 134 | + |
| 135 | + await screen.findByText('Account'); |
| 136 | + fireEvent.click(within(rowOf('Account')).getByLabelText(`crm_account ${t('perm.action.edit', 'en')}`)); |
| 137 | + |
| 138 | + await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 }); |
| 139 | + expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'com.acme.crm.custom' }); |
| 140 | + expect(server.saved[0]!.objects).toStrictEqual({ |
| 141 | + crm_account: { allowRead: true, allowEdit: true }, |
| 142 | + crm_deal: { allowRead: true }, |
| 143 | + }); |
| 144 | + expect(server.saved[0]!.fields).toStrictEqual(PAIR_SET.fields); |
| 145 | + }); |
| 146 | +}); |
| 147 | + |
| 148 | +describe('CONTROL: an ordinary package’s set is scoped to its own objects (objectstack#29674)', () => { |
| 149 | + const SET = { |
| 150 | + name: 'the_set', |
| 151 | + label: 'A set', |
| 152 | + objects: { a_account: { allowRead: true }, b_ledger: { allowRead: true, allowEdit: true } }, |
| 153 | + fields: { 'b_ledger.total': { readable: true, editable: false } }, |
| 154 | + }; |
| 155 | + |
| 156 | + it('lists only its package’s objects and keeps another package’s rows byte-identical on save', async () => { |
| 157 | + const server = serverWith(structuredClone(SET)); |
| 158 | + clientImpl = makeClient(server); |
| 159 | + renderMatrix('app.a'); |
| 160 | + |
| 161 | + await screen.findByText('A account'); |
| 162 | + expect(screen.queryByText('B ledger')).toBeNull(); |
| 163 | + expect(server.listed.every((p) => p === 'app.a')).toBe(true); |
| 164 | + |
| 165 | + fireEvent.click(within(rowOf('A account')).getByLabelText(`a_account ${t('perm.action.edit', 'en')}`)); |
| 166 | + await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 }); |
| 167 | + expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a' }); |
| 168 | + expect(server.saved[0]!.objects).toStrictEqual({ |
| 169 | + a_account: { allowRead: true, allowEdit: true }, |
| 170 | + b_ledger: SET.objects.b_ledger, |
| 171 | + }); |
| 172 | + expect(server.saved[0]!.fields).toStrictEqual(SET.fields); |
| 173 | + }); |
| 174 | +}); |
0 commit comments