feat(node-modal): sanitize parent_id on type change and exclude descendants

- App.tsx: filter descendants of edited node from parentCandidates to
  prevent picking a child as parent (would create a cycle).
- NodeModal.tsx: clear parent_id when switching to a type with no valid
  parents, and revalidate parent_id on submit against the type's allowlist.
- Tests: cover getValidParentTypes for lxc/vm/docker_container and types
  that disallow parents.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-05-16 17:41:58 +02:00
parent 3a4df578ec
commit 8b97f578c6
3 changed files with 55 additions and 3 deletions
+18 -1
View File
@@ -561,7 +561,24 @@ export default function App() {
onSubmit={handleUpdateNode} onSubmit={handleUpdateNode}
initial={editNode?.data} initial={editNode?.data}
title="Edit Node" title="Edit Node"
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))} parentCandidates={(() => {
const descendants = new Set<string>()
if (editNodeId) {
const queue = [editNodeId]
while (queue.length) {
const id = queue.shift()!
for (const n of nodes) {
if (n.data.parent_id === id && !descendants.has(n.id)) {
descendants.add(n.id)
queue.push(n.id)
}
}
}
}
return nodes
.filter((n) => !descendants.has(n.id))
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
})()}
currentNodeId={editNodeId ?? undefined} currentNodeId={editNodeId ?? undefined}
/> />
+15 -1
View File
@@ -95,8 +95,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setLabelError(false) setLabelError(false)
const selectedType = (form.type ?? 'generic') as NodeType const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType)
let safeParentId = form.parent_id
if (validParentTypes.length === 0) {
safeParentId = undefined
} else if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
}
onSubmit({ onSubmit({
...form, ...form,
parent_id: safeParentId,
container_mode: canUseContainerMode ? !!form.container_mode : false, container_mode: canUseContainerMode ? !!form.container_mode : false,
}) })
onClose() onClose()
@@ -116,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Type</Label> <Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => { <Select value={form.type} onValueChange={(v) => {
const t = v as NodeType const t = v as NodeType
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) })) setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t }
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
return next
})
}}> }}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue> <SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
@@ -1,5 +1,26 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { resolveVirtualEdgeParent } from '../virtualEdgeParent' import { resolveVirtualEdgeParent, getValidParentTypes } from '../virtualEdgeParent'
describe('getValidParentTypes', () => {
it('returns container-mode types for lxc', () => {
expect(getValidParentTypes('lxc')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
})
it('returns container-mode types for vm', () => {
expect(getValidParentTypes('vm')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
})
it('returns docker_host/lxc/vm/proxmox for docker_container', () => {
expect(getValidParentTypes('docker_container')).toEqual(['docker_host', 'lxc', 'vm', 'proxmox'])
})
it('returns empty list for types that cannot have a parent', () => {
expect(getValidParentTypes('server')).toEqual([])
expect(getValidParentTypes('router')).toEqual([])
expect(getValidParentTypes('proxmox')).toEqual([])
expect(getValidParentTypes('docker_host')).toEqual([])
})
})
describe('resolveVirtualEdgeParent', () => { describe('resolveVirtualEdgeParent', () => {
it('nests lxc under proxmox (container-mode parent)', () => { it('nests lxc under proxmox (container-mode parent)', () => {