feat(scan): add guided Linux agent setup in administration
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 29s

This commit is contained in:
2026-09-17 18:56:30 +02:00
parent 1329f164d8
commit 68c0a224e5
15 changed files with 869 additions and 89 deletions

View File

@@ -0,0 +1,129 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
const token = 'fedeo_agent_' + 'a'.repeat(64)
const scanner = 'fujitsu:ScanSnap iX1600:test'
const baseAgent = { id: agentId, name: 'Linux Büro', active: true, scannerNames: [], scanDefaults: {}, capabilities: {} }
async function setup(t, initialAgent = null, overrides = {}) {
const helpers = await import('../utils/agentInstaller.mjs')
const requests = [], notices = [], events = [], downloads = [], modals = []
const timers = new Map()
const mounted = [], unmounted = []
let timerId = 0
let reportedAgent = { ...baseAgent }
const context = vm.createContext({
...helpers, ref: vue.ref, computed: vue.computed, watch: vue.watch,
defineProps: () => ({ initialAgent }),
defineEmits: () => (...args) => events.push(args),
onMounted: handler => mounted.push(handler), onBeforeUnmount: handler => unmounted.push(handler),
useAdmin: () => ({
createInstanceAgent: async body => { requests.push(['create', body]); return { agent: { ...baseAgent }, token } },
updateInstanceAgent: async (id, body) => { requests.push(['update', id, body]); return { agent: { ...baseAgent, ...body } } },
rotateInstanceAgentToken: async id => { requests.push(['rotate', id]); return { token } },
getInstanceAgents: async () => ({ agents: [reportedAgent] }),
...overrides,
}),
useToast: () => ({ add: notice => notices.push(notice) }),
useModal: () => ({ open: (...args) => modals.push(args) }),
useRuntimeConfig: () => ({ public: { apiBase: '/backend' } }),
window: { location: { origin: 'https://fedeo.example.com' } },
bundle: JSON.parse(fs.readFileSync(__dirname + '/../assets/device-agent/bundle.json', 'utf8')),
FileScanModal: 'FileScanModal',
Blob, URL: { createObjectURL: blob => { downloads.push(blob); return 'blob:test' }, revokeObjectURL() {} },
document: { createElement: () => ({ click() {}, remove() {} }), body: { appendChild() {} } },
setTimeout: callback => { timers.set(++timerId, callback); return timerId },
clearTimeout: id => timers.delete(id), setInterval: () => -1, clearInterval() {},
})
const source = fs.readFileSync(__dirname + '/../components/AgentSetupWizard.vue', 'utf8')
.split('<script setup lang="ts">')[1].split('</script>')[0].replace(/^import .*$/gm, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }), context)
mounted.forEach(handler => handler())
await new Promise(setImmediate)
const close = () => unmounted.forEach(handler => handler())
t.after(close)
return { run: code => vm.runInContext(code, context), requests, notices, events, downloads, modals, timers, close, report: value => { reportedAgent = value } }
}
test('new setup creates one agent and downloads software with the correct reverse-proxy URL', async t => {
const ui = await setup(t)
await ui.run('prepareAgent()')
await new Promise(setImmediate)
assert.equal(ui.run('step.value'), 1)
ui.run('downloadInstaller()')
assert.equal(ui.requests.filter(item => item[0] === 'create').length, 1)
assert.equal(ui.downloads.length, 1)
assert.ok((await ui.downloads[0].text()).startsWith('#!/usr/bin/env bash'))
assert.equal(ui.run('endpointPreview.value'), 'https://fedeo.example.com/backend/instance-agent/heartbeat')
assert.equal(ui.run('downloaded.value'), true)
ui.run('backendUrl.value="https://other.example.com/backend"')
await new Promise(setImmediate)
assert.equal(ui.run('downloaded.value'), false)
})
test('existing agent requires explicit acknowledgement before token rotation', async t => {
const ui = await setup(t, { ...baseAgent })
await ui.run('prepareAgent()')
await ui.run('rotateToken()')
assert.equal(ui.requests.length, 0)
ui.run('rotateConfirmed.value=true')
await ui.run('rotateToken()')
assert.equal(ui.requests[0][0], 'rotate')
assert.equal(ui.run('tokenReady.value'), true)
assert.equal(ui.run('rotateConfirmed.value'), false)
})
test('stale or inactive agents cannot finish setup; fresh reports save the chosen duplex scanner', async t => {
const ui = await setup(t, { ...baseAgent })
ui.report({ ...baseAgent, lastSeenAt: new Date(Date.now() - 300000).toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
ui.run('step.value=2; checkingSince.value=Date.now()-1000')
await ui.run('checkConnection()')
await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), false)
assert.equal(ui.requests.length, 0)
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
await ui.run('checkConnection()')
ui.run('profile.value="ix1600"; step.value=3')
await ui.run('finishSetup()')
assert.equal(ui.run('saved.value'), true)
assert.equal(ui.requests[0][2].preferredScannerName, scanner)
assert.equal(ui.requests[0][2].scanDefaults.source, 'ADF Duplex')
assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0)
ui.run('openTestScan()')
assert.equal(ui.modals[0][1].scanData.agentId, agentId)
})
test('online agent without a detected scanner remains incomplete', async t => {
const ui = await setup(t, { ...baseAgent })
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), capabilities: { scan: true } })
await ui.run('checkConnection()')
await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), true)
assert.equal(ui.run('readyToSave.value'), false)
assert.equal(ui.requests.length, 0)
})
test('connection errors stop automatic requests and are visible for retry', async t => {
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } })
ui.run('step.value=2')
await ui.run('checkConnection()')
assert.equal(ui.run('connectionError.value'), 'offline')
assert.equal(ui.timers.size, 0)
})
test('closing setup clears credentials and does not schedule polling after an in-flight response', async t => {
let resolve
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: () => new Promise(done => { resolve = done }) })
ui.run('step.value=2; token.value="' + token + '"')
const pending = ui.run('checkConnection()')
ui.close()
resolve({ agents: [{ ...baseAgent, lastSeenAt: new Date().toISOString() }] })
await pending
assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0)
})