feat(scan): add guided Linux agent setup in administration
This commit is contained in:
129
frontend/tests/agent-setup.test.cjs
Normal file
129
frontend/tests/agent-setup.test.cjs
Normal 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)
|
||||
})
|
||||
@@ -4,21 +4,21 @@ const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
|
||||
async function setup(getJob) {
|
||||
async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) {
|
||||
const notices = []
|
||||
const requests = []
|
||||
let unmount
|
||||
const context = vm.createContext({
|
||||
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch,
|
||||
onBeforeUnmount: handler => { unmount = handler },
|
||||
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document' } }),
|
||||
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document', ...scanData } }),
|
||||
defineEmits: () => () => {},
|
||||
useModal: () => ({ close() {} }),
|
||||
useToast: () => ({ add: notice => notices.push(notice) }),
|
||||
setTimeout: callback => queueMicrotask(callback),
|
||||
useNuxtApp: () => ({ $api: async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url === '/api/instance-agents') return { agents: [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }] }
|
||||
if (url === '/api/instance-agents') return { agents }
|
||||
if (url === '/api/scan-jobs') return { job: { id: 'job' } }
|
||||
return getJob()
|
||||
} }),
|
||||
@@ -70,3 +70,16 @@ test('iX1600 preset resets image settings and normalizes the output filename to
|
||||
assert.equal(ui.run('scanForm.postprocess'), false)
|
||||
assert.equal(ui.run('scanForm.resolution'), 300)
|
||||
})
|
||||
|
||||
test('setup test scan uses the requested agent even when another agent is first', async () => {
|
||||
const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true }, scannerNames: ['scanner-' + id] }))
|
||||
const ui = await setup(() => ({ job: { status: 'completed' } }), { agentId: 'configured' }, agents)
|
||||
await ui.run('startScan()')
|
||||
assert.equal(ui.requests.find(request => request.url === '/api/scan-jobs').options.body.agentId, 'configured')
|
||||
})
|
||||
|
||||
test('an unavailable setup agent never silently scans with another device', async () => {
|
||||
const ui = await setup(() => ({}), { agentId: 'missing' })
|
||||
await ui.run('startScan()')
|
||||
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user