feat(selfhost): show sanitized update logs in admin UI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 5m45s

This commit is contained in:
2026-09-25 18:42:26 +02:00
parent 8f06d06037
commit 29155f916a
9 changed files with 398 additions and 9 deletions

View File

@@ -99,3 +99,90 @@ test('an older polling response cannot erase a new submission whose response was
assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false)
})
test('failed jobs automatically show logs and refresh them only while open', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request', error: { code: 'UPDATE_FAILED', message: 'Auftrag fehlgeschlagen.' } }
let content = 'pull access denied'
const f = await setup(t, async path => {
if (path.endsWith('/logs')) return { requestId: failed.requestId, logs: [{ source: 'commands', content, truncated: false }] }
return path.endsWith('/status') ? { ...overview(failed), capabilities: { requestLogs: true } } : failed
})
assert.equal(f.ui.logsOpen.value, true)
assert.equal(f.ui.logEntries.value[0].content, content)
content = 'final failure detail'
await f.ui.refresh()
assert.equal(f.ui.logEntries.value[0].content, content)
await f.ui.toggleLogs()
const count = f.calls.filter(c => c.path.endsWith('/logs')).length
await f.ui.refresh()
assert.equal(f.ui.logsOpen.value, false)
assert.equal(f.calls.filter(c => c.path.endsWith('/logs')).length, count)
})
test('older host services explain the required upgrade without requesting unsupported endpoints', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request' }
const f = await setup(t, async path => path.endsWith('/status') ? overview(failed) : failed)
assert.equal(f.ui.logsOpen.value, true)
assert.match(f.ui.logsMessage.value, /Host-Update-Dateien aktualisieren/)
assert.equal(f.calls.some(c => c.path.endsWith('/logs')), false)
assert.equal(f.ui.connected.value, true)
})
test('log failures preserve job state and revoked admin access clears the displayed logs', async t => {
let failure
const f = await setup(t, async path => {
if (path.endsWith('/logs')) {
if (failure) throw failure
return { requestId: check.requestId, logs: [{ source: 'commands', content: 'diagnostic', truncated: false }] }
}
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
await f.ui.toggleLogs()
failure = { statusCode: 503 }
await f.ui.refreshLogs()
assert.match(f.ui.logsMessage.value, /konnte nicht geladen/)
assert.equal(f.ui.connected.value, true)
assert.equal(f.ui.job.value.status, 'checked')
failure = { statusCode: 403 }
await f.ui.refreshLogs()
assert.equal(f.ui.accessDenied.value, true)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('a delayed log response cannot appear under a newly submitted job', async t => {
let release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', requestId: options.body.requestId, command: 'check' }
if (path.endsWith('/logs')) return new Promise(resolve => { release = resolve })
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
const loading = f.ui.toggleLogs()
await new Promise(setImmediate)
await f.ui.check()
release({ requestId: check.requestId, logs: [{ source: 'commands', content: 'previous job', truncated: false }] })
await loading
assert.notEqual(f.ui.job.value.requestId, check.requestId)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('the update card renders log output as escaped text and explains truncation', async t => {
const f = await setup(t, async path => path.endsWith('/status') ? overview() : check)
f.ui.logsOpen.value = true
const { parse, compileScript } = require('@vue/compiler-sfc')
const descriptor = parse(fs.readFileSync(__dirname + '/../components/SelfhostUpdateCard.vue', 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: 'update-card-test', inlineTemplate: true }).content
const exports = {}
const context = vm.createContext({ exports, require, ref: vue.ref, computed: vue.computed,
useRuntimeConfig: () => ({ public: {} }), useSelfhostUpdates: () => ({ ...f.ui,
logEntries: vue.ref([{ source: 'commands', content: '<script>alert("log")</script>', truncated: true }]) }) })
vm.runInContext(ts.transpile(compiled, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
const app = vue.createSSRApp(exports.default)
for (const name of ['UCard', 'UIcon', 'UBadge', 'UAlert', 'UProgress', 'UButton', 'UModal', 'UCheckbox']) {
app.component(name, { setup: (_props, { slots }) => () => vue.h('div', slots.default?.()) })
}
const html = await require('@vue/server-renderer').renderToString(app)
assert.match(html, /Werkzeugausgaben/)
assert.match(html, /Gekürzte Anzeige/)
assert.match(html, /&lt;script&gt;/)
assert.doesNotMatch(html, /<script>/)
})