diff --git a/src/__tests__/attachmentInheritance.test.js b/src/__tests__/attachmentInheritance.test.js index 956f988..e025aed 100644 --- a/src/__tests__/attachmentInheritance.test.js +++ b/src/__tests__/attachmentInheritance.test.js @@ -18,7 +18,7 @@ describe('computeEffectiveAttachments', () => { ] it('direct node attachments remain on their node, unflagged as inherited', () => { - const atts = [{ id: 'a1', node_id: 'vm-a', scope: 'node', stage: 'run' }] + const atts = [{ id: 'a1', target_node: 'vm-a', scope: 'node', stage: 'run' }] const out = computeEffectiveAttachments(nodes, atts) expect(out.get('vm-a')).toHaveLength(1) expect(out.get('vm-a')[0].id).toBe('a1') @@ -27,7 +27,7 @@ describe('computeEffectiveAttachments', () => { }) it('group_inherited attachments propagate to all descendants (including nested group descendants)', () => { - const atts = [{ id: 'inh', node_id: 'g1', scope: 'group_inherited', stage: 'preflight' }] + const atts = [{ id: 'inh', target_node: 'g1', scope: 'group_inherited', stage: 'preflight' }] const out = computeEffectiveAttachments(nodes, atts) // Group owns its own copy (non-inherited — it's the source row) expect(out.get('g1')).toHaveLength(1) @@ -46,8 +46,8 @@ describe('computeEffectiveAttachments', () => { it('merges direct + inherited attachments on the same node', () => { const atts = [ - { id: 'direct', node_id: 'vm-a', scope: 'node', stage: 'post' }, - { id: 'inh', node_id: 'g1', scope: 'group_inherited', stage: 'pre' }, + { id: 'direct', target_node: 'vm-a', scope: 'node', stage: 'post' }, + { id: 'inh', target_node: 'g1', scope: 'group_inherited', stage: 'pre' }, ] const out = computeEffectiveAttachments(nodes, atts) const list = out.get('vm-a') @@ -58,7 +58,7 @@ describe('computeEffectiveAttachments', () => { }) it('does not mutate the source attachments array', () => { - const atts = [{ id: 'inh', node_id: 'g1', scope: 'group_inherited' }] + const atts = [{ id: 'inh', target_node: 'g1', scope: 'group_inherited' }] const snapshot = JSON.stringify(atts) computeEffectiveAttachments(nodes, atts) expect(JSON.stringify(atts)).toBe(snapshot) @@ -67,9 +67,9 @@ describe('computeEffectiveAttachments', () => { describe('applyBulkAttachmentEdit', () => { const atts = [ - { id: 'a1', node_id: 'vm-a', stage: 'pre', order: 0 }, - { id: 'a2', node_id: 'vm-b', stage: 'pre', order: 1 }, - { id: 'a3', node_id: 'g1', scope: 'node' }, + { id: 'a1', target_node: 'vm-a', stage: 'pre', order_in_stage: 0 }, + { id: 'a2', target_node: 'vm-b', stage: 'pre', order_in_stage: 1 }, + { id: 'a3', target_node: 'g1', scope: 'node' }, ] it('sets stage on selected rows only', () => { diff --git a/src/__tests__/composeAttachments.test.js b/src/__tests__/composeAttachments.test.js new file mode 100644 index 0000000..8944266 --- /dev/null +++ b/src/__tests__/composeAttachments.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { compose } from '@/overlay/compose' + +function baseDoc() { + return { + schema_version: '1.0', + kind: 'lab', + name: 'base', + nodes: [{ id: 'vm-a', kind: 'vm' }], + } +} + +describe('compose — UI attachment shape reaches the right node', () => { + it('appends a canonical attachment (target_node + source) onto its node, dropping target_node', () => { + const overlay = { + schema_version: '1.0', + source_url: 'x', + source_sha: 'y', + attachments_added: [ + { + id: 'att1', + target_node: 'vm-a', + stage: 'main', + order_in_stage: 0, + source: { kind: 'catalog_role', ref: 'src-a:roles/wazuh' }, + }, + ], + } + const eff = compose(baseDoc(), overlay) + const node = eff.nodes.find((n) => n.id === 'vm-a') + expect(node.attachments).toHaveLength(1) + const att = node.attachments[0] + expect(att.source.kind).toBe('catalog_role') + expect(att.stage).toBe('main') + expect(att.order_in_stage).toBe(0) + expect(att.target_node).toBeUndefined() // compose strips the routing key + }) + + it('drops a legacy attachment that uses node_id instead of target_node (guards the drift)', () => { + const overlay = { + schema_version: '1.0', + source_url: 'x', + source_sha: 'y', + attachments_added: [{ id: 'legacy', node_id: 'vm-a', stage: 'main' }], + } + const eff = compose(baseDoc(), overlay) + expect(eff.nodes.find((n) => n.id === 'vm-a').attachments).toBeUndefined() + }) +}) diff --git a/src/__tests__/normalizeAttachment.test.js b/src/__tests__/normalizeAttachment.test.js new file mode 100644 index 0000000..c4b0fa3 --- /dev/null +++ b/src/__tests__/normalizeAttachment.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { normalizeAttachment } from '../composables/useInfraBuilder' + +describe('normalizeAttachment — legacy → canonical attachment shape', () => { + it('maps node_id → target_node and order → order_in_stage, dropping the legacy keys', () => { + const out = normalizeAttachment({ id: 'a', node_id: 'vm-a', order: 2, stage: 'pre' }) + expect(out.target_node).toBe('vm-a') + expect(out.order_in_stage).toBe(2) + expect(out.node_id).toBeUndefined() + expect(out.order).toBeUndefined() + expect(out.stage).toBe('pre') + }) + + it('preserves a literal order of 0', () => { + expect(normalizeAttachment({ id: 'a', node_id: 'x', order: 0 }).order_in_stage).toBe(0) + }) + + it('is idempotent on already-canonical rows', () => { + const canon = { id: 'a', target_node: 'vm-a', order_in_stage: 1, source: { kind: 'inline_yaml' } } + expect(normalizeAttachment(canon)).toEqual(canon) + }) + + it('prefers an existing target_node over node_id when both are present', () => { + const out = normalizeAttachment({ id: 'a', target_node: 'canon', node_id: 'legacy' }) + expect(out.target_node).toBe('canon') + expect(out.node_id).toBeUndefined() + }) + + it('passes non-object input through unchanged', () => { + expect(normalizeAttachment(null)).toBeNull() + expect(normalizeAttachment(undefined)).toBeUndefined() + }) +}) diff --git a/src/components/project/AttachmentManager.vue b/src/components/project/AttachmentManager.vue index 4f06a2c..e5e63a2 100644 --- a/src/components/project/AttachmentManager.vue +++ b/src/components/project/AttachmentManager.vue @@ -50,7 +50,7 @@ const selectionIntersectsGroup = computed(() => { ) for (const a of props.attachments) { if (!selected.value.has(a.id)) continue - if (a.node_id && groupIds.has(a.node_id)) return true + if (a.target_node && groupIds.has(a.target_node)) return true } return false }) @@ -178,9 +178,9 @@ function applyNodeScoped() { />