diff --git a/frontend-modern/src/features/proxmox/__fixtures__/backupIdentity.ts b/frontend-modern/src/features/proxmox/__fixtures__/backupIdentity.ts new file mode 100644 index 0000000000..522a13dd2e --- /dev/null +++ b/frontend-modern/src/features/proxmox/__fixtures__/backupIdentity.ts @@ -0,0 +1,79 @@ +import type { BackupTask, GuestSnapshot, PBSBackup, StorageBackup } from '@/types/api'; +import type { Resource } from '@/types/resource'; + +export const BACKUP_IDENTITY_NOW = Date.parse('2026-10-04T12:00:00Z'); + +export const identityGuest = ( + id: string, + instance: string, + node: string, + extra: Partial> = {}, +): Resource => + ({ + id, + name: id, + type: 'vm', + platformId: instance, + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'running', + lastSeen: BACKUP_IDENTITY_NOW, + proxmox: { vmid: 100, instance, nodeName: node, ...extra }, + }) as Resource; + +export const identityArchive = (extra: Partial = {}): StorageBackup => ({ + id: 'file-100', + instance: 'east', + node: 'pve-1', + type: 'vm', + vmid: 100, + storage: 'local', + time: '2026-10-04T11:00:00Z', + ctime: BACKUP_IDENTITY_NOW / 1000 - 3600, + size: 1024, + format: 'zst', + volid: 'local:backup/vzdump-qemu-100.vma.zst', + protected: false, + isPBS: false, + verified: false, + ...extra, +}); + +export const identitySnapshot = (extra: Partial = {}): GuestSnapshot => ({ + id: 'snapshot-100', + instance: 'east', + node: 'pve-1', + type: 'vm', + vmid: 100, + name: 'before-update', + time: '2026-10-04T11:30:00Z', + vmstate: false, + ...extra, +}); + +export const identityPBS = (extra: Partial = {}): PBSBackup => ({ + id: 'pbs-100', + instance: 'pbs-server', + namespace: 'pve-1', + datastore: 'main', + backupType: 'vm', + vmid: '100', + backupTime: '2026-10-04T10:00:00Z', + size: 2048, + files: ['index.json.blob'], + protected: false, + verified: true, + ...extra, +}); + +export const identityTask = (extra: Partial = {}): BackupTask => ({ + id: 'task-100', + instance: 'east', + node: 'pve-1', + type: 'vzdump', + vmid: 100, + status: 'OK', + startTime: '2026-10-04T11:00:00Z', + endTime: '2026-10-04T11:05:00Z', + ...extra, +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts new file mode 100644 index 0000000000..0539bfcbab --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.identity.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { + BACKUP_IDENTITY_NOW, + identityArchive, + identityGuest, + identityPBS, + identitySnapshot, + identityTask, +} from '../__fixtures__/backupIdentity'; +import { buildProxmoxBackupRecoveryModel } from '../proxmoxBackupRecoveryModel'; + +type Input = Parameters[0]; +const model = (input: Partial) => + buildProxmoxBackupRecoveryModel({ + workloads: [], + archives: [], + snapshots: [], + pbsBackups: [], + tasks: [], + nowMs: BACKUP_IDENTITY_NOW, + ...input, + }); +const owners = (result: ReturnType) => + result.recoverableArtifacts.map((artifact) => artifact.workload.resourceId); + +describe('backup-identity: native ownership rather than first label match', () => { + it.each([false, true])('is independent of inventory order (reversed: %s)', (reversed) => { + const workloads = [identityGuest('west-100', 'west', 'pve-10'), identityGuest('east-100', 'east', 'pve-1')]; + if (reversed) workloads.reverse(); + const result = model({ workloads, archives: [identityArchive()], snapshots: [identitySnapshot()], pbsBackups: [identityPBS()], tasks: [identityTask()] }); + expect(owners(result)).toEqual(['east-100', 'east-100', 'east-100']); + expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined(); + expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100'); + }); + + it('uses the PVE instance before a reused node name', () => { + const result = model({ + workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')], + archives: [identityArchive()], snapshots: [identitySnapshot()], tasks: [identityTask()], + }); + expect(owners(result)).toEqual(['east-100', 'east-100']); + expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined(); + }); + + it('keeps same-instance migrated backups without using the old node to select another cluster', () => { + const result = model({ + workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-2')], + archives: [identityArchive()], tasks: [identityTask()], + }); + expect(owners(result)).toEqual(['east-100']); + expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100'); + }); + + it('does not let a matching node override a conflicting PVE instance, even with one visible guest', () => { + const result = model({ + workloads: [identityGuest('east-100', 'east', 'pve-1')], + archives: [identityArchive({ instance: 'removed-east' })], snapshots: [identitySnapshot({ instance: 'removed-east' })], + tasks: [identityTask({ instance: 'removed-east' })], + }); + expect(owners(result)).toEqual([undefined, undefined]); + const live = result.coverageRows.find((row) => row.workload.resourceId === 'east-100')!; + expect(live.artifacts).toEqual([]); + expect(live.latestTask).toBeUndefined(); + const orphan = result.coverageRows.find((row) => row.isOrphaned)!; + expect(orphan.latestTask?.id).toBe('task-100'); + expect(orphan.posture).toBe('not-evaluated'); + }); + + it('does not accept a partial node match or contradictory singleton when PVE instance is absent', () => { + const result = model({ + workloads: [identityGuest('west-100', '', 'pve-10')], + archives: [identityArchive({ instance: '' })], snapshots: [identitySnapshot({ instance: '' })], tasks: [identityTask({ instance: '' })], + }); + expect(owners(result)).toEqual([undefined, undefined]); + expect(result.coverageRows.find((row) => row.workload.resourceId)?.latestTask).toBeUndefined(); + }); + + it('accepts exact native aliases, not mutable display labels', () => { + const workloads = [identityGuest('west-100', 'west', 'pve-10', { nodeDisplayName: 'pve-1' }), identityGuest('east-100', 'east', 'pve-new', { nodeAliases: ['pve-1'] })]; + const result = model({ workloads, archives: [identityArchive({ instance: '' })], pbsBackups: [identityPBS()], tasks: [identityTask({ instance: '' })] }); + expect(owners(result)).toEqual(['east-100', 'east-100']); + expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100'); + }); + + it('does not turn a reused namespace/node or alias into a unique PBS owner', () => { + const result = model({ workloads: [identityGuest('west-100', 'west', 'shared', { nodeAliases: ['pve-1'] }), identityGuest('east-100', 'east', 'shared', { nodeAliases: ['pve-1'] })], pbsBackups: [identityPBS()] }); + expect(owners(result)).toEqual([undefined]); + expect(result.coverageRows.filter((row) => row.workload.resourceId).every((row) => row.pbsCount === 0)).toBe(true); + }); + + it('does not use a datastore label as a PVE workload identity', () => { + const result = model({ workloads: [identityGuest('west-100', 'west', 'main'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: '' })] }); + expect(owners(result)).toEqual([undefined]); + }); + + it('preserves root-namespace PBS for a unique VMID and exact instance namespace with collisions', () => { + expect(owners(model({ workloads: [identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: '' })] }))).toEqual(['east-100']); + expect(owners(model({ workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: ' EAST ' })] }))).toEqual(['east-100']); + }); + + it('retains exact-node matching without instance metadata and the unscoped singleton control', () => { + const workload = identityGuest('east-100', '', 'pve-1'); + expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '' })] }))).toEqual(['east-100']); + expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '', node: '' })] }))).toEqual(['east-100']); + }); + + it('leaves an ambiguous unknown-type task unattached across VM and LXC identities', () => { + const vm = identityGuest('vm-100', 'east', 'pve-1'); + const ct = { ...identityGuest('ct-100', 'east', 'pve-1'), type: 'system-container' } as typeof vm; + const result = model({ workloads: [vm, ct], tasks: [identityTask()] }); + expect(result.coverageRows.every((row) => row.latestTask === undefined)).toBe(true); + }); + + it('preserves unresolved artifacts but does not pool identically named PBS sources', () => { + const result = model({ pbsBackups: [identityPBS({ id: 'pbs-a', instance: 'pbs-a' }), identityPBS({ id: 'pbs-b', instance: 'pbs-b' })] }); + expect(result.recoverableArtifacts).toHaveLength(2); + expect(result.coverageRows).toHaveLength(2); + expect(new Set(result.coverageRows.map((row) => row.key)).size).toBe(2); + expect(result.coverageRows.every((row) => row.pbsCount === 1 && row.posture === 'not-evaluated')).toBe(true); + }); + + it('does not attach a PVE task to a PBS-only orphan with a similar location label', () => { + const result = model({ pbsBackups: [identityPBS()], tasks: [identityTask({ instance: '' })] }); + expect(result.coverageRows[0].latestTask).toBeUndefined(); + }); + + it('keeps unresolved PVE instances separate while associating their own typed and untyped tasks', () => { + const result = model({ archives: [identityArchive({ id: 'file-east' }), identityArchive({ id: 'file-west', instance: 'west' })], tasks: [identityTask(), identityTask({ id: 'task-west', instance: 'west', type: 'vm' })] }); + expect(result.coverageRows).toHaveLength(2); + expect(result.coverageRows.map((row) => [row.workload.instance, row.latestTask?.id]).sort()).toEqual([['east', 'task-100'], ['west', 'task-west']]); + }); +});