From e4e287a93a0252def961933981ed6014fd15620c Mon Sep 17 00:00:00 2001 From: bashrusakh Date: Wed, 21 Oct 2026 17:12:46 +1100 Subject: [PATCH] admin-ui: stop logging raw AxiosError on request failures The shared axios request interceptor attaches the session token to every request as config.headers['api-token'], and an axios error object carries error.config. Every request catch block that logged the raw error therefore printed the API token (and config.data, the request body) into devtools and into any console screenshot taken for support. Log a bounded diagnostic instead of the raw object at the request failure sites in the custom-client view (custom_build/list, custom_preset/list, custom_preset/delete, custom_build/delete), the dashboard, server health, server config and admin config loads. The response interceptor already toasts a bounded message for these failures, so no signal is lost. The preset custom_json parse failure is bounded to the error type only: the V8 JSON.parse message quotes an excerpt of the offending input, and custom_json can carry permanent_password and key. Add a regression test that drives the real implementations with an axios error carrying a canary token and body, and assert neither reaches the console while a bounded diagnostic remains. Run every file in admin-ui/tests/ in CI. Co-Authored-By: opencode --- .github/workflows/build.yml | 2 +- .../src/components/dashboard/ServerHealth.vue | 3 +- admin-ui/src/store/app.js | 3 +- admin-ui/src/views/custom-client/index.vue | 38 +- admin-ui/src/views/index/index.vue | 3 +- admin-ui/src/views/server/config.vue | 3 +- .../admin-ui-request-error-logging.test.mjs | 655 ++++++++++++++++++ 7 files changed, 693 insertions(+), 14 deletions(-) create mode 100644 admin-ui/tests/admin-ui-request-error-logging.test.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1af3663..0779dad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,7 +85,7 @@ jobs: cache-dependency-path: | api/go.sum go.work.sum - - run: node --test tests/custom-client-validation.test.mjs + - run: node --test tests/*.test.mjs rust: name: Rust checks diff --git a/admin-ui/src/components/dashboard/ServerHealth.vue b/admin-ui/src/components/dashboard/ServerHealth.vue index 8f5e682..20c17b5 100644 --- a/admin-ui/src/components/dashboard/ServerHealth.vue +++ b/admin-ui/src/components/dashboard/ServerHealth.vue @@ -167,7 +167,8 @@ export default defineComponent({ extraCount.value = Math.max(0, activeConnections.value - usage.value.length) error.value = false } catch (e) { - console.error('Failed to load server health', e) + // Bounded diagnostic: the raw AxiosError carries config.headers['api-token']. + console.error('Failed to load server health:', e?.message || String(e)) error.value = true } finally { loading.value = false diff --git a/admin-ui/src/store/app.js b/admin-ui/src/store/app.js index 8749cab..713753f 100644 --- a/admin-ui/src/store/app.js +++ b/admin-ui/src/store/app.js @@ -111,7 +111,8 @@ export const useAppStore = defineStore({ this.replaceAdminTitle(res.data.title) this.setting.hello = res.data.hello }).catch(err => { - console.warn('Failed to load admin config', err) + // Bounded diagnostic: the raw AxiosError carries config.headers['api-token']. + console.warn('Failed to load admin config:', err?.message || String(err)) }) }, replaceAdminTitle (newTitle) { diff --git a/admin-ui/src/views/custom-client/index.vue b/admin-ui/src/views/custom-client/index.vue index 07499e5..5727e82 100644 --- a/admin-ui/src/views/custom-client/index.vue +++ b/admin-ui/src/views/custom-client/index.vue @@ -994,7 +994,9 @@ export default defineComponent({ applyUploadFieldError(field, uploadRejectionMessage(e)) } else { applyUploadFieldError(field, T('ImageFieldUploadFailedError')) - console.error(e) + // Bounded diagnostic only: an unexpected failure can still carry the + // upload request config, so the raw object must never be printed. + console.error('Custom client image upload failed:', e?.message || String(e)) } } } @@ -1425,7 +1427,12 @@ export default defineComponent({ builds.value = nextBuilds total.value = res.data.total || 0 } catch (e) { - if (lifecycleGuard.isCurrent('builds', generation)) console.error(e) + // The response interceptor already toasted the bounded server message, so + // only a bounded diagnostic is logged here. Logging the raw AxiosError + // would print config.headers['api-token'] and the request body. + if (lifecycleGuard.isCurrent('builds', generation)) { + console.error('Failed to load custom builds:', e?.message || String(e)) + } } finally { if (lifecycleGuard.isCurrent('builds', generation) && !silent) loading.value = false } @@ -1498,7 +1505,10 @@ export default defineComponent({ if (!lifecycleGuard.isCurrent('presets', generation)) return presets.value = res.data.list || [] } catch (e) { - if (lifecycleGuard.isCurrent('presets', generation)) console.error(e) + // Bounded diagnostic: the raw AxiosError carries config.headers['api-token']. + if (lifecycleGuard.isCurrent('presets', generation)) { + console.error('Failed to load custom presets:', e?.message || String(e)) + } } } @@ -1562,7 +1572,10 @@ export default defineComponent({ syncFieldAria() ElMessage.success(T('OperationSuccess')) } catch (e) { - console.error('preset custom_json parse error', e) + // The V8 JSON.parse message quotes an excerpt of the offending input, + // and preset.custom_json can contain permanent_password/key. Log the + // error type only — never the message, never the object. + console.error('preset custom_json parse error:', e?.name || 'Error') } } @@ -1624,8 +1637,10 @@ export default defineComponent({ await nextTick() presetSelectRef.value?.focus?.() } catch (e) { - // Errors are toasted globally by the axios interceptor; just log here. - if (e !== 'cancel') console.error(e) + // Errors are toasted globally by the axios interceptor; log a bounded + // diagnostic only. The raw AxiosError carries config.headers['api-token'] + // and the request body. + if (e !== 'cancel') console.error('Failed to delete custom preset:', e?.message || String(e)) } } @@ -1697,8 +1712,10 @@ export default defineComponent({ loadBuilds() } catch (e) { // Backend/network errors are already toasted globally by the axios - // response interceptor (utils/request.js); only log unexpected JS errors. - if (e !== 'cancel') console.error(e) + // response interceptor (utils/request.js); log a bounded diagnostic only + // for unexpected JS errors. The raw AxiosError carries + // config.headers['api-token'] and the request body. + if (e !== 'cancel') console.error('Failed to delete custom build:', e?.message || String(e)) } } @@ -1804,7 +1821,10 @@ export default defineComponent({ applyServerConfigDefaults() } } catch (e) { - if (lifecycleGuard.isCurrent('config', generation)) console.warn('fetchConfig failed:', e) + // Bounded diagnostic: the raw AxiosError carries config.headers['api-token']. + if (lifecycleGuard.isCurrent('config', generation)) { + console.warn('fetchConfig failed:', e?.message || String(e)) + } } } diff --git a/admin-ui/src/views/index/index.vue b/admin-ui/src/views/index/index.vue index 6f6127f..dc08ec6 100644 --- a/admin-ui/src/views/index/index.vue +++ b/admin-ui/src/views/index/index.vue @@ -149,7 +149,8 @@ export default defineComponent({ const logRes = await fetchLoginLogs({ page: 1, page_size: 10 }) recentLogs.value = logRes.data.list || [] } catch (e) { - console.error('Failed to load dashboard', e) + // Bounded diagnostic: the raw AxiosError carries config.headers['api-token']. + console.error('Failed to load dashboard:', e?.message || String(e)) } finally { loading.value = false } diff --git a/admin-ui/src/views/server/config.vue b/admin-ui/src/views/server/config.vue index 36934de..5bdfb95 100644 --- a/admin-ui/src/views/server/config.vue +++ b/admin-ui/src/views/server/config.vue @@ -78,7 +78,8 @@ export default defineComponent({ const res = await fetchAllConfig() cfg.value = res.data || {} } catch (e) { - console.error(e) + // Bounded diagnostic: the raw AxiosError carries config.headers['api-token']. + console.error('Failed to load server config:', e?.message || String(e)) } finally { loading.value = false } diff --git a/admin-ui/tests/admin-ui-request-error-logging.test.mjs b/admin-ui/tests/admin-ui-request-error-logging.test.mjs new file mode 100644 index 0000000..1eefbaa --- /dev/null +++ b/admin-ui/tests/admin-ui-request-error-logging.test.mjs @@ -0,0 +1,655 @@ +// Run with: node --test tests/*.test.mjs +// Issue #96 regression guard: a failing admin-ui HTTP request must never print +// the raw axios error. +// +// A raw AxiosError carries `error.config`, and the shared axios request +// interceptor attaches the session token to EVERY request as +// `config.headers['api-token']` (src/utils/request.js). `config.data` carries +// the request body. Printing the object therefore prints the API token and the +// request body into devtools and into any console screenshot taken for support. +// +// The same holds for a JSON.parse failure: the V8 SyntaxError message quotes an +// excerpt of the offending input, so a preset's custom_json (which can hold +// permanent_password/key) must never reach the console through a message either. +// +// These tests drive the REAL implementations. Every audited module is read from +// source, the relevant slice is extracted by string boundaries, evaluated in a +// node:vm context with a stubbed console, and then called with an axios error +// whose config carries a canary token and a canary body. Nothing here +// re-implements the production code path: if a site starts logging the raw +// object again, the runtime assertions below fail. +// +// The same pattern (read the SFC, extract a slice by string boundaries, run it +// in a node:vm context with a stubbed console) is used by +// tests/custom-client-validation.test.mjs. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' +import { ref, reactive, nextTick } from 'vue' +import axios from 'axios' + +// Canary secrets. These values appear nowhere else in the repository: if one of +// them shows up in a console log, an axios error object reached the console. +const LEAK_TOKEN = 'LEAK_CANARY_API_TOKEN_96' +const LEAK_BODY = 'LEAK_CANARY_BODY_96' +// The bounded diagnostic must still carry the useful part of the failure. +const BOUNDED_MESSAGE = 'Request failed with status code 500' + +const readSource = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8') + +// Slice a source file between two literal boundaries, the way the existing +// custom-client test does. The end marker is exclusive. +const between = (source, start, end) => { + assert.ok(source.includes(start) && source.includes(end), `missing source boundary: ${start}`) + return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start))) +} + +// A real axios error whose config retains the request body and the api-token +// header — the exact shape the shared response interceptor rejects with. +const axiosErrorWithSecrets = () => new axios.AxiosError( + BOUNDED_MESSAGE, + 'ERR_BAD_RESPONSE', + { data: LEAK_BODY, headers: { 'api-token': LEAK_TOKEN } }, + {}, + { status: 500, data: { code: 101, message: 'OperationFailed', data: null } }, +) + +// Capture every console channel so a site that logs through warn/error/info +// is covered by the same assertions. +const makeConsole = () => { + const calls = [] + const record = (method) => (...args) => { calls.push({ method, args }) } + return { + calls, + console: { + error: record('error'), + warn: record('warn'), + log: record('log'), + info: record('info'), + debug: record('debug'), + }, + } +} + +// Render a logged value the way a browser console actually displays it. +// +// This must NOT be plain JSON.stringify: a real Error's `message` and `stack` +// are non-enumerable, so JSON.stringify(error) yields "{}" and would hide the +// exact leak this file guards (the V8 JSON.parse message quotes an excerpt of +// the offending input). A browser console renders the error's message and +// stack, so this renderer does the same for Error-like values. +const renderLoggedValue = (value) => { + if (typeof value === 'string') return value + if (value instanceof Error) { + return `${value.name}: ${value.message}${value.stack ? `\n${value.stack}` : ''}` + } + try { return JSON.stringify(value) } catch { return String(value) } +} + +const serializedLogs = (calls) => calls + .map(({ args }) => args.map(renderLoggedValue).join(' ')) + .join('\n') + +// Half 1 of the contract: the raw config/headers/body must not be present in +// what was logged, and no raw error object may be handed to the console. +const assertNoRequestSecretsLogged = (calls, label) => { + const rendered = serializedLogs(calls) + assert.equal(rendered.includes(LEAK_TOKEN), false, + `${label}: the api-token must never be logged, got: ${rendered}`) + assert.equal(rendered.includes(LEAK_BODY), false, + `${label}: the request body must never be logged, got: ${rendered}`) + for (const { method, args } of calls) { + for (const arg of args) { + assert.equal(typeof arg, 'string', + `${label}: console.${method} must not receive a raw error object`) + } + } +} + +// Half 2 of the contract: a bounded diagnostic is still logged, and it carries +// the failure's message so the log stays useful. +const assertBoundedDiagnosticLogged = (calls, label) => { + assert.ok(calls.length > 0, `${label}: a bounded diagnostic must still be logged`) + const rendered = serializedLogs(calls) + assert.ok(rendered.includes(BOUNDED_MESSAGE), + `${label}: the bounded diagnostic must carry the error message, got: ${rendered}`) +} + +// Supplementary source check: inside an audited function, no console call may +// receive the caught error identifier in a form that can carry request +// internals. This is a belt-and-braces guard on top of the runtime assertions +// above, not a replacement for them. +// +// Flagged (leaks): +// console.error(e) — the raw object +// console.error('m', e) — the raw object alongside a message +// console.error('m', e, more) — the raw object as a NON-final argument +// console.error(`m ${e}`) — template interpolation +// console.error('m', e?.config) — a property of the raw object +// +// NOT flagged (bounded by construction): +// console.error('m', e?.message) — the error's own message +// console.error('m', e.message) +// console.error('m', e?.name) — a class-level identifier +// console.error('m', e?.name || 'Error') +// +// The argument list is extracted by counting parentheses, so a message argument +// that itself contains parentheses does not truncate the scan. +const CONSOLE_CALL = /console\.\w+\(/g + +// Split a call's argument list on top-level commas (commas nested in +// parentheses, brackets, braces, or strings do not separate arguments). +const splitArguments = (argumentText) => { + const args = [] + let depth = 0 + let quote = null + let current = '' + for (let i = 0; i < argumentText.length; i++) { + const ch = argumentText[i] + const prev = argumentText[i - 1] + if (quote) { + current += ch + if (ch === quote && prev !== '\\') quote = null + continue + } + if (ch === '"' || ch === "'" || ch === '`') { quote = ch; current += ch; continue } + if (ch === '(' || ch === '[' || ch === '{') { depth++; current += ch; continue } + if (ch === ')' || ch === ']' || ch === '}') { depth--; current += ch; continue } + if (ch === ',' && depth === 0) { args.push(current); current = ''; continue } + current += ch + } + if (current.trim() !== '') args.push(current) + return args.map(arg => arg.trim()) +} + +// Find the argument list of every console.(...) call in the source. +const consoleCallArguments = (source) => { + const calls = [] + let match + CONSOLE_CALL.lastIndex = 0 + while ((match = CONSOLE_CALL.exec(source)) !== null) { + let depth = 1 + let i = match.index + match[0].length + let quote = null + for (; i < source.length && depth > 0; i++) { + const ch = source[i] + const prev = source[i - 1] + if (quote) { + if (ch === quote && prev !== '\\') quote = null + continue + } + if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue } + if (ch === '(') depth++ + else if (ch === ')') depth-- + } + if (depth === 0) { + calls.push({ + call: match[0].replace(/\($/, ''), + args: splitArguments(source.slice(match.index + match[0].length, i - 1)), + }) + CONSOLE_CALL.lastIndex = i + } + } + return calls +} + +// An argument that references the caught error identifier in an unbounded way. +// A bare identifier, a member access that is not a bounded scalar property, or +// a template literal that interpolates the identifier. +// +// The bounded scalar properties are `message` and `name`: a message is the +// error's own text and a name is a class-level identifier ("SyntaxError"). +// `String(e)` is bounded too — it stringifies the error rather than passing +// the object on. +const ERROR_IDENTIFIER = /\b(err|e)\b/g +const BOUNDED_ERROR_PROPERTIES = ['message', 'name'] + +const isUnboundedErrorReference = (argument) => { + const text = argument.trim() + if (text === '') return false + // Template interpolation: `... ${e} ...` (or ${e?.message}, which is bounded). + if (text.includes('${')) { + const interpolations = [...text.matchAll(/\$\{([^}]*)\}/g)].map(m => m[1].trim()) + return interpolations.some(isUnboundedErrorReference) + } + // Every occurrence of the error identifier is examined, so an argument that + // mixes a bounded and an unbounded reference is still flagged. + let unbounded = false + ERROR_IDENTIFIER.lastIndex = 0 + let match + while ((match = ERROR_IDENTIFIER.exec(text)) !== null) { + // `String(e)` stringifies the error: bounded, not a raw object. + if (/\bString\(\s*$/.test(text.slice(0, match.index))) continue + const rest = text.slice(match.index + match[0].length) + const property = rest.match(/^\s*(?:\?\.)?\.?\s*([A-Za-z_$][\w$]*)/) + if (property) { + if (!BOUNDED_ERROR_PROPERTIES.includes(property[1])) unbounded = true + } else { + // A bare identifier reference. + unbounded = true + } + } + return unbounded +} + +const assertNoRawErrorArgument = (source, label) => { + for (const { call, args } of consoleCallArguments(source)) { + for (const argument of args) { + assert.equal(isUnboundedErrorReference(argument), false, + `${label}: ${call}(...) must not receive the caught error unbounded, got: ${argument}`) + } + } +} + +// --- fixtures --------------------------------------------------------------- + +// src/views/custom-client/index.vue: the four request paths named by issue #96 +// (custom_preset/list, custom_build/list, custom_preset/delete, +// custom_build/delete), the config/all fetch, and the preset custom_json parse +// (a JSON.parse failure, whose V8 message quotes an excerpt of the input). +const customClientSource = readSource('../src/views/custom-client/index.vue') +const customClientSlices = { + lifecycleGuard: between(customClientSource, + ' const requestGenerations = Object.create(null)', ' // B-017: при silent=true'), + loadBuilds: between(customClientSource, + ' const loadBuilds = async (silent = false) => {', '\n // B-017: история билдов не обновлялась сама'), + loadPresets: between(customClientSource, + ' const loadPresets = async () => {', '\n // Single source of truth for the fields persisted inside custom_json.'), + deletePreset: between(customClientSource, + ' const deletePreset = async (preset) => {', '\n const submitBuild = async () => {'), + deleteBuild: between(customClientSource, + ' const deleteBuild = async (row) => {', '\n const resetForm = () => {'), + loadConfig: between(customClientSource, + ' const loadConfig = async () => {', '\n onMounted(async () => {'), + loadPresetIntoForm: between(customClientSource, + ' const loadPresetIntoForm = async (preset) => {', '\n const onPresetSelect = (id) => {'), +} + +function customClientFixture (apis) { + const { calls, console } = makeConsole() + const context = { + ref, reactive, nextTick, axios, + console, calls, + T: value => value, + onUnmounted: () => {}, + ElMessage: { success: () => {} }, + ElMessageBox: { confirm: async () => true }, + list: apis.list, + listPresets: apis.listPresets, + removePreset: apis.removePreset, + remove: apis.remove, + fetchConfig: apis.fetchConfig, + applyServerConfigDefaults: () => {}, + builds: ref([]), + total: ref(0), + loading: ref(false), + presets: ref([]), + selectedPresetId: ref(null), + presetSelectRef: ref(null), + serverConfigDefaults: reactive({ key: '' }), + publicApiServerMissing: ref(false), + page: ref(1), + pageSize: ref(10), + // State the preset loader touches. + form: reactive({}), + formRef: ref(null), + explicitPresetFields: ref(new Set()), + invalidFields: ref({}), + serverFieldErrors: ref({}), + resetFormFields: () => {}, + syncFieldAria: () => {}, + ensurePolling: () => {}, + announceBuildStatusChanges: () => {}, + hasActiveBuilds: () => false, + PRESET_FIELDS: [], + } + vm.createContext(context) + vm.runInContext( + customClientSlices.lifecycleGuard + + customClientSlices.loadBuilds + + customClientSlices.loadPresets + + customClientSlices.deletePreset + + customClientSlices.deleteBuild + + customClientSlices.loadConfig + + customClientSlices.loadPresetIntoForm + ` + lifecycleGuard.mounted = true; + globalThis.api = { lifecycleGuard, loadBuilds, loadPresets, deletePreset, deleteBuild, loadConfig, loadPresetIntoForm }; + `, context) + return context +} + +// src/components/dashboard/ServerHealth.vue — the dashboard/health poll. +const serverHealthSource = readSource('../src/components/dashboard/ServerHealth.vue') +const serverHealthSlice = between(serverHealthSource, + ' const REFRESH_SECONDS = 15', '\n const refreshNow = () => {') + +function serverHealthFixture (fetchHealth) { + const { calls, console } = makeConsole() + const context = { ref, console, calls, T: value => value, fetchHealth } + vm.createContext(context) + vm.runInContext(serverHealthSlice + '\n globalThis.api = { load };', context) + return context +} + +// src/views/index/index.vue — the dashboard stats / login-log load. +const dashboardSource = readSource('../src/views/index/index.vue') +const dashboardSlice = between(dashboardSource, + ' const loadData = async () => {', '\n const connectNative = () => {') + +function dashboardFixture (fetchStats, fetchLoginLogs) { + const { calls, console } = makeConsole() + const context = { + ref, console, calls, T: value => value, + fetchStats, fetchLoginLogs, + stats: ref({}), + recentLogs: ref([]), + loading: ref(true), + } + vm.createContext(context) + vm.runInContext(dashboardSlice + '\n globalThis.api = { loadData };', context) + return context +} + +// src/views/server/config.vue — the /config/all load. +const serverConfigSource = readSource('../src/views/server/config.vue') +const serverConfigSlice = between(serverConfigSource, + ' const loadData = async () => {', '\n onMounted(loadData)') + +function serverConfigFixture (fetchAllConfig) { + const { calls, console } = makeConsole() + const context = { + ref, console, calls, T: value => value, + onMounted: () => {}, + fetchAllConfig, + cfg: ref({}), + loading: ref(true), + } + vm.createContext(context) + vm.runInContext(serverConfigSlice + '\n globalThis.api = { loadData };', context) + return context +} + +// src/store/app.js — the getAdminConfig action. +const appStoreSource = readSource('../src/store/app.js') +const appStoreSlice = between(appStoreSource, + ' getAdminConfig () {', '\n replaceAdminTitle (newTitle) {') + +function appStoreFixture (admin) { + const { calls, console } = makeConsole() + const context = { console, calls, admin } + vm.createContext(context) + vm.runInContext( + `const __store = { ${appStoreSlice} };\n globalThis.api = __store;`, context) + return context +} + +// --- the four request paths named by issue #96 ------------------------------- + +test('custom_build/list failure does not log the raw axios error', async () => { + const ctx = customClientFixture({ + list: async () => { throw axiosErrorWithSecrets() }, + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + await ctx.api.loadBuilds() + assertNoRequestSecretsLogged(ctx.calls, 'custom_build/list') + assertBoundedDiagnosticLogged(ctx.calls, 'custom_build/list') + assertNoRawErrorArgument(customClientSlices.loadBuilds, 'custom_build/list') +}) + +test('custom_preset/list failure does not log the raw axios error', async () => { + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => { throw axiosErrorWithSecrets() }, + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + await ctx.api.loadPresets() + assertNoRequestSecretsLogged(ctx.calls, 'custom_preset/list') + assertBoundedDiagnosticLogged(ctx.calls, 'custom_preset/list') + assertNoRawErrorArgument(customClientSlices.loadPresets, 'custom_preset/list') +}) + +test('custom_preset/delete failure does not log the raw axios error', async () => { + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => ({}), + removePreset: async () => { throw axiosErrorWithSecrets() }, + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + await ctx.api.deletePreset({ id: 7 }) + assertNoRequestSecretsLogged(ctx.calls, 'custom_preset/delete') + assertBoundedDiagnosticLogged(ctx.calls, 'custom_preset/delete') + assertNoRawErrorArgument(customClientSlices.deletePreset, 'custom_preset/delete') +}) + +test('custom_build/delete failure does not log the raw axios error', async () => { + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => { throw axiosErrorWithSecrets() }, + fetchConfig: async () => ({}), + }) + await ctx.api.deleteBuild({ id: 9 }) + assertNoRequestSecretsLogged(ctx.calls, 'custom_build/delete') + assertBoundedDiagnosticLogged(ctx.calls, 'custom_build/delete') + assertNoRawErrorArgument(customClientSlices.deleteBuild, 'custom_build/delete') +}) + +// --- the preset custom_json parse path --------------------------------------- + +// A preset's custom_json can hold the secret-bearing fields (permanent_password +// and key). A malformed one makes JSON.parse throw a SyntaxError whose message +// quotes an EXCERPT of the offending input, so the excerpt — not the whole +// value — is what leaks. +// +// The canary is chosen so that the excerpt V8 produces is a recognisable +// substring of it. Measured on Node 20, 22 and 24, parsing +// `{"permanent_password":ZQX7LEAKCANARY96}` yields exactly: +// +// Unexpected token 'Z', ..."password":ZQX7LEAKCA"... is not valid JSON +// +// i.e. the excerpt carries the first 10 characters of the value +// ("ZQX7LEAKCA"). Asserting on the FULL value alone would not discriminate — +// the excerpt is a fragment — so the fragment is asserted as well. +const PRESET_SECRET_CANARY = 'ZQX7LEAKCANARY96' +const PRESET_SECRET_FRAGMENT = 'ZQX7LEAKCA' + +test('a malformed preset custom_json never logs the secret or any fragment of it', async () => { + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + // The unquoted value makes V8 quote an excerpt of it in the SyntaxError. + const malformed = `{"permanent_password":${PRESET_SECRET_CANARY}}` + await ctx.api.loadPresetIntoForm({ id: 1, custom_json: malformed }) + + const rendered = serializedLogs(ctx.calls) + assert.equal(rendered.includes(PRESET_SECRET_CANARY), false, + `the preset secret must never be logged, got: ${rendered}`) + assert.equal(rendered.includes(PRESET_SECRET_FRAGMENT), false, + `no fragment of the preset secret may be logged, got: ${rendered}`) + // The bounded diagnostic must still be present: the operation marker and the + // error TYPE (never the message, which is derived from the input). + assert.ok(rendered.includes('preset custom_json parse error'), + `the operation marker must still be logged, got: ${rendered}`) + assert.ok(rendered.includes('SyntaxError'), + `the error type must still be logged, got: ${rendered}`) + // No raw error object may reach the console either. + for (const { method, args } of ctx.calls) { + for (const arg of args) { + assert.equal(typeof arg, 'string', + `console.${method} must not receive a raw error object`) + } + } + assertNoRawErrorArgument(customClientSlices.loadPresetIntoForm, 'preset custom_json parse') +}) + +test('a malformed preset custom_json is diagnosed without the input-derived message', async () => { + // The message is derived from the input, so it must not be logged at all — + // not even a bounded slice of it. This asserts the message itself is absent + // while the class-level error name is present. + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + const malformed = `{"permanent_password":${PRESET_SECRET_CANARY}}` + await ctx.api.loadPresetIntoForm({ id: 1, custom_json: malformed }) + + const rendered = serializedLogs(ctx.calls) + assert.equal(rendered.includes('Unexpected token'), false, + `the input-derived parse message must never be logged, got: ${rendered}`) + assert.equal(rendered.includes('is not valid JSON'), false, + `the input-derived parse message must never be logged, got: ${rendered}`) + assert.ok(rendered.includes('SyntaxError'), + `the error type must still be logged, got: ${rendered}`) +}) + +// --- the remaining audited request failures ---------------------------------- + +test('the custom-client config load failure does not log the raw axios error', async () => { + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => { throw axiosErrorWithSecrets() }, + }) + await ctx.api.loadConfig() + assertNoRequestSecretsLogged(ctx.calls, 'config/all') + assertBoundedDiagnosticLogged(ctx.calls, 'config/all') + assertNoRawErrorArgument(customClientSlices.loadConfig, 'config/all') +}) + +test('the server-health poll failure does not log the raw axios error', async () => { + const ctx = serverHealthFixture(async () => { throw axiosErrorWithSecrets() }) + await ctx.api.load() + assertNoRequestSecretsLogged(ctx.calls, 'dashboard/health') + assertBoundedDiagnosticLogged(ctx.calls, 'dashboard/health') + assertNoRawErrorArgument(serverHealthSlice, 'dashboard/health') +}) + +test('the dashboard load failure does not log the raw axios error', async () => { + const ctx = dashboardFixture( + async () => { throw axiosErrorWithSecrets() }, + async () => ({}), + ) + await ctx.api.loadData() + assertNoRequestSecretsLogged(ctx.calls, 'dashboard/stats') + assertBoundedDiagnosticLogged(ctx.calls, 'dashboard/stats') + assertNoRawErrorArgument(dashboardSlice, 'dashboard/stats') +}) + +test('the server-config load failure does not log the raw axios error', async () => { + const ctx = serverConfigFixture(async () => { throw axiosErrorWithSecrets() }) + await ctx.api.loadData() + assertNoRequestSecretsLogged(ctx.calls, 'config/all') + assertBoundedDiagnosticLogged(ctx.calls, 'config/all') + assertNoRawErrorArgument(serverConfigSlice, 'config/all') +}) + +test('the admin-config store load failure does not log the raw axios error', async () => { + const ctx = appStoreFixture(async () => { throw axiosErrorWithSecrets() }) + await ctx.api.getAdminConfig() + assertNoRequestSecretsLogged(ctx.calls, 'config/admin') + assertBoundedDiagnosticLogged(ctx.calls, 'config/admin') + assertNoRawErrorArgument(appStoreSlice, 'config/admin') +}) + +// --- preserved behaviour ----------------------------------------------------- +// The fix must not change what the UI does. These cases pin the neighbouring +// behaviour of the same code paths: a successful request still populates the +// list, a cancelled confirm stays silent, and the stale-response guard still +// suppresses the log for a superseded request. + +test('a successful custom_build/list still populates the build list', async () => { + const ctx = customClientFixture({ + list: async () => ({ data: { list: [{ id: 1 }], total: 1 } }), + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + await ctx.api.loadBuilds() + assert.equal(ctx.builds.value.length, 1) + assert.equal(ctx.total.value, 1) + assert.equal(ctx.calls.length, 0, 'a successful request must not log anything') +}) + +test('a successful custom_preset/list still populates the preset list', async () => { + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => ({ data: { list: [{ id: 3 }] } }), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + await ctx.api.loadPresets() + assert.equal(ctx.presets.value.length, 1) + assert.equal(ctx.calls.length, 0) +}) + +test('a cancelled delete confirm stays silent', async () => { + const ctx = customClientFixture({ + list: async () => ({}), + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + // ElMessageBox.confirm rejects with the literal 'cancel' when dismissed. + ctx.ElMessageBox.confirm = async () => { throw 'cancel' } + await ctx.api.deletePreset({ id: 7 }) + await ctx.api.deleteBuild({ id: 9 }) + assert.equal(ctx.calls.length, 0, 'a dismissed confirm is not a failure and must stay silent') +}) + +test('a superseded request is not logged at all', async () => { + // The per-operation generation guard means a response from a stale request + // must neither update state nor reach the console: only the CURRENT + // generation logs, so two overlapping loads produce exactly one bounded + // diagnostic, not two. + const ctx = customClientFixture({ + list: async () => { throw axiosErrorWithSecrets() }, + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + // Start a first load, then start a second one so the first generation is stale. + const first = ctx.api.loadBuilds() + const second = ctx.api.loadBuilds() + await Promise.all([first, second]) + assert.equal(ctx.calls.length, 1, + 'only the current generation may log a bounded diagnostic') + assertNoRequestSecretsLogged(ctx.calls, 'superseded custom_build/list') +}) + +test('a non-axios failure still logs a bounded diagnostic with its message', async () => { + // A genuinely unexpected JS failure (no axios markers) must still be + // visible to a developer, but only as a bounded string. + const ctx = customClientFixture({ + list: async () => { throw new Error('boom in list') }, + listPresets: async () => ({}), + removePreset: async () => ({}), + remove: async () => ({}), + fetchConfig: async () => ({}), + }) + await ctx.api.loadBuilds() + assert.equal(ctx.calls.length, 1) + const rendered = serializedLogs(ctx.calls) + assert.ok(rendered.includes('boom in list'), + `the bounded diagnostic must carry the message, got: ${rendered}`) +})