Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ function makeClient() {
...(provenance !== undefined ? { provenance } : {}),
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [],
get: async (type: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,15 @@ function deferred() {
}

/** The slice of the metadata client the editor calls here. */
type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
type FakeClient = Record<string, (...args: never[]) => unknown>;

function makeClient(server: Server, opts: { holdFirstSave?: Promise<void> } = {}): FakeClient {
return {
layered: async () => LAYERED,
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async () => null,
save: async (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ function makeClient(set: Record<string, unknown>) {
...(codeLayer ? { provenance: 'package' } : {}),
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function makeClient(server: FakeServer) {
return {
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object' ? server.objectNames.map((name) => ({ item: { name } })) : [],
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ function makeClient() {
overlayScope: null,
})),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [],
get: async (type: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const SET = {
const client = {
layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async () => null,
save: async (_type: string, _name: string, payload: unknown) => payload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ function makeClient(server: Server) {
overlayScope: null,
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => {
if (type === 'object') return [{ item: { name: 'a_account' } }];
return [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26840 — the package door's Access matrix lists the package's
* DRAFT objects too, so a new package can be secured before its first
* publish.
*
* The fake client behaves like the server's two reads of one package:
*
* • the published door (`list` / `get`) knows only the published object;
* • the draft-overlaid door (`withPreviewDrafts(true)`, `?preview=draft`)
* also serves each pending draft, with its `_draft` mark.
*
* The draft-only row carries the object picker's draft mark, its fields come
* from its draft, and its grants write into the set's draft. The environment
* door keeps the published list and its rows carry no mark.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

interface Server {
set: Record<string, unknown>;
saved: Array<Record<string, unknown>>;
savedOpts: Array<Record<string, unknown> | undefined>;
listCalls: Array<{ preview: boolean; opts?: Record<string, unknown> }>;
}

const PUBLISHED = { name: 'a_account', label: 'Account', fields: { name: { label: 'Name' } } };
const DRAFT_ONLY = {
name: 'a_invoice',
label: 'Invoice',
fields: { amount: { label: 'Amount' } },
_draft: true,
_packageId: 'app.a',
};

function makeClient(server: Server, preview = false): any {
return {
withPreviewDrafts: (on: boolean) => makeClient(server, on),
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string, opts?: Record<string, unknown>) => {
if (type !== 'object') return [];
server.listCalls.push({ preview, opts });
return preview ? [PUBLISHED, DRAFT_ONLY] : [PUBLISHED];
},
get: async (type: string, name: string) => {
if (type !== 'object') return null;
if (name === PUBLISHED.name) return PUBLISHED;
// A draft-only object has no published definition.
return preview && name === DRAFT_ONLY.name ? DRAFT_ONLY : null;
},
save: async (_type: string, _name: string, payload: Record<string, unknown>, opts?: Record<string, unknown>) => {
server.saved.push(payload);
server.savedOpts.push(opts);
server.set = payload;
return payload;
},
};
}

let clientImpl: any;

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
}),
}));

vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

afterEach(cleanup);

function freshServer(): Server {
return {
set: { name: 'sales_perms', label: 'Sales', objects: {}, fields: {} },
saved: [],
savedOpts: [],
listCalls: [],
};
}

function renderMatrix(opts: { packageId?: string; studio?: boolean } = {}) {
return render(
<MemoryRouter>
<PermissionMatrixEditPage
type="permission"
name="sales_perms"
packageId={opts.packageId}
studio={opts.studio ? { tab: 'objects', onTabChange: () => {} } : undefined}
/>
</MemoryRouter>,
);
}

const rowOf = (text: string) => screen.getByText(text).closest('tr')!;
const draftMarks = (row: HTMLElement) => row.querySelectorAll('[data-draft-marker]');

describe('PermissionMatrixEditPage — draft objects at the package door (objectstack#26840)', () => {
it('lists a draft-only object badged as a draft, and a published one without the badge', async () => {
clientImpl = makeClient(freshServer());
renderMatrix({ packageId: 'app.a' });

await screen.findByText('Invoice');
const invoiceMarks = draftMarks(rowOf('Invoice'));
expect(invoiceMarks).toHaveLength(1);
expect(invoiceMarks[0]).toHaveTextContent('(draft)');
expect(draftMarks(rowOf('Account'))).toHaveLength(0);
});

it('badges the draft-only row in the Studio layout too', async () => {
clientImpl = makeClient(freshServer());
renderMatrix({ packageId: 'app.a', studio: true });

await screen.findByText('Invoice');
expect(draftMarks(rowOf('Invoice'))).toHaveLength(1);
expect(draftMarks(rowOf('Account'))).toHaveLength(0);
});

it("writes the draft-only object's grants into the set's draft", async () => {
const server = freshServer();
clientImpl = makeClient(server);
renderMatrix({ packageId: 'app.a' });

await screen.findByText('Invoice');
fireEvent.click(within(rowOf('Invoice')).getByLabelText('a_invoice Read'));

await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 });
expect((server.saved[0] as any).objects.a_invoice).toMatchObject({ allowRead: true });
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a' });
});

it("reads a draft-only object's fields from its draft", async () => {
clientImpl = makeClient(freshServer());
renderMatrix({ packageId: 'app.a' });

fireEvent.click(await screen.findByRole('button', { name: /a_invoice/ }));
await screen.findByLabelText('a_invoice.amount readable');
});
});

describe('PermissionMatrixEditPage — environment door (objectstack#26840)', () => {
it('keeps the published list: no preview read, no draft rows, no badge', async () => {
const server = freshServer();
clientImpl = makeClient(server);
renderMatrix();

await screen.findByText('Account');
expect(screen.queryByText('Invoice')).not.toBeInTheDocument();
expect(document.querySelectorAll('[data-draft-marker]')).toHaveLength(0);
expect(server.listCalls.length).toBeGreaterThan(0);
expect(server.listCalls.every((c) => !c.preview)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ function makeClient() {
overlayScope: null,
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ function makeClient() {
overlayScope: null,
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) =>
type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ interface SavedSet {
fields?: Record<string, FieldEntry>;
}

type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
type FakeClient = Record<string, (...args: never[]) => unknown>;

let clientImpl: FakeClient;
let saved: SavedSet[] = [];
Expand Down Expand Up @@ -68,6 +69,8 @@ function makeClient(objectPerm: Record<string, boolean>, storedFields: Record<st
overlayScope: null,
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_project' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null),
save: async (_type: string, _name: string, payload: Record<string, unknown>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ interface SavedSet {
}

/** The slice of the metadata client the editor calls here. */
type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
type FakeClient = Record<string, (...args: never[]) => unknown>;

let clientImpl: FakeClient;
let saved: SavedSet[] = [];
Expand Down Expand Up @@ -65,6 +66,8 @@ function makeClient(storedFields: Record<string, FieldEntry>): FakeClient {
overlayScope: null,
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null),
save: async (_type: string, _name: string, payload: Record<string, unknown>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ interface Server {
savedOpts: Array<Record<string, unknown> | undefined>;
}

type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
type FakeClient = Record<string, (...args: never[]) => unknown>;

function makeClient(server: Server): FakeClient {
return {
Expand All @@ -44,6 +45,8 @@ function makeClient(server: Server): FakeClient {
server.draftReads.push(args);
return server.draft ? { type: 'permission', name: 'sales_perms', item: server.draft.body, version: server.draft.version } : null;
},
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : type === 'permission' ? [PUBLISHED] : []),
get: async () => null,
save: async (_type: string, _name: string, payload: Record<string, unknown>, saveOpts?: Record<string, unknown>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ function makeClient(server: Server) {
return {
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => (server.draft ? { item: server.draft } : null),
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => {
if (type === 'object') return server.packageObjects.map((o) => ({ item: o }));
return [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ function makeClient(
getDraft: async () => (opts.draft ? { type: 'permission', name: 'sales_perms', item: opts.draft } : null),
// The package declares `a_account` only — which is what narrows the draft
// and is therefore what makes `payload` an unusable merge base.
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async () => null,
save: async (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ function makeClient(server: Server) {
// does for a published set, so the load reads its layers exactly as before
// (call #1 above). A set the list does not hold is not asked while its
// draft is in hand.
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object'
? [{ item: { name: 'a_account' } }, { item: { name: 'a_contact' } }]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ function makeClient() {
overlayScope: null,
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [],
get: async (type: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function makeClient() {
overlayScope: null,
}),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [],
get: async (type: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ function makeClient(server: FakeServer) {
return {
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object' ? server.objectNames.map((name) => ({ item: { name } })) : [],
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@ interface Server {
judge: (body: Record<string, unknown>) => Refusal | null;
}

type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
type FakeClient = Record<string, (...args: never[]) => unknown>;

function makeClient(server: Server): FakeClient {
return {
layered: async () => LAYERED,
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async () => null,
save: async (_type: string, _name: string, payload: Record<string, unknown>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const wire = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as

/** The reads both hosts make, answered in memory; the save is the real client's. */
const client = {
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string) => {
if (type === 'permission') return [{ name: SET.name, label: SET.label }];
if (type === 'object') return [{ name: 'a_account', label: 'Account' }];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ function makeClient(server: Server) {
// ADR-0086 P2: the package door reads any pending draft first. This fixture
// models the published baseline only (no pending draft) → null.
getDraft: async () => null,
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
withPreviewDrafts() { return this; },
list: async (type: string, opts?: { packageId?: string }) => {
if (type === 'object') {
// The server scopes to the package — the panel must not see anything
Expand Down
Loading
Loading