ab36ba6f81
Add a Proxmox VE importer that reads the /api2/json REST API with a read-only
API token and drops hosts (proxmox), VMs (vm) and LXC containers (lxc) onto the
canvas as typed nodes with run state and hardware specs (vCPU/RAM/disk).
- Backend: proxmox_service (httpx) + proxmox routes (test-connection, import,
import-pending, config). Two-tier dedupe — merge onto an existing scanned node
by IP, else synthetic pve-{host}-{vmid} identity. Update-in-place, never
deletes. Host->guest rendered as a 'virtual' edge via the pending-link flow.
- Security: token is env-only (PROXMOX_TOKEN_*), never written to disk by the
app, never returned by any endpoint; errors are credential-sanitized.
- Auto-sync: optional scheduled re-import into pending (APScheduler job).
- PendingDevice.properties carries specs through approve (+ migration).
- Frontend: ProxmoxImportModal, sidebar entry, pending inventory source filter,
Settings auto-sync section, proxmoxApi client.
- Docs: docs/proxmox-import.md, README + FEATURES sections, .env.example keys.
- Tests: backend service/router/scheduler, frontend modal/client/pending.
ha-relevant: maybe
251 lines
9.9 KiB
TypeScript
251 lines
9.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
type Interceptor<T> = {
|
|
fulfilled?: (v: T) => T | Promise<T>
|
|
rejected?: (e: unknown) => unknown
|
|
}
|
|
|
|
interface MockInstance {
|
|
defaults: { baseURL?: string }
|
|
interceptors: {
|
|
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
|
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
|
}
|
|
get: ReturnType<typeof vi.fn>
|
|
post: ReturnType<typeof vi.fn>
|
|
patch: ReturnType<typeof vi.fn>
|
|
delete: ReturnType<typeof vi.fn>
|
|
__req: Interceptor<{ headers: Record<string, string> }>
|
|
__res: Interceptor<unknown>
|
|
}
|
|
|
|
const hoisted = vi.hoisted(() => ({ instances: [] as unknown[] }))
|
|
const instances = hoisted.instances as MockInstance[]
|
|
|
|
vi.mock('axios', () => {
|
|
return {
|
|
default: {
|
|
create: (cfg: { baseURL?: string }) => {
|
|
const inst: MockInstance = {
|
|
defaults: { baseURL: cfg?.baseURL },
|
|
interceptors: {
|
|
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
|
|
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
|
|
},
|
|
get: vi.fn(() => Promise.resolve({ data: {} })),
|
|
post: vi.fn(() => Promise.resolve({ data: {} })),
|
|
patch: vi.fn(() => Promise.resolve({ data: {} })),
|
|
delete: vi.fn(() => Promise.resolve({ data: {} })),
|
|
__req: {},
|
|
__res: {},
|
|
}
|
|
hoisted.instances.push(inst)
|
|
return inst
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import * as clientModule from '../client'
|
|
|
|
describe('api/client', () => {
|
|
const mod = clientModule
|
|
const [api, publicApi] = instances
|
|
|
|
beforeEach(() => {
|
|
useAuthStore.setState({ token: null, isAuthenticated: false })
|
|
api.get.mockClear()
|
|
api.post.mockClear()
|
|
api.patch.mockClear()
|
|
api.delete.mockClear()
|
|
publicApi.get.mockClear()
|
|
publicApi.post.mockClear()
|
|
})
|
|
|
|
it('creates two axios instances with /api/v1 baseURL', () => {
|
|
expect(instances).toHaveLength(2)
|
|
expect(api.defaults.baseURL).toBe('/api/v1')
|
|
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
|
})
|
|
|
|
it('exports `api` matching the first created instance', () => {
|
|
expect(mod.api).toBe(api)
|
|
})
|
|
|
|
it('request interceptor adds Authorization header when token present', () => {
|
|
useAuthStore.setState({ token: 'tok-123', isAuthenticated: true })
|
|
const cfg = { headers: {} as Record<string, string> }
|
|
const out = api.__req.fulfilled!(cfg)
|
|
expect((out as typeof cfg).headers.Authorization).toBe('Bearer tok-123')
|
|
})
|
|
|
|
it('request interceptor leaves headers untouched when no token', () => {
|
|
const cfg = { headers: {} as Record<string, string> }
|
|
const out = api.__req.fulfilled!(cfg)
|
|
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
|
|
})
|
|
|
|
it('response interceptor passes through 2xx responses', () => {
|
|
const r = { status: 200, data: { ok: true } }
|
|
expect(api.__res.fulfilled!(r)).toBe(r)
|
|
})
|
|
|
|
it('response interceptor calls logout on 401', async () => {
|
|
const logout = vi.spyOn(useAuthStore.getState(), 'logout')
|
|
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
|
const err = { response: { status: 401 } }
|
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
|
expect(logout).toHaveBeenCalled()
|
|
})
|
|
|
|
it('response interceptor does not call logout on non-401', async () => {
|
|
const logout = vi.fn()
|
|
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
|
const err = { response: { status: 500 } }
|
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
|
expect(logout).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('response interceptor handles error with no response object', async () => {
|
|
const logout = vi.fn()
|
|
useAuthStore.setState({ logout })
|
|
const err = { message: 'network down' }
|
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
|
expect(logout).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('publicApi has no request/response interceptors registered', () => {
|
|
expect(publicApi.__req.fulfilled).toBeUndefined()
|
|
expect(publicApi.__res.fulfilled).toBeUndefined()
|
|
})
|
|
|
|
it('authApi.login posts to /auth/login', () => {
|
|
mod.authApi.login('u', 'p')
|
|
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
|
|
})
|
|
|
|
it('canvasApi.load GETs /canvas', () => {
|
|
mod.canvasApi.load()
|
|
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
|
|
})
|
|
|
|
it('canvasApi.save POSTs to /canvas/save with payload', () => {
|
|
const payload = { nodes: [], edges: [], viewport: {} }
|
|
mod.canvasApi.save(payload)
|
|
expect(api.post).toHaveBeenCalledWith('/canvas/save', payload)
|
|
})
|
|
|
|
it('nodesApi CRUD calls correct endpoints', () => {
|
|
mod.nodesApi.create({ a: 1 })
|
|
expect(api.post).toHaveBeenCalledWith('/nodes', { a: 1 })
|
|
mod.nodesApi.update('n1', { b: 2 })
|
|
expect(api.patch).toHaveBeenCalledWith('/nodes/n1', { b: 2 })
|
|
mod.nodesApi.delete('n1')
|
|
expect(api.delete).toHaveBeenCalledWith('/nodes/n1')
|
|
})
|
|
|
|
it('edgesApi CRUD calls correct endpoints', () => {
|
|
mod.edgesApi.create({ s: 'a', t: 'b' })
|
|
expect(api.post).toHaveBeenCalledWith('/edges', { s: 'a', t: 'b' })
|
|
mod.edgesApi.delete('e1')
|
|
expect(api.delete).toHaveBeenCalledWith('/edges/e1')
|
|
})
|
|
|
|
it('liveviewApi.load uses publicApi with key param', () => {
|
|
mod.liveviewApi.load('k-1')
|
|
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1' } })
|
|
expect(api.get).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('liveviewApi.load forwards design as design_id when provided', () => {
|
|
mod.liveviewApi.load('k-1', 'design-9')
|
|
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1', design_id: 'design-9' } })
|
|
})
|
|
|
|
it('liveviewApi.getConfig hits the authenticated config endpoint', () => {
|
|
mod.liveviewApi.getConfig()
|
|
expect(api.get).toHaveBeenCalledWith('/liveview/config')
|
|
})
|
|
|
|
it('scanApi endpoints route correctly', () => {
|
|
mod.scanApi.trigger()
|
|
expect(api.post).toHaveBeenCalledWith('/scan/trigger', {})
|
|
mod.scanApi.pending()
|
|
expect(api.get).toHaveBeenCalledWith('/scan/pending')
|
|
mod.scanApi.hidden()
|
|
expect(api.get).toHaveBeenCalledWith('/scan/hidden')
|
|
mod.scanApi.runs()
|
|
expect(api.get).toHaveBeenCalledWith('/scan/runs')
|
|
mod.scanApi.clearPending()
|
|
expect(api.delete).toHaveBeenCalledWith('/scan/pending')
|
|
mod.scanApi.approve('d1', { foo: 'bar' })
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/approve', { foo: 'bar' })
|
|
mod.scanApi.hide('d1')
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/hide')
|
|
mod.scanApi.ignore('d1')
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
|
mod.scanApi.bulkApprove(['a', 'b'])
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'], design_id: undefined })
|
|
mod.scanApi.bulkApprove(['a'], 'design-9')
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' })
|
|
mod.scanApi.bulkHide(['a'])
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
|
mod.scanApi.restore('d1')
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/restore')
|
|
mod.scanApi.bulkRestore(['a'])
|
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-restore', { device_ids: ['a'] })
|
|
mod.scanApi.stop('run-1')
|
|
expect(api.post).toHaveBeenCalledWith('/scan/run-1/stop')
|
|
mod.scanApi.getConfig()
|
|
expect(api.get).toHaveBeenCalledWith('/scan/config')
|
|
mod.scanApi.saveConfig({ ranges: ['1.0/24'] })
|
|
expect(api.post).toHaveBeenCalledWith('/scan/config', { ranges: ['1.0/24'] })
|
|
})
|
|
|
|
it('settingsApi get/save', () => {
|
|
mod.settingsApi.get()
|
|
expect(api.get).toHaveBeenCalledWith('/settings')
|
|
mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
|
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
|
})
|
|
|
|
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
|
const cfg = { mqtt_host: 'h', mqtt_port: 1883 }
|
|
mod.zigbeeApi.testConnection(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/zigbee/test-connection', cfg)
|
|
mod.zigbeeApi.importNetwork(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/zigbee/import', cfg)
|
|
mod.zigbeeApi.importToPending(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
|
})
|
|
|
|
it('zwaveApi.testConnection/importNetwork/importToPending', () => {
|
|
const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' }
|
|
mod.zwaveApi.testConnection(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg)
|
|
mod.zwaveApi.importNetwork(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg)
|
|
mod.zwaveApi.importToPending(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
|
|
})
|
|
|
|
it('proxmoxApi.testConnection/importNetwork/importToPending', () => {
|
|
const cfg = { host: 'pve', port: 8006, token_id: 'u@pam!t', token_secret: 's', verify_tls: true }
|
|
mod.proxmoxApi.testConnection(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/proxmox/test-connection', cfg)
|
|
mod.proxmoxApi.importNetwork(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/proxmox/import', cfg)
|
|
mod.proxmoxApi.importToPending(cfg)
|
|
expect(api.post).toHaveBeenCalledWith('/proxmox/import-pending', cfg)
|
|
})
|
|
|
|
it('proxmoxApi.getConfig/saveConfig hit /proxmox/config', () => {
|
|
mod.proxmoxApi.getConfig()
|
|
expect(api.get).toHaveBeenCalledWith('/proxmox/config')
|
|
const conf = { host: 'pve', port: 8006, verify_tls: true, sync_enabled: false, sync_interval: 3600 }
|
|
mod.proxmoxApi.saveConfig(conf)
|
|
expect(api.post).toHaveBeenCalledWith('/proxmox/config', conf)
|
|
})
|
|
})
|