Skip to content

Commit 1f47bfd

Browse files
committed
wip: tests and changeset for the served verdict at a field's path
Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2a349a9 commit 1f47bfd

4 files changed

Lines changed: 434 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): a flow edge condition's `expression-invalid` finding carries the slot's key path (`flows[0].edges[1].condition`) beside its `where` text
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An `expression-invalid` finding on a top-level flow edge's `condition` used to be located by its `where` prose alone (`flow 'f' · edge 'e2' (decide→end) condition`), which a reader keyed on paths could not address. Its `path` is now the key path in the spelling the other rules' findings use, so a draft's `_diagnostics` reports it item-relative (`edges.1.condition`) and Studio's edge condition field shows the refusal under itself.
11+
12+
- `where`, the message and the verdict are unchanged: nothing is accepted or refused differently.
13+
- The edge is indexed in the list as authored (a `null` member before it does not renumber it).
14+
- A nested region's edge, and a flow in a map-form `flows` collection, keep `where` as their `path`, as before.
15+
- `ExprIssue` gains the optional `path`.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26858 — the permission-set editor hands its RLS clause editors
5+
* the served verdict on the set on screen.
6+
*
7+
* The set's page is its own: it holds the draft read itself and strips the
8+
* read decorations off the body, so the verdict the draft read serves (since
9+
* objectstack#26707, what its publish will refuse) never reached a field. The
10+
* page now keeps that verdict and provides it, and reads it again after each
11+
* package-door save, so the field follows the draft it describes.
12+
*
13+
* The fake door below serves a draft whose `_diagnostics` refuses its USING
14+
* clause the way `rls-predicate-unenforceable` does, until the clause changes.
15+
*/
16+
17+
import '@testing-library/jest-dom/vitest';
18+
import { afterEach, describe, expect, it, vi } from 'vitest';
19+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
20+
import { MemoryRouter } from 'react-router-dom';
21+
22+
const REFUSED_USING = 'deal_score > 5';
23+
const REFUSAL =
24+
"rowLevelSecurity 'hot_deals' using reads 'deal_score', a formula field the database cannot filter on";
25+
26+
const SET = {
27+
name: 'sales_perms',
28+
label: 'Sales',
29+
objects: { a_account: { allowRead: true } },
30+
fields: {},
31+
};
32+
33+
interface Server {
34+
draft: Record<string, unknown> | null;
35+
draftReads: number;
36+
}
37+
38+
/** The door's publish verdict on a stored draft: it refuses the one USING clause. */
39+
function verdictOf(body: Record<string, unknown>) {
40+
const rows = (body.rowLevelSecurity ?? []) as Array<{ using?: string }>;
41+
const errors = rows.flatMap((r, i) =>
42+
r.using === REFUSED_USING
43+
? [{ path: `rowLevelSecurity.${i}.using`, message: REFUSAL, code: 'rls-predicate-unenforceable' }]
44+
: [],
45+
);
46+
return errors.length ? { valid: false, errors } : { valid: true };
47+
}
48+
49+
type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
50+
51+
function makeClient(server: Server): FakeClient {
52+
return {
53+
layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }),
54+
getDraft: async () => {
55+
server.draftReads += 1;
56+
return server.draft
57+
? { type: 'permission', name: 'sales_perms', item: { ...server.draft, _draft: true, _diagnostics: verdictOf(server.draft) } }
58+
: null;
59+
},
60+
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
61+
get: async () => null,
62+
save: async (_type: string, _name: string, payload: Record<string, unknown>) => {
63+
server.draft = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
64+
return payload;
65+
},
66+
};
67+
}
68+
69+
let clientImpl: FakeClient;
70+
71+
vi.mock('./useMetadata', () => ({
72+
useMetadataClient: () => clientImpl,
73+
useMetadataTypes: () => ({
74+
loading: false,
75+
error: null,
76+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
77+
}),
78+
}));
79+
80+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
81+
82+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
83+
84+
afterEach(cleanup);
85+
86+
const USING_PLACEHOLDER = 'organization_id == current_user.organization_id';
87+
88+
async function openPackageDoor(server: Server) {
89+
clientImpl = makeClient(server);
90+
render(
91+
<MemoryRouter>
92+
<PermissionMatrixEditPage type="permission" name="sales_perms" packageId="app.a" />
93+
</MemoryRouter>,
94+
);
95+
await screen.findByText('a_account');
96+
fireEvent.click(screen.getByRole('button', { name: /Row-Level Security/ }));
97+
}
98+
99+
const policy = (using: string) => ({
100+
...SET,
101+
rowLevelSecurity: [{ name: 'hot_deals', object: 'a_account', operation: 'select', using, enabled: true }],
102+
});
103+
104+
describe('the permission-set editor provides the served verdict to its RLS clauses (objectstack#26858)', () => {
105+
it('a pending draft the publish refuses: the USING clause shows the server message, not "Valid CEL"', async () => {
106+
const server: Server = { draft: policy(REFUSED_USING), draftReads: 0 };
107+
await openPackageDoor(server);
108+
expect(await screen.findByText(REFUSAL)).toBeInTheDocument();
109+
// The lint itself answered: the clause parses, and only the server refuses it.
110+
await waitFor(() => expect(screen.getByPlaceholderText(USING_PLACEHOLDER)).toHaveValue(REFUSED_USING));
111+
await new Promise((r) => setTimeout(r, 600));
112+
expect(screen.queryByText('Valid CEL')).toBeNull();
113+
});
114+
115+
it('the verdict follows the draft: after an autosave of a changed clause the refusal goes', async () => {
116+
const server: Server = { draft: policy(REFUSED_USING), draftReads: 0 };
117+
await openPackageDoor(server);
118+
await screen.findByText(REFUSAL);
119+
const readsBefore = server.draftReads;
120+
121+
fireEvent.change(screen.getByPlaceholderText(USING_PLACEHOLDER), { target: { value: 'owner_id == current_user.id' } });
122+
123+
await waitFor(() => expect(server.draftReads).toBeGreaterThan(readsBefore), { timeout: 5000 });
124+
await waitFor(() => expect(screen.queryByText(REFUSAL)).toBeNull(), { timeout: 5000 });
125+
expect(await screen.findByText('Valid CEL', {}, { timeout: 3000 })).toBeInTheDocument();
126+
});
127+
128+
it('control: a draft the publish accepts reads "Valid CEL"', async () => {
129+
const server: Server = { draft: policy('owner_id == current_user.id'), draftReads: 0 };
130+
await openPackageDoor(server);
131+
expect(await screen.findByText('Valid CEL', {}, { timeout: 3000 })).toBeInTheDocument();
132+
expect(screen.queryByText(REFUSAL)).toBeNull();
133+
});
134+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26858 — the edit page provides the served verdict on the body
5+
* on screen (`DraftVerdictContext`), so a field inside it — the flow edge
6+
* condition, the CEL editors — can read the errors at its own path.
7+
*
8+
* The canvas stand-in below reads the context exactly as a field does
9+
* (`useServerErrorsAt`) at one path and at a sibling. The server double serves
10+
* a pending draft whose verdict refuses that path, the way the draft read does
11+
* since objectstack#26707.
12+
*/
13+
14+
import '@testing-library/jest-dom/vitest';
15+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
17+
import { MemoryRouter } from 'react-router-dom';
18+
19+
const REFUSAL = 'region "main" names a component this deployment does not have';
20+
21+
const server = vi.hoisted(() => ({ draft: null as Record<string, unknown> | null }));
22+
23+
const mockClient = vi.hoisted(() => ({
24+
list: vi.fn(async () => []),
25+
listDrafts: vi.fn(async () => []),
26+
get: vi.fn(async () => null),
27+
references: vi.fn(async () => []),
28+
layered: vi.fn(async () => ({
29+
code: null,
30+
overlay: server.draft,
31+
overlayScope: 'env',
32+
effective: server.draft,
33+
_diagnostics: { valid: true },
34+
editable: true,
35+
deletable: true,
36+
resettable: false,
37+
lock: 'none',
38+
})),
39+
getDraft: vi.fn(async (type: string, name: string) => {
40+
if (!server.draft) throw Object.assign(new Error('No pending draft'), { code: 'NO_DRAFT', status: 404 });
41+
return {
42+
type,
43+
name,
44+
item: {
45+
...server.draft,
46+
_draft: true,
47+
_diagnostics: { valid: false, errors: [{ path: 'regions.0', message: REFUSAL, code: 'page-component-unknown' }] },
48+
},
49+
};
50+
}),
51+
save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })),
52+
publish: vi.fn(async () => ({ success: true })),
53+
reset: vi.fn(async () => ({})),
54+
}));
55+
56+
vi.mock('./useMetadata', async (importOriginal) => {
57+
const mod = await importOriginal<typeof import('./useMetadata')>();
58+
return {
59+
...mod,
60+
useMetadataClient: () => mockClient,
61+
useMetadataTypes: () => ({
62+
entries: [{ type: 'page', name: 'page', label: 'Page', allowEnvironmentOverlay: true, schema: { required: [] } }],
63+
}),
64+
};
65+
});
66+
67+
import { MetadataResourceEditPage } from './ResourceEditPage';
68+
import { registerMetadataPreview, getMetadataPreview } from './preview-registry';
69+
import { useServerErrorsAt } from './draftVerdict';
70+
71+
/** Reads the page's verdict the way an expression field does. */
72+
function VerdictProbe() {
73+
const here = useServerErrorsAt('regions.0');
74+
const sibling = useServerErrorsAt('regions.1');
75+
return (
76+
<>
77+
<pre data-testid="at-path">{here.map((e) => e.message).join('|')}</pre>
78+
<pre data-testid="at-sibling">{sibling.map((e) => e.message).join('|')}</pre>
79+
</>
80+
);
81+
}
82+
83+
const realPage = getMetadataPreview('page');
84+
85+
beforeEach(() => {
86+
server.draft = { name: 'home', label: 'Home', type: 'home', template: 'default', regions: [{ name: 'main', components: [] }] };
87+
registerMetadataPreview('page', VerdictProbe as never);
88+
});
89+
90+
afterEach(() => {
91+
cleanup();
92+
if (realPage) registerMetadataPreview('page', realPage);
93+
window.history.replaceState(null, '', '/');
94+
});
95+
96+
describe('MetadataResourceEditPage provides the served verdict to the fields inside it (objectstack#26858)', () => {
97+
it('a field at the refused path reads the refusal; a sibling path reads nothing', async () => {
98+
window.history.replaceState(null, '', '/metadata/page/home');
99+
render(
100+
<MemoryRouter initialEntries={['/metadata/page/home']}>
101+
<MetadataResourceEditPage type="page" name="home" />
102+
</MemoryRouter>,
103+
);
104+
await waitFor(() => expect(screen.getByTestId('at-path')).toHaveTextContent(REFUSAL), { timeout: 8000 });
105+
expect(screen.getByTestId('at-sibling')).toHaveTextContent('');
106+
});
107+
});

0 commit comments

Comments
 (0)