|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#27865 — New Permission Set: one mistake is one message. |
| 5 | + * |
| 6 | + * (a) a Name the client validator refuses holds the save: no `PUT` leaves, |
| 7 | + * even on a click inside the live pass's debounce, and the inline error |
| 8 | + * on Name is the only message (no summary banner, no server refusal); |
| 9 | + * (b) a Name the client accepts but the server refuses, naming `name`, is |
| 10 | + * said once: inline on Name, with no banner repeating it; |
| 11 | + * (c) a Name derived from the Label, never touched, is held the same way, |
| 12 | + * and the Save attempt shows its error inline (the create form's |
| 13 | + * show-on-touch deferral ends there); |
| 14 | + * (d) a good Name saves (control); |
| 15 | + * (e) the hold's BOUND: a good Name beside a client-refused key that is not |
| 16 | + * the identity (the server-gained-a-key skew shape the advisory ruling |
| 17 | + * is about) still sends — only the Name holds a create. |
| 18 | + * |
| 19 | + * The REAL page over a REAL `MetadataClient` whose transport is an in-memory |
| 20 | + * server that records every `PUT` and answers the way the save door does: an |
| 21 | + * ungrammatical name is a 400 `INVALID_REQUEST` with no issues |
| 22 | + * (`refuseUngrammaticalMetaItemName`), and `taken_set` stands for a name the |
| 23 | + * client cannot judge, refused 422 with an issue at `name`. |
| 24 | + * |
| 25 | + * That the live Zod pass stays advisory everywhere else is pinned, unchanged, |
| 26 | + * in `ResourceEditPage.schemaAdvisory.test.tsx`. |
| 27 | + */ |
| 28 | + |
| 29 | +import '@testing-library/jest-dom/vitest'; |
| 30 | +import * as React from 'react'; |
| 31 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 32 | +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; |
| 33 | +import { MemoryRouter, Route, Routes } from 'react-router-dom'; |
| 34 | +import { z } from 'zod'; |
| 35 | +import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel'; |
| 36 | +import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; |
| 37 | +import { MetadataClient } from '@object-ui/data-objectstack'; |
| 38 | + |
| 39 | +const TAKEN = 'taken_set'; |
| 40 | +const TAKEN_MESSAGE = 'A permission set with this name already exists.'; |
| 41 | + |
| 42 | +const server = { |
| 43 | + puts: [] as Array<{ name: string; body: Record<string, unknown> }>, |
| 44 | +}; |
| 45 | + |
| 46 | +const json = (status: number, body: unknown) => |
| 47 | + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); |
| 48 | + |
| 49 | +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { |
| 50 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 51 | + const url = new URL(String(input), 'http://console.test'); |
| 52 | + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); |
| 53 | + if (!meta) return json(200, []); |
| 54 | + const seg = meta[1].split('/').map(decodeURIComponent); |
| 55 | + if (seg[0] === '_drafts') return json(200, { drafts: [] }); |
| 56 | + if (seg.length === 1) return json(200, { items: [] }); |
| 57 | + if (method === 'PUT') { |
| 58 | + const name = seg[1]!; |
| 59 | + server.puts.push({ name, body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown> }); |
| 60 | + if (!METADATA_ITEM_NAME_PATTERN.test(name)) { |
| 61 | + return json(400, { |
| 62 | + success: false, |
| 63 | + error: { code: 'INVALID_REQUEST', message: `${JSON.stringify(name)} is not a legal metadata item name.` }, |
| 64 | + }); |
| 65 | + } |
| 66 | + if (name === TAKEN) { |
| 67 | + return json(422, { |
| 68 | + success: false, |
| 69 | + error: { code: 'INVALID_METADATA', message: 'Invalid permission', details: { issues: [{ path: ['name'], message: TAKEN_MESSAGE }] } }, |
| 70 | + }); |
| 71 | + } |
| 72 | + return json(200, { success: true, version: 'hmac-sha256:v1', seq: 1, state: 'draft', message: `Saved ${seg[0]}/${name}` }); |
| 73 | + } |
| 74 | + return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } }); |
| 75 | +} |
| 76 | + |
| 77 | +const client = vi.hoisted(() => ({ current: null as unknown })); |
| 78 | +/** Extra keys the served create seed carries — set by (e) only. */ |
| 79 | +const seedExtra = vi.hoisted(() => ({ current: {} as Record<string, unknown> })); |
| 80 | + |
| 81 | +vi.mock('./useMetadata', async (importOriginal) => { |
| 82 | + const mod = await importOriginal<typeof import('./useMetadata')>(); |
| 83 | + return { |
| 84 | + ...mod, |
| 85 | + useMetadataClient: () => client.current, |
| 86 | + useMetadataTypes: () => ({ |
| 87 | + loading: false, |
| 88 | + entries: [ |
| 89 | + { |
| 90 | + type: 'permission', |
| 91 | + label: 'Permission Set', |
| 92 | + domain: 'security', |
| 93 | + allowEnvironmentOverlay: true, |
| 94 | + allowRuntimeCreate: true, |
| 95 | + schema: PERMISSION_JSON_SCHEMA, |
| 96 | + createSeed: { ...(getMetadataCreateSeed('permission') as Record<string, unknown>), ...seedExtra.current }, |
| 97 | + }, |
| 98 | + ], |
| 99 | + }), |
| 100 | + }; |
| 101 | +}); |
| 102 | + |
| 103 | +import { MetadataResourceEditPage } from './ResourceEditPage'; |
| 104 | +import { registerDefaultMetadataSchemas } from './default-schemas'; |
| 105 | +import { registerBuiltinAnchors } from './anchors'; |
| 106 | + |
| 107 | +const PERMISSION_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('permission') as z.ZodType, { |
| 108 | + io: 'input', |
| 109 | + unrepresentable: 'any', |
| 110 | +}) as Record<string, unknown>; |
| 111 | + |
| 112 | +client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); |
| 113 | + |
| 114 | +registerDefaultMetadataSchemas(); |
| 115 | +// The live registration: Label → Name derive, `createLive`. |
| 116 | +registerBuiltinAnchors(); |
| 117 | + |
| 118 | +beforeEach(() => { |
| 119 | + server.puts = []; |
| 120 | + seedExtra.current = {}; |
| 121 | +}); |
| 122 | +afterEach(() => { |
| 123 | + cleanup(); |
| 124 | +}); |
| 125 | + |
| 126 | +const saveButton = () => screen.getByTestId('resource-save'); |
| 127 | + |
| 128 | +async function openNewPermissionSet() { |
| 129 | + render( |
| 130 | + <MemoryRouter initialEntries={['/metadata/permission/new']}> |
| 131 | + <Routes> |
| 132 | + <Route path="/metadata/permission/new" element={<MetadataResourceEditPage type="permission" name="" createMode />} /> |
| 133 | + <Route path="/metadata/permission/:name" element={<div data-testid="landed" />} /> |
| 134 | + </Routes> |
| 135 | + </MemoryRouter>, |
| 136 | + ); |
| 137 | + return screen.findByRole('textbox', { name: /^Name/ }, { timeout: 8000 }); |
| 138 | +} |
| 139 | + |
| 140 | +/** The one message: exactly one inline issue, said nowhere else on the page. */ |
| 141 | +async function expectOnlyInlineIssue(): Promise<string> { |
| 142 | + const inline = await screen.findAllByTestId('schema-form-issue', {}, { timeout: 8000 }); |
| 143 | + expect(inline).toHaveLength(1); |
| 144 | + const text = inline[0]!.textContent ?? ''; |
| 145 | + expect(text).not.toBe(''); |
| 146 | + expect(screen.getAllByText(text, { exact: false })).toHaveLength(1); |
| 147 | + expect(screen.queryByTestId('metadata-validation-banner')).toBeNull(); |
| 148 | + return text; |
| 149 | +} |
| 150 | + |
| 151 | +describe('New Permission Set: one mistake, one message (objectstack#27865)', () => { |
| 152 | + it('(a) a Name the client refuses holds the save — no PUT, only the inline error', async () => { |
| 153 | + const nameBox = await openNewPermissionSet(); |
| 154 | + fireEvent.change(nameBox, { target: { value: 'Bad Name' } }); |
| 155 | + // Inside the live pass's 200 ms debounce: the hold must not depend on it. |
| 156 | + fireEvent.click(saveButton()); |
| 157 | + await expectOnlyInlineIssue(); |
| 158 | + await new Promise((r) => setTimeout(r, 300)); |
| 159 | + expect(server.puts).toEqual([]); |
| 160 | + expect(screen.queryByText(/is not a legal metadata item name/)).toBeNull(); |
| 161 | + expect(screen.queryByTestId('landed')).toBeNull(); |
| 162 | + // A second click is held the same way. |
| 163 | + fireEvent.click(saveButton()); |
| 164 | + await new Promise((r) => setTimeout(r, 300)); |
| 165 | + expect(server.puts).toEqual([]); |
| 166 | + await expectOnlyInlineIssue(); |
| 167 | + }); |
| 168 | + |
| 169 | + it('(b) a Name the client accepts but the server refuses at `name` is said once, inline', async () => { |
| 170 | + const nameBox = await openNewPermissionSet(); |
| 171 | + fireEvent.change(nameBox, { target: { value: TAKEN } }); |
| 172 | + fireEvent.blur(nameBox); |
| 173 | + fireEvent.click(saveButton()); |
| 174 | + await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); |
| 175 | + const text = await expectOnlyInlineIssue(); |
| 176 | + expect(text).toContain(TAKEN_MESSAGE); |
| 177 | + expect(screen.queryByTestId('landed')).toBeNull(); |
| 178 | + }); |
| 179 | + |
| 180 | + it('(c) a derived, untouched Name the client refuses is held and its error shown inline', async () => { |
| 181 | + await openNewPermissionSet(); |
| 182 | + const labelBox = screen.getByRole('textbox', { name: /^Label/ }); |
| 183 | + fireEvent.change(labelBox, { target: { value: 'A' } }); |
| 184 | + // Single-letter slug: refused by the spec's two-character floor. |
| 185 | + await waitFor(() => expect(screen.getByRole('textbox', { name: /^Name/ })).toHaveValue('a')); |
| 186 | + expect(screen.queryAllByTestId('schema-form-issue')).toHaveLength(0); |
| 187 | + fireEvent.click(saveButton()); |
| 188 | + await expectOnlyInlineIssue(); |
| 189 | + await new Promise((r) => setTimeout(r, 300)); |
| 190 | + expect(server.puts).toEqual([]); |
| 191 | + }); |
| 192 | + |
| 193 | + it('(d) a good Name saves (control)', async () => { |
| 194 | + const nameBox = await openNewPermissionSet(); |
| 195 | + fireEvent.change(nameBox, { target: { value: 'sales_ops' } }); |
| 196 | + fireEvent.click(saveButton()); |
| 197 | + await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); |
| 198 | + expect(server.puts[0]!.name).toBe('sales_ops'); |
| 199 | + await screen.findByTestId('landed', {}, { timeout: 8000 }); |
| 200 | + }); |
| 201 | + |
| 202 | + it('(e) a good Name beside a client-refused non-identity key still sends — the hold is the Name only', async () => { |
| 203 | + // A key the bundled spec does not declare, served in the create seed: the |
| 204 | + // client's strict pass refuses it at the root, the (newer) server takes it. |
| 205 | + seedExtra.current = { zzServerOnlyKey: true }; |
| 206 | + const nameBox = await openNewPermissionSet(); |
| 207 | + fireEvent.change(nameBox, { target: { value: 'sales_ops' } }); |
| 208 | + // The client does refuse the draft — surfaced, advisory — and not at Name. |
| 209 | + const banner = await screen.findByTestId('metadata-validation-banner', {}, { timeout: 8000 }); |
| 210 | + expect(banner.textContent).toContain('(root)'); |
| 211 | + expect(screen.queryAllByTestId('schema-form-issue')).toHaveLength(0); |
| 212 | + fireEvent.click(saveButton()); |
| 213 | + await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); |
| 214 | + expect(server.puts[0]!.name).toBe('sales_ops'); |
| 215 | + expect(server.puts[0]!.body.zzServerOnlyKey).toBe(true); |
| 216 | + await screen.findByTestId('landed', {}, { timeout: 8000 }); |
| 217 | + }); |
| 218 | +}); |
0 commit comments