feat(console): mark immutable resource fields as read-only in edit forms#6
feat(console): mark immutable resource fields as read-only in edit forms#6lexfrei wants to merge 9 commits into
Conversation
Wire up a minimal vitest + jsdom + @testing-library/react setup in apps/console so the policy-bearing schema helpers and form behaviour introduced in upcoming commits can be developed test-first. - vitest.config.ts: jsdom env, test glob under src/, setup file. - src/test-setup.ts: import jest-dom matchers. - package.json: "test" and "test:watch" scripts. - root package.json: aggregate "test" delegating to workspaces. - tsconfig.json: pick up jest-dom matcher types. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Introduce pure helpers used to recognise and enforce Kubernetes-style immutability rules on resource schemas. findImmutablePaths(schema) walks an OpenAPI/JSON-schema tree and returns every path whose subschema carries an x-kubernetes-validations entry with rule "self == oldSelf". Object properties contribute named segments; array items and additionalProperties contribute the wildcard segment "*". oneOf, anyOf and allOf branches are considered when determining whether a node is immutable. overlayImmutable(submitted, original, paths) deep-clones the submitted value, then for each path copies the value from original. For wildcard segments the array length is aligned to original so an immutable list cannot be reshaped client-side. IMMUTABLE_HELP_TEXT centralises the user-facing string consumed by later UI wiring. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
AJV has no CEL evaluator, so the rule does nothing for JSON-Schema validation; the UI consults the raw schema once to harvest immutability paths and then drops the extension so RJSF traversal stays small. Also add characterization tests for keysOrderToUiSchema and sanitizeSchema so existing behaviour (int-or-string coercion, preserve-unknown-fields → additionalProperties, "Chart Values" → "Parameters") is locked in. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
When immutableMode='enforce' is passed, every immutable schema path (as reported by findImmutablePaths) receives ui:readonly=true plus a ui:help string sourced from IMMUTABLE_HELP_TEXT. RJSF then renders the corresponding inputs read-only and surfaces the helper text underneath. The default 'off' keeps every field editable so create flows are unaffected. Wildcard segments map to RJSF's 'items' key for arrays; for object maps (additionalProperties) the readonly flag is set on the field itself, to be propagated by the AdditionalPropertiesField renderer. Add a RTL component test covering enforce / off / omitted paths, plus afterEach(cleanup) in the shared test-setup so renders don't leak between cases. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The component already derives isReadonly from readonly || disabled and forwards it to the inner <Form> and Add/Remove controls; the new tests pin that contract by exercising it via SchemaForm + immutableMode. When immutableMode is omitted the Add control and per-entry Remove buttons render. With immutableMode='enforce' those controls disappear, the inner inputs become disabled, and the canonical help text is rendered alongside the map field. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
ApplicationOrderPage and BackupResourceEditPage now opt their SchemaForm into immutableMode='enforce' when in edit flow so the relevant fields render read-only with helper text. On submit, prepareUpdateSpec walks the schema for immutable paths and copies the original values from the persisted spec into the outgoing body. The API server therefore observes no delta on those paths and the CEL immutability check is not triggered, even if the user managed to bypass the read-only UI (YAML editor, devtools, RJSF bug). prepareUpdateSpec is a thin pure wrapper around findImmutablePaths + overlayImmutable so the schema-vs-no-schema and malformed-JSON edges can be locked down without rendering full pages under jsdom. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
- Switch from ui:readonly to ui:disabled so immutable inputs render greyed out, matching the product decision. RJSF readonly only sets the HTML readonly attribute, which leaves the control visually identical to an editable one. - overlayImmutable: warn and replace wholesale when a root-level immutable path is supplied (almost certainly a schema-authoring mistake worth surfacing in DevTools). - overlayImmutable: when a wildcard segment is the last in the path the whole array is replaced from source; when nested fields are immutable the loop iterates the longer of source/target so the user can still add new array entries and they survive the overlay. - prepareUpdateSpec: short-circuit to a deep clone when original is null/undefined to avoid blanking immutable fields with undefined. Log a console.warn when the schema string fails to parse instead of silently disabling immutability enforcement. - SchemaForm: parse openAPISchema once and derive the sanitised schema plus immutable path set from the shared object. - SchemaForm: add a SchemaForm-level test for the array-items wildcard case so the rendering branch is covered end-to-end. - keysOrderToUiSchema: accept ReadonlyArray<ReadonlyArray<string>> so call sites can hand in immutable-path-style arrays without casting. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request implements immutability enforcement in the SchemaForm component by parsing x-kubernetes-validations and applying ui:disabled and ui:help to immutable paths. It introduces a prepareUpdateSpec utility to ensure immutable values are preserved during updates and adds Vitest for testing. The review feedback highlights improvements for the array overlay logic to prevent potential null-value issues, suggests consolidating utility functions like isPlainObject and deepClone for better consistency, and recommends cleaning up redundant code in the update preparation logic.
| const sourceArr = Array.isArray(source) ? source : [] | ||
| const targetArr = Array.isArray(target) ? target : [] | ||
| const len = Math.max(sourceArr.length, targetArr.length) | ||
| const out: unknown[] = [] | ||
| for (let i = 0; i < len; i++) { | ||
| if (i >= sourceArr.length) { | ||
| out[i] = deepClone(targetArr[i]) | ||
| } else { | ||
| out[i] = overlayPath(targetArr[i], sourceArr[i], path, depth + 1) | ||
| } | ||
| } | ||
| return out |
There was a problem hiding this comment.
The current array overlay logic can produce arrays with undefined elements (holes) when the source array is longer than the target array (e.g., when an item is deleted). When serialized to JSON, these holes become null, which may violate the schema or cause unexpected behavior on the API server. Using targetArr.map is a safer and more concise way to handle positional overlays while correctly respecting deletions and additions.
const sourceArr = Array.isArray(source) ? source : []
const targetArr = Array.isArray(target) ? target : []
return targetArr.map((item, i) =>
i < sourceArr.length
? overlayPath(item, sourceArr[i], path, depth + 1)
: deepClone(item),
)| const isPlainObject = (v: unknown): v is Record<string, unknown> => | ||
| v !== null && typeof v === "object" && !Array.isArray(v) |
There was a problem hiding this comment.
Export isPlainObject so it can be reused in SchemaForm.tsx and deepClone for better consistency and code reuse.
| const isPlainObject = (v: unknown): v is Record<string, unknown> => | |
| v !== null && typeof v === "object" && !Array.isArray(v) | |
| export const isPlainObject = (v: unknown): v is Record<string, unknown> => | |
| v !== null && typeof v === "object" && !Array.isArray(v) |
| function deepClone<T>(value: T): T { | ||
| if (value === null || value === undefined) return value | ||
| if (Array.isArray(value)) return value.map(deepClone) as unknown as T | ||
| if (typeof value === "object") { | ||
| const out: Record<string, unknown> = {} | ||
| for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | ||
| out[k] = deepClone(v) | ||
| } | ||
| return out as unknown as T | ||
| } | ||
| return value | ||
| } |
There was a problem hiding this comment.
Export deepClone to allow its reuse in prepare-update.ts, and use the isPlainObject helper for a cleaner and more consistent implementation.
| function deepClone<T>(value: T): T { | |
| if (value === null || value === undefined) return value | |
| if (Array.isArray(value)) return value.map(deepClone) as unknown as T | |
| if (typeof value === "object") { | |
| const out: Record<string, unknown> = {} | |
| for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | |
| out[k] = deepClone(v) | |
| } | |
| return out as unknown as T | |
| } | |
| return value | |
| } | |
| export function deepClone<T>(value: T): T { | |
| if (value === null || value === undefined) return value | |
| if (Array.isArray(value)) return value.map(deepClone) as unknown as T | |
| if (isPlainObject(value)) { | |
| const out: Record<string, unknown> = {} | |
| for (const [k, v] of Object.entries(value)) { | |
| out[k] = deepClone(v) | |
| } | |
| return out as unknown as T | |
| } | |
| return value | |
| } |
| import { | ||
| findImmutablePaths, | ||
| overlayImmutable, | ||
| type ImmutablePath, | ||
| } from "./immutable-paths.ts" |
There was a problem hiding this comment.
Add deepClone to the imports from ./immutable-paths.ts to replace the redundant cloneShallowAsDeep function.
| import { | |
| findImmutablePaths, | |
| overlayImmutable, | |
| type ImmutablePath, | |
| } from "./immutable-paths.ts" | |
| import { | |
| findImmutablePaths, | |
| overlayImmutable, | |
| deepClone, | |
| type ImmutablePath, | |
| } from "./immutable-paths.ts" |
| openAPISchema: string, | ||
| ): T { | ||
| if (original === undefined || original === null) { | ||
| return cloneShallowAsDeep(submitted) |
| function cloneShallowAsDeep<T>(value: T): T { | ||
| if (value === null || value === undefined) return value | ||
| return JSON.parse(JSON.stringify(value)) as T | ||
| } |
| import { | ||
| IMMUTABLE_HELP_TEXT, | ||
| findImmutablePaths, | ||
| type ImmutablePath, | ||
| } from "../lib/immutable-paths.ts" |
There was a problem hiding this comment.
Add isPlainObject to the imports from ../lib/immutable-paths.ts.
| import { | |
| IMMUTABLE_HELP_TEXT, | |
| findImmutablePaths, | |
| type ImmutablePath, | |
| } from "../lib/immutable-paths.ts" | |
| import { | |
| IMMUTABLE_HELP_TEXT, | |
| findImmutablePaths, | |
| isPlainObject, | |
| type ImmutablePath, | |
| } from "../lib/immutable-paths.ts" |
| key: string, | ||
| ): Record<string, unknown> { | ||
| const existing = uiNode[key] | ||
| if (existing && typeof existing === "object" && !Array.isArray(existing)) { |
Second-pass review surfaced four defects in overlayImmutable that
produced data corruption in the PUT body for cases the schema walker
already documents as supported. Fix each, pin behaviour with tests.
1. Nested-field overlay with the submitted array shorter than original
used to leave undefined slots ("holes"); the new loop walks the
target length so deletions stick. Adds are still preserved because
the overlay never touches indices past source.
2. additionalProperties map with *-last used to be a no-op (Array.isArray
guarded the only branch). Now an object-source replaces target with a
deep clone, so YAML-editor / devtools edits cannot smuggle a mutation
past the form's read-only state.
3. additionalProperties map with *-not-last used to coerce the map to
an empty array. New code handles object source/target separately:
iterate shared keys, overlay the immutable nested subfield from
source, keep user-added keys and respect user-deleted ones.
4. When the persisted spec has no value at the immutable path, the
overlay used to write undefined into the body, silently erasing the
user's input. Now keep the submitted value — the form already greys
the field out so interactive edits are blocked, and a missing source
value isn't an immutability violation to defend against.
Also surface two trade-offs reviewers asked about in plain comments:
- SchemaForm.parsedSchema useMemo identity (same string ⇒ same object).
- ApplicationOrderPage uses mount-time initialSpec; documented as a
re-mount-to-refresh trade-off.
README gains a one-line 'pnpm test' section to surface the new suite.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
- Replace .hasOwnProperty(seg) with Object.prototype.hasOwnProperty.call so no-prototype-builtins doesn't regress. Add a test pinning the "neither side has the field" code path. - Add three pinned-as-broken tests with FIXME notes for shapes not yet supported by the walker/overlay: oneOf/anyOf/allOf branch properties, intermediate-missing-parent during overlay, and index-aligned overlay on user-reordered arrays. These behaviours stay as-is in this PR. - Add a component test pinning the deliberate UI/overlay asymmetry on additionalProperties when only nested values are immutable: the form freezes the whole map for simplicity while overlay enforces per-entry. - Expand sanitizeSchema's doc block to mention x-kubernetes-validations (stripped here, harvested from the raw schema by immutable-paths). - Tighten SchemaForm's walker doc-comment so "raw (sanitised)" is not self-contradicting. - Spy on console.warn inside the malformed-schema prepareUpdateSpec test so the suite doesn't emit log noise. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Summary
Resource fields declared immutable in the schema via
x-kubernetes-validations: [{rule: "self == oldSelf"}]are now:Create flows are untouched.
Acceptance criterion
The companion cozystack/cozystack#2639 marks MariaDB
storageClassimmutable (itself dependent on cozystack/cozyvalues-gen#24). With all three PRs in place, opening an existing MariaDB instance for edit grays outstorageClass, shows the helper text, keeps the rest editable, and the outgoing PUT carries the originalstorageClassvalue.What's in this PR
apps/console/src/lib/immutable-paths.ts:findImmutablePathswalks the schema tree (properties,items,additionalProperties,oneOf/anyOf/allOf) and returns every path carrying the CEL rule.overlayImmutablecopies the original spec's values along those paths into the submitted body. Wildcard semantics:*-last with an array source: replace target with a clone of source (whole array immutable).*-last with an object-map source: replace target with a clone of source (whole map immutable).*-not-last with an array source: walk the user's array; restore the nested immutable subfield from source at shared indices, keep user-added entries verbatim, respect user deletions.*-not-last with an object-map source: walk the user's keys; restore the nested immutable subfield from source at shared keys, keep user-added keys verbatim, respect user-deleted keys.apps/console/src/lib/prepare-update.ts— pure wrapper combining the two; logs a console warning when the schema fails to parse; short-circuits when the persisted spec is null/undefined to avoid blanking immutable fields with undefined.SchemaFormaccepts animmutableModeprop ("enforce" | "off", default"off"). When enforcing, walks the schema and emitsui:disabled+ui:helpper immutable path. Wildcard*maps to RJSF'sitemsfor arrays. For object maps (additionalProperties) the field itself is marked disabled — per-entry-value-immutable semantics is deliberately deferred until a real schema asks for it.sanitizeSchemastripsx-kubernetes-validationsonce it has been harvested from the raw schema. AJV has no CEL evaluator, so the rule was pure noise.ApplicationOrderPageandBackupResourceEditPageopt in to enforcement on edit and callprepareUpdateSpecbefore mutating.Known limitations (pinned with FIXME tests)
propertiesbeneathoneOf/anyOf/allOfbranches; it only treats the parent as immutable when any branch carries the rule itself.Each is covered by an explicit test pinning the current behaviour so a future contributor can iterate them.
Test infrastructure
vitest + jsdom + @testing-library/react are added as part of this PR. 53 unit and component tests cover the schema walker, sanitiser, submit overlay, and form behaviour (greyed inputs, helper text, array-items rendering, etc.).
pnpm testruns them all.Tested manually against a hand-edited MariaDB
values.schema.jsoncarrying the rule onstorageClass:No-op until cozystack ships the schema annotation
When no schema carries the rule,
findImmutablePathsreturns[]everywhere, all walkers are no-ops, the overlay is identity, edit pages behave exactly as today. The UI lights up resource-by-resource as upstream annotates immutable fields.