|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The package door's save merges onto the PENDING DRAFT when one exists, not |
| 5 | + * onto the published body (objectstack#26837). |
| 6 | + * |
| 7 | + * ## The defect these pins reach |
| 8 | + * |
| 9 | + * `doSave`'s package-door arm re-reads the set and merges this package's slice |
| 10 | + * onto that read with `mergePermissionSlice`, which takes only the facets the |
| 11 | + * editor can author from the edited body and everything else from the base. |
| 12 | + * The base was `client.layered(...).effective` — the PUBLISHED body — while the |
| 13 | + * save itself writes the package DRAFT. So a draft another door wrote (the |
| 14 | + * card's reproduction: `PUT …?mode=draft&package=P` with `description` and |
| 15 | + * `packageId`) lost those keys on the next Studio edit: the load displayed the |
| 16 | + * draft, one checkbox autosaved, and the PUT carried the published values (or |
| 17 | + * none) in their place. 200, no error. |
| 18 | + * |
| 19 | + * ## Why the pin drives the REAL editor |
| 20 | + * |
| 21 | + * `mergePermissionSlice` is correct for any base it is handed — its own pins |
| 22 | + * (`permission-slice.authoredKeys.test.ts`, `permission-slice.facetDrift.test.ts`) |
| 23 | + * call it directly and could never see this. The defect is the CHOICE of base, |
| 24 | + * which only `doSave` makes, so these pins run the real page, the real load and |
| 25 | + * the real autosave (objectui#11787: the package door has no Save button). |
| 26 | + * |
| 27 | + * ## The arms, and what each one's absence is read against |
| 28 | + * |
| 29 | + * 1. A pending draft whose non-authored keys differ from the published body: |
| 30 | + * every one of them is on the wire byte-for-byte, out-of-scope rows come |
| 31 | + * from the draft, and the edited row lands. |
| 32 | + * 2. No pending draft: the base is still the published `effective`, so the |
| 33 | + * published body's own keys and other packages' rows are on the wire — |
| 34 | + * the control that the draft arm is not "the edited body, wholesale". |
| 35 | + * 3. The save-time DRAFT read rejects: the save is refused on the same |
| 36 | + * channel as objectui#9420's layered refusal, `client.save` is never |
| 37 | + * called, and the control `getDraftCalls === 2` proves the read ran. |
| 38 | + */ |
| 39 | + |
| 40 | +import '@testing-library/jest-dom/vitest'; |
| 41 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 42 | +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 43 | +import { MemoryRouter } from 'react-router-dom'; |
| 44 | + |
| 45 | +/** |
| 46 | + * The published record. `a_account` is the door's own package; `b_order` and |
| 47 | + * `b_order.total` are another package's grants. |
| 48 | + */ |
| 49 | +const PUBLISHED = { |
| 50 | + name: 'sales_perms', |
| 51 | + label: 'Sales', |
| 52 | + description: 'Published description', |
| 53 | + objects: { |
| 54 | + a_account: { allowRead: true, allowCreate: true }, |
| 55 | + b_order: { allowRead: true }, |
| 56 | + }, |
| 57 | + fields: { 'b_order.total': { readable: true, editable: false } }, |
| 58 | +}; |
| 59 | + |
| 60 | +/** |
| 61 | + * The pending package draft another door wrote — every key the editor cannot |
| 62 | + * author differs from {@link PUBLISHED}, and so does an out-of-scope row, so a |
| 63 | + * value taken from the wrong base cannot pass for the right one. |
| 64 | + */ |
| 65 | +const DRAFT = { |
| 66 | + name: 'sales_perms', |
| 67 | + label: 'Sales', |
| 68 | + description: 'Set through the API', |
| 69 | + packageId: 'app.a', |
| 70 | + objects: { |
| 71 | + a_account: { allowRead: true, allowCreate: true }, |
| 72 | + b_order: { allowRead: true, allowEdit: true, viewAllRecords: true }, |
| 73 | + }, |
| 74 | + fields: { 'b_order.total': { readable: true, editable: true } }, |
| 75 | +}; |
| 76 | + |
| 77 | +/** The facets `EDITOR_AUTHORED_KEYS` does not name — the ones the base supplies. */ |
| 78 | +const NON_AUTHORED = ['description', 'packageId'] as const; |
| 79 | + |
| 80 | +/** Distinctive slice of the refusal the author sees on the page's error strip. */ |
| 81 | +const REFUSAL = /could not be re-read/; |
| 82 | + |
| 83 | +interface Server { |
| 84 | + saved: Array<Record<string, unknown>>; |
| 85 | + savedOpts: Array<Record<string, unknown> | undefined>; |
| 86 | + /** `getDraft` calls seen so far — #1 is the load, #2 is `doSave`'s re-read. */ |
| 87 | + getDraftCalls: number; |
| 88 | +} |
| 89 | + |
| 90 | +function freshServer(): Server { |
| 91 | + return { saved: [], savedOpts: [], getDraftCalls: 0 }; |
| 92 | +} |
| 93 | + |
| 94 | +function makeClient(server: Server, opts: { draft: Record<string, unknown> | null; rejectSaveTimeDraft?: boolean }) { |
| 95 | + return { |
| 96 | + layered: async () => ({ effective: PUBLISHED, code: null, overlay: null, overlayScope: null }), |
| 97 | + getDraft: async () => { |
| 98 | + server.getDraftCalls += 1; |
| 99 | + if (opts.rejectSaveTimeDraft && server.getDraftCalls > 1) throw new Error('draft read failed'); |
| 100 | + return opts.draft ? { type: 'permission', name: 'sales_perms', item: opts.draft, version: 'v1' } : null; |
| 101 | + }, |
| 102 | + // The package declares `a_account` only. |
| 103 | + list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []), |
| 104 | + get: async () => null, |
| 105 | + save: async ( |
| 106 | + _type: string, |
| 107 | + _name: string, |
| 108 | + payload: Record<string, unknown>, |
| 109 | + saveOpts?: Record<string, unknown>, |
| 110 | + ) => { |
| 111 | + server.saved.push(payload); |
| 112 | + server.savedOpts.push(saveOpts); |
| 113 | + return payload; |
| 114 | + }, |
| 115 | + } as any; |
| 116 | +} |
| 117 | + |
| 118 | +let clientImpl: any; |
| 119 | + |
| 120 | +vi.mock('./useMetadata', () => ({ |
| 121 | + useMetadataClient: () => clientImpl, |
| 122 | + useMetadataTypes: () => ({ |
| 123 | + loading: false, |
| 124 | + error: null, |
| 125 | + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }], |
| 126 | + }), |
| 127 | +})); |
| 128 | + |
| 129 | +vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null })); |
| 130 | + |
| 131 | +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; |
| 132 | + |
| 133 | +afterEach(cleanup); |
| 134 | + |
| 135 | +function renderPackageDoor() { |
| 136 | + return render( |
| 137 | + <MemoryRouter> |
| 138 | + <PermissionMatrixEditPage type="permission" name="sales_perms" packageId="app.a" /> |
| 139 | + </MemoryRouter>, |
| 140 | + ); |
| 141 | +} |
| 142 | + |
| 143 | +/** One ordinary edit: revoke every grant on the door's own `a_account` row. */ |
| 144 | +function editAccountRow() { |
| 145 | + const row = screen.getByText('a_account').closest('tr')!; |
| 146 | + fireEvent.click(within(row).getByRole('button', { name: 'None' })); |
| 147 | +} |
| 148 | + |
| 149 | +/** The package door autosaves after the shared pause (1.5s). */ |
| 150 | +const AUTOSAVE = { timeout: 4000 }; |
| 151 | + |
| 152 | +describe('PermissionMatrixEditPage — the PACKAGE door saves onto the pending draft (objectstack#26837)', () => { |
| 153 | + it('keeps every key the editor does not author from the pending draft, byte-for-byte', async () => { |
| 154 | + const server = freshServer(); |
| 155 | + clientImpl = makeClient(server, { draft: DRAFT }); |
| 156 | + renderPackageDoor(); |
| 157 | + await screen.findByText('a_account'); |
| 158 | + |
| 159 | + editAccountRow(); |
| 160 | + |
| 161 | + await waitFor(() => expect(server.saved).toHaveLength(1), AUTOSAVE); |
| 162 | + const body = server.saved[0] as any; |
| 163 | + |
| 164 | + // The edited row lands… |
| 165 | + expect(body.objects.a_account).toEqual({}); |
| 166 | + // …and the draft's non-authored keys survive, not the published ones. |
| 167 | + for (const key of NON_AUTHORED) expect(body[key]).toEqual(DRAFT[key]); |
| 168 | + // Out-of-scope rows come from the draft too: it is what this save replaces. |
| 169 | + expect(body.objects.b_order).toEqual(DRAFT.objects.b_order); |
| 170 | + expect(body.fields).toEqual(DRAFT.fields); |
| 171 | + // CONTROL: the save-time draft read ran (#1 is the load). |
| 172 | + expect(server.getDraftCalls).toBe(2); |
| 173 | + expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a' }); |
| 174 | + expect(screen.queryByText(REFUSAL)).toBeNull(); |
| 175 | + }); |
| 176 | + |
| 177 | + it('CONTROL — with no pending draft the base is still the published body', async () => { |
| 178 | + const server = freshServer(); |
| 179 | + clientImpl = makeClient(server, { draft: null }); |
| 180 | + renderPackageDoor(); |
| 181 | + await screen.findByText('a_account'); |
| 182 | + |
| 183 | + editAccountRow(); |
| 184 | + |
| 185 | + await waitFor(() => expect(server.saved).toHaveLength(1), AUTOSAVE); |
| 186 | + const body = server.saved[0] as any; |
| 187 | + |
| 188 | + expect(body.objects.a_account).toEqual({}); |
| 189 | + expect(body.description).toBe(PUBLISHED.description); |
| 190 | + expect('packageId' in body).toBe(false); |
| 191 | + expect(body.objects.b_order).toEqual(PUBLISHED.objects.b_order); |
| 192 | + expect(body.fields).toEqual(PUBLISHED.fields); |
| 193 | + expect(screen.queryByText(REFUSAL)).toBeNull(); |
| 194 | + }); |
| 195 | + |
| 196 | + it('REFUSES the save when the save-time draft read rejects, rather than fall back to the published body', async () => { |
| 197 | + const server = freshServer(); |
| 198 | + clientImpl = makeClient(server, { draft: DRAFT, rejectSaveTimeDraft: true }); |
| 199 | + renderPackageDoor(); |
| 200 | + await screen.findByText('a_account'); |
| 201 | + |
| 202 | + editAccountRow(); |
| 203 | + |
| 204 | + // Settle on either arm — the refusal on screen, or a PUT landing. |
| 205 | + await waitFor( |
| 206 | + () => expect(server.saved.length > 0 || screen.queryByText(REFUSAL) !== null).toBe(true), |
| 207 | + AUTOSAVE, |
| 208 | + ); |
| 209 | + |
| 210 | + // CONTROL: the save-time draft read really ran and really rejected. |
| 211 | + expect(server.getDraftCalls).toBe(2); |
| 212 | + expect(server.saved).toEqual([]); |
| 213 | + expect(server.savedOpts).toEqual([]); |
| 214 | + // The same error strip objectui#9420's layered refusal renders into. |
| 215 | + expect(screen.getByText(REFUSAL)).toBeInTheDocument(); |
| 216 | + expect(screen.queryByTestId('perm-autosaving')).toBeNull(); |
| 217 | + }); |
| 218 | +}); |
0 commit comments