Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin-ui/src/api/custom_preset.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function create (data) {
url: '/custom_preset/create',
method: 'post',
data,
useServerErrorMessage: true,
})
}

Expand Down
33 changes: 23 additions & 10 deletions admin-ui/src/views/custom-client/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1091,15 +1091,16 @@ export default defineComponent({
return Object.keys(snapshot.values).every((field) => snapshot.values[field] === form[field])
}

// Classify a create failure. A 400 carries machine-readable per-field reasons
// (data.fields) and the axios response interceptor has already toasted a
// bounded server message; `applyServerFieldErrors` applies those reasons or,
// per F-B, intentionally drops a stale response. Both outcomes are expected
// and already surfaced, so they are reported without an error object.
// Logging the raw AxiosError would expose config.data.custom_json (including
// permanent_password) and config.headers['api-token'] in the console. Only a
// genuinely unexpected failure is logged, in a bounded form that never
// includes the request config.
// Classify a build-create or preset-save failure. A 400 carries
// machine-readable per-field reasons (data.fields) and the axios response
// interceptor has already toasted a bounded server message;
// `applyServerFieldErrors` applies those reasons or, per F-B, intentionally
// drops a stale response. Both outcomes are expected and already surfaced,
// so they are reported without an error object. Logging the raw AxiosError
// would expose config.data.custom_json (including permanent_password) and
// config.headers['api-token'] in the console. Only a genuinely unexpected
// failure is logged, in a bounded form that never includes the request
// config.
const reportCreateBuildFailure = async (e, snapshot) => {
// A failed envelope arrives as the raw body object (request.js rejects
// `res`), so it carries neither axios markers nor necessarily data.fields.
Expand Down Expand Up @@ -1575,6 +1576,11 @@ export default defineComponent({
}

const saveCurrentAsPreset = async () => {
// A preset payload is exactly PRESET_FIELDS + the record columns, so a
// rejected save carries the same data.fields contract as a build create.
// Snapshot the submitted values before the request so a late response is
// not applied to a form that was edited in flight.
let submittedSnapshot
try {
const name = await ElMessageBox.prompt(T('PresetName'), T('SaveAsPreset'), {
inputPlaceholder: 'My Preset',
Expand All @@ -1586,6 +1592,7 @@ export default defineComponent({
const customPayload = {}
for (const f of PRESET_FIELDS) customPayload[f] = form[f]
const customJson = JSON.stringify(customPayload)
submittedSnapshot = snapshotSubmittedFields()
await createPreset({
name: name.value,
platform: form.platform,
Expand All @@ -1597,7 +1604,13 @@ export default defineComponent({
ElMessage.success(T('OperationSuccess'))
await loadPresets()
} catch (e) {
if (e !== 'cancel') console.error(e)
// Prompt cancellation stays silent. Reuse the build-create failure
// handling so data.fields (for example the hide_cm/pass_approve_mode
// incompatibility) renders through the same inline/ARIA pipeline and
// the raw AxiosError — which can carry custom_json/permanent_password
// and the api-token header — is never logged.
if (e === 'cancel') return
await reportCreateBuildFailure(e, submittedSnapshot)
}
}

Expand Down
140 changes: 132 additions & 8 deletions admin-ui/tests/custom-client-validation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,23 @@ const key = Buffer.alloc(32, 7).toString('base64')
// Exercise the real selection lookup and save handler; only the dialog/API and
// unrelated form-loading effects are stubbed. The prompt spy simulates editing
// the initial value before confirming.
//
// Saving a preset uses the same create-response contract as submitBuild (the
// preset payload is exactly PRESET_FIELDS + platform/version/app_name), so run
// the real snapshot/report/applyServerFieldErrors slices here rather than a
// fixture copy: a rejected save must reach the same inline/ARIA pipeline.
function presetSaveFixture() {
const calls = { prompts: [], payloads: [], refreshes: 0 }
const calls = { prompts: [], payloads: [], refreshes: 0, focused: [], scrolled: [] }
const logs = []
const context = {
ref, computed, T: value => value, PRESET_FIELDS,
form: reactive(formState()), presetPasswordClearIntent: ref(false),
reactive, ref, computed, nextTick, watch, atob, btoa, URL, T: value => value,
PRESET_FIELDS,
form: reactive(formState()),
formRef: ref(null),
serverConfigDefaults: reactive({ key: '' }),
document: { getElementById: () => null },
axios,
presetPasswordClearIntent: ref(false),
ElMessageBox: { prompt: async (message, title, options) => {
calls.prompts.push({ message, title, options })
return { value: context.editedName ?? options.inputValue ?? '' }
Expand All @@ -172,13 +184,25 @@ function presetSaveFixture() {
loadPresets: async () => { calls.refreshes++ },
loadPresetIntoForm: () => {},
resetForm: () => { context.api.selectedPresetId.value = null },
console,
fetchServerAddresses: async () => ({ data: { addresses: [] } }),
console: { error: (...args) => logs.push(args) },
logs,
}
vm.createContext(context)
vm.runInContext(between(' const presets = ref([])', ' const clearSavedPresetPassword') +
between(' const canPreservePresetPassword', ' const resetFormFields') +
vm.runInContext(
between(' const requiredFieldNames', ' const useServerKey') +
between(' const useServerKey', ' const isFieldInvalid') +
between(' const isFieldInvalid', ' const resetFormFields') +
between(' const requestGenerations = Object.create(null)', ' // B-017: при silent=true') +
between(' const presets = ref([])', ' const clearSavedPresetPassword') +
between(' const onPresetSelect', ' const deletePreset') + `
globalThis.api = { presets, selectedPresetId, onPresetSelect, saveCurrentAsPreset };
globalThis.api = { presets, selectedPresetId, onPresetSelect, saveCurrentAsPreset,
form, rules, requiredFieldSet, invalidFields, serverFieldErrors,
serverFieldError, serverFieldMessage, applyServerFieldErrors,
snapshotSubmittedFields, isSubmittedSnapshotCurrent, reportCreateBuildFailure,
isFieldInvalid, isRequiredField, clearFieldError, syncFieldAria,
onApproveModeChange, onHideConnectionManagementChange, onPlatformChange,
lifecycleGuard };
`, context)
context.api.presets.value = [
{ id: 1, name: 'First preset', has_permanent_password: true },
Expand Down Expand Up @@ -236,6 +260,106 @@ test('without a selection Save preset keeps the blank editable new-preset prompt
assert.equal(ctx.calls.payloads[0].preserve_permanent_password, false)
})

test('the custom_preset create wrapper surfaces the bounded server message', () => {
const api = readFileSync(new URL('../src/api/custom_preset.js', import.meta.url), 'utf8')
const impl = api.match(/export function create \(data\) \{[\s\S]*?\n\}/)
assert.ok(impl, 'custom_preset create must exist')
assert.match(impl[0], /url: '\/custom_preset\/create'/)
assert.match(impl[0], /useServerErrorMessage: true/,
'a rejected preset save must toast the server envelope message, not the raw axios text')
})

test('a rejected preset save maps pass_approve_mode/incompatible to the inline/ARIA pipeline', async () => {
const ctx = presetSaveFixture()
ctx.editedName = 'Preset with hide_cm'
ctx.form.hide_cm = true
ctx.form.permanent_password = 'secret'
ctx.form.pass_approve_mode = 'password-click' // FORM_DEFAULTS, not the required 'password'
const attributes = {}
ctx.document = { getElementById: id => id === 'custom-client-pass_approve_mode-input' ? {
setAttribute: (name, value) => { attributes[name] = value },
removeAttribute: name => { delete attributes[name] },
focus: () => { ctx.calls.focused.push(id) },
} : null }
ctx.formRef.value = {
validate: async () => true,
fields: [{ prop: 'pass_approve_mode', $el: { querySelector: () => null } }],
scrollToField: field => { ctx.calls.scrolled.push(field) },
}
ctx.createPreset = async payload => {
ctx.calls.payloads.push(payload)
throw staleCreateError([{ field: 'pass_approve_mode', code: 'incompatible' }])
}
await ctx.api.saveCurrentAsPreset()
assert.equal(ctx.calls.payloads.length, 1, 'the save must still send the preset payload')
assert.equal(ctx.logs.length, 0, 'a rejected preset save must not log the raw axios error')
assert.equal(ctx.api.serverFieldError('pass_approve_mode'), 'CustomClientPassApproveModeIncompatible')
assert.equal(ctx.api.isFieldInvalid('pass_approve_mode'), true)
await ctx.api.syncFieldAria()
assert.equal(attributes['aria-invalid'], 'true')
assert.equal(attributes['aria-describedby'], 'custom-client-pass_approve_mode-error')
assert.deepEqual(ctx.calls.scrolled, ['pass_approve_mode'])
assert.deepEqual(ctx.calls.focused, ['custom-client-pass_approve_mode-input'])
})

test('a stale preset-save rejection is not applied after the approve mode is fixed in flight', async () => {
const ctx = presetSaveFixture()
ctx.editedName = 'Preset with hide_cm'
ctx.form.hide_cm = true
ctx.form.permanent_password = 'secret'
ctx.form.pass_approve_mode = 'password-click'
let rejectCreate
let createStarted
const started = new Promise(resolve => { createStarted = resolve })
ctx.createPreset = async payload => {
ctx.calls.payloads.push(payload)
createStarted()
return new Promise((resolve, reject) => { rejectCreate = reject })
}
const pending = ctx.api.saveCurrentAsPreset()
await started
ctx.form.pass_approve_mode = 'password' // fixed while the save is in flight
rejectCreate(staleCreateError([{ field: 'pass_approve_mode', code: 'incompatible' }]))
await pending
assert.equal(ctx.api.serverFieldError('pass_approve_mode'), '',
'the stale response must not land on the fixed value')
assert.equal(ctx.api.isFieldInvalid('pass_approve_mode'), false)
assert.equal(ctx.logs.length, 0, 'a dropped stale response is expected and must not be logged')
})

test('after the approve mode is fixed the preset save succeeds and refreshes', async () => {
const ctx = presetSaveFixture()
ctx.editedName = 'Preset with hide_cm'
ctx.form.hide_cm = true
ctx.form.permanent_password = 'secret'
ctx.form.pass_approve_mode = 'password-click'
let attempt = 0
ctx.createPreset = async payload => {
ctx.calls.payloads.push(payload)
attempt++
if (attempt === 1) throw staleCreateError([{ field: 'pass_approve_mode', code: 'incompatible' }])
}
await ctx.api.saveCurrentAsPreset()
assert.equal(ctx.api.isFieldInvalid('pass_approve_mode'), true)
ctx.form.pass_approve_mode = 'password'
ctx.api.onApproveModeChange()
assert.equal(ctx.api.isFieldInvalid('pass_approve_mode'), false,
'editing the approve mode clears the stale incompatibility')
await ctx.api.saveCurrentAsPreset()
assert.equal(ctx.calls.payloads.length, 2)
assert.equal(ctx.calls.refreshes, 1, 'the successful retry must refresh the preset list')
})

test('cancelling the preset name prompt stays silent', async () => {
const ctx = presetSaveFixture()
ctx.ElMessageBox.prompt = async () => { throw 'cancel' }
await ctx.api.saveCurrentAsPreset()
assert.equal(ctx.logs.length, 0, 'prompt cancellation is not a failure')
assert.equal(ctx.calls.payloads.length, 0)
assert.equal(ctx.calls.refreshes, 0)
assert.deepEqual(Object.keys(ctx.api.serverFieldErrors.value), [])
})

// Fixture for the create-success path (F-D). It runs the same extracted SFC
// logic as `fixture` (so isSubmittedSnapshotCurrent/snapshotSubmittedFields are
// the real implementation), plus submitBuild's own body, with the request layer
Expand Down Expand Up @@ -468,7 +592,7 @@ test('the success-path guard actually covers the width of the create payload', (
// Negative control: the old implementation compared only requiredFieldNames,
// which is narrower than what submitBuild sends. Assert the guard is driven by
// the submitted payload, not by the required set.
const guard = between(' const isSubmittedSnapshotCurrent', ' // Classify a create failure')
const guard = between(' const isSubmittedSnapshotCurrent', ' // Classify a build-create or preset-save failure')
assert.equal(guard.includes('requiredFieldNames.every'), false, 'the guard must not be limited to requiredFieldNames')
assert.match(guard, /Object\.keys\(snapshot\.values\)\.every/)
const snapshotFn = between(' const snapshotSubmittedFields', ' // True when the live form')
Expand Down
Loading