From 11a8fc21deae5ca141e04c0ffeaae5808d4f2eca Mon Sep 17 00:00:00 2001 From: chen21019 Date: Wed, 30 Sep 2026 23:29:58 +0800 Subject: [PATCH 1/3] fix: preserve masked key on certificate metadata edits --- README.md | 8 + app/components/edit-certificate/component.js | 29 ++- app/components/edit-certificate/template.hbs | 2 +- app/components/input-certificate/component.js | 1 + app/components/input-certificate/template.hbs | 2 +- app/mixins/cattle-transitioning-resource.js | 6 +- app/mixins/new-or-edit.js | 4 +- docs/releases/web-console-1.6.161.md | 37 ++++ package-lock.json | 4 +- package.json | 2 +- .../components/input-certificate-test.js | 50 +++++ ...it-certificate-metadata-validation-test.js | 180 ++++++++++++++++++ ...-transitioning-resource-validation-test.js | 35 +++- tests/unit/mixins/new-or-edit-test.js | 12 ++ translations/de-de.yaml | 3 +- translations/en-us.yaml | 2 +- translations/fa-ir.yaml | 2 +- translations/fil-ph.yaml | 3 +- translations/fr-fr.yaml | 2 +- translations/hu-hu.yaml | 3 +- translations/ja-jp.yaml | 2 +- translations/ko-kr.yaml | 2 +- translations/pt-br.yaml | 3 +- translations/ru-ru.yaml | 3 +- translations/uk-ua.yaml | 3 +- translations/zh-hans.yaml | 2 +- translations/zh-tw.yaml | 2 +- 27 files changed, 369 insertions(+), 35 deletions(-) create mode 100644 docs/releases/web-console-1.6.161.md create mode 100644 tests/integration/components/input-certificate-test.js create mode 100644 tests/unit/components/edit-certificate-metadata-validation-test.js diff --git a/README.md b/README.md index 4d0c712fc5..d9982e9f9c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Release `1.6.161` is being prepared to fix existing Certificate metadata edits +blocked by the masked private key. Name/description-only edits omit certificate +material from the PUT body; new certificates and material replacements keep +full validation. The editor explains this distinction in all supported locales. +Focused real-model and rendered three-language UI tests passed 25/25; final CI, +publication and isolated browser acceptance are still pending. See the +[preparation note](docs/releases/web-console-1.6.161.md). + The current published release is `1.6.160`. It retains the existing Node 24, Ember, Sass, dependency, browser-smoke, terminal, console, and test-harness modernization. It adds a provider-neutral OpenID Connect administration and sign-in flow with diff --git a/app/components/edit-certificate/component.js b/app/components/edit-certificate/component.js index a424eb5b74..6c261051e5 100644 --- a/app/components/edit-certificate/component.js +++ b/app/components/edit-certificate/component.js @@ -14,15 +14,38 @@ export default ModalBase.extend(NewOrEdit, CertificateKeyValidation, { this.set('model', this.get('originalModel').clone()); }, + isMetadataOnlyUpdate() { + const model = this.get('model'); + const original = this.get('originalModel'); + const value = (record, field) => record?.get?.(field); + const normalize = (material) => typeof material === 'string' ? material.trim() : material == null ? '' : material; + const cert = value(original, 'cert'); + + // Schema validation trims the clone. Compare both sides consistently, but + // never send those trimmed, unchanged PEM values back in a metadata PUT. + return Boolean(value(original, 'id') && value(model, 'id') === value(original, 'id') && + value(model, 'type') === 'certificate' && value(original, 'type') === 'certificate' && + typeof cert === 'string' && cert.trim() && normalize(value(model, 'cert')) === normalize(cert) && + normalize(value(model, 'certChain')) === normalize(value(original, 'certChain')) && + normalize(value(original, 'key')) === '' && + normalize(value(model, 'key')) === ''); + }, + + validate() { + return this._super(this.isMetadataOnlyUpdate() ? {updateOmittedFields: ['key']} : undefined); + }, + doSave() { const model = this.get('model'); const data = { name: model.get('name'), description: model.get('description'), - cert: model.get('cert'), - key: model.get('key'), - certChain: model.get('certChain'), }; + if ( !this.isMetadataOnlyUpdate() ) { + data.cert = model.get('cert'); + data.key = model.get('key'); + data.certChain = model.get('certChain'); + } return this._super({data}); }, diff --git a/app/components/edit-certificate/template.hbs b/app/components/edit-certificate/template.hbs index 214cfc2a3d..5e96deca77 100644 --- a/app/components/edit-certificate/template.hbs +++ b/app/components/edit-certificate/template.hbs @@ -12,7 +12,7 @@
{{t 'editCertificate.noteKeyWriteOnly'}}
- {{input-certificate model=this.model}} + {{input-certificate model=this.model keyRequired=false}} {{top-errors errors=this.errors}} diff --git a/app/components/input-certificate/component.js b/app/components/input-certificate/component.js index 6611b427d6..a60ff327ea 100644 --- a/app/components/input-certificate/component.js +++ b/app/components/input-certificate/component.js @@ -2,6 +2,7 @@ import Component from '@ember/component'; export default Component.extend({ model: null, + keyRequired: true, tagName: 'div', classNames: ['row'], diff --git a/app/components/input-certificate/template.hbs b/app/components/input-certificate/template.hbs index 372778b863..d5fcf7f185 100644 --- a/app/components/input-certificate/template.hbs +++ b/app/components/input-certificate/template.hbs @@ -1,6 +1,6 @@
- +
{{input-text-file value=this.model.key diff --git a/app/mixins/cattle-transitioning-resource.js b/app/mixins/cattle-transitioning-resource.js index fdb887f4f0..a55a3b26ae 100644 --- a/app/mixins/cattle-transitioning-resource.js +++ b/app/mixins/cattle-transitioning-resource.js @@ -384,7 +384,7 @@ export default Mixin.create({ } }, - validationErrors: function() { + validationErrors: function(options) { let intl = this.get('intl'); var errors = []; @@ -478,7 +478,9 @@ export default Mixin.create({ var len = (val ? get(val,'length') : 0); - if ( field.required && (val === null || (typeof val === 'string' && len === 0) || (isArray(val) && len === 0) ) ) + const omittedOnUpdate = this.get('id') && Array.isArray(options?.updateOmittedFields) && + options.updateOmittedFields.includes(key); + if ( field.required && !omittedOnUpdate && (val === null || (typeof val === 'string' && len === 0) || (isArray(val) && len === 0) ) ) { errors.push(intl.t('validation.required', {key: displayKey})); continue; diff --git a/app/mixins/new-or-edit.js b/app/mixins/new-or-edit.js index ebc77ddd74..a219264423 100644 --- a/app/mixins/new-or-edit.js +++ b/app/mixins/new-or-edit.js @@ -26,9 +26,9 @@ export default Mixin.create({ this.set('saving',false); }, - validate: function() { + validate: function(options) { var model = this.get('primaryResource'); - var errors = model.validationErrors(); + var errors = model.validationErrors(options); if ( errors.get('length') ) { this.set('errors', errors); diff --git a/docs/releases/web-console-1.6.161.md b/docs/releases/web-console-1.6.161.md new file mode 100644 index 0000000000..518edd53db --- /dev/null +++ b/docs/releases/web-console-1.6.161.md @@ -0,0 +1,37 @@ +# Web Console 1.6.161 + +Preparation only; publication and packaged browser acceptance are pending. + +The existing Certificate editor applied the create-schema required-key check +even though the API does not return the private key. The native owner editor +on isolated Server `v1.6.494` failed with `KEY_REQUIRED` before any resource +write. This patch fixes that editor rather than bypassing its validation. + +For a persisted matching Certificate with a masked key and unchanged +certificate/chain, validation explicitly permits omitting the key, and the PUT +body contains only `name` and `description`. The original PEM material is not +resent after the shared validator trims the clone. The decision is recomputed +when saving; no persistent bypass flag is used. + +New certificates, changed or cleared certificate/chain values, and supplied +replacement keys keep the ordinary validation and five-field replacement body. +Encrypted private keys remain rejected. The shared validator's default remains +strict; this is not a blanket exception for write-only or required fields. + +The existing hint now explains metadata preservation and the corresponding-key +requirement for replacements in all 13 locales. A masked edit key is no longer +marked unconditionally required; the create form retains its required marker. + +Focused native Chrome 153 QUnit validation passed 25/25 tests including the final +hint and template changes. Coverage uses the real Certificate clone, schema and trim +chain, metadata-only and replacement payloads, required/format checks, encrypted +keys, sync/async save failures and the complete save/callback/lock lifecycle. +Rendered English, Traditional Chinese and Japanese controls preserve the create +required marker and remove it for masked edit keys. The existing hint is present +in all 13 locales; locale checks report zero missing, orphan or invalid ICU keys. +Full official CI, deterministic archive publication and owner/member packaged +browser acceptance remain pending. + +No API, authorization, authentication, session, OIDC, MFA, proxy, firewall or +stored-data contract changes are introduced. Earlier failed browser receipts +remain failures; component tests do not complete the resource/role matrix. diff --git a/package-lock.json b/package-lock.json index a85968a51b..937d12900b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.160", + "version": "1.6.161", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.160", + "version": "1.6.161", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index f7d91bc2d4..c711f68631 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.160", + "version": "1.6.161", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/tests/integration/components/input-certificate-test.js b/tests/integration/components/input-certificate-test.js new file mode 100644 index 0000000000..11b18da5d4 --- /dev/null +++ b/tests/integration/components/input-certificate-test.js @@ -0,0 +1,50 @@ +import EmberObject from '@ember/object'; +import Component from '@ember/component'; +import { precompileTemplate } from '@ember/template-compilation'; +import { findAll, render, setupContext, setupRenderingContext, teardownContext } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import resolver from '../../helpers/resolver'; +import { initialize as initializePodLayouts } from 'ui/initializers/pod-component-layouts'; +import { initialize as initializeIntl } from 'ui/instance-initializers/intl'; + +module('Integration | Component | input certificate', function(hooks) { + hooks.beforeEach(async function() { + this.testRoot = document.createElement('div'); + this.testRoot.id = 'ember-testing'; + document.body.appendChild(this.testRoot); + await setupContext(this, {resolver}); + initializePodLayouts(); + initializeIntl(this.owner); + this.intl = this.owner.lookup('service:intl'); + this.owner.register('component:input-text-file', Component.extend({tagName: 'div'})); + await setupRenderingContext(this); + this.model = EmberObject.create({key: null, cert: 'SYNTHETIC', certChain: null}); + }); + + hooks.afterEach(async function() { + await teardownContext(this); + this.testRoot.remove(); + }); + + test('create and edit key indicators follow their explicit validation context in three locales', async function(assert) { + for (const locale of ['en-us', 'zh-tw', 'ja-jp']) { + this.intl.addTranslations(locale, await (await fetch(`/translations/${locale}.json`)).json()); + this.intl.setLocale([locale, 'en-us']); + await render(precompileTemplate('{{input-certificate model=this.model}}')); + assert.true(findAll('label')[0].textContent.trim().endsWith('*'), `${locale}: create requires a key`); + await render(precompileTemplate('{{input-certificate model=this.model keyRequired=false}}')); + const labels = findAll('label').map(label => label.textContent.trim()); + assert.false(labels[0].endsWith('*'), `${locale}: masked edit key is not unconditionally required`); + assert.true(labels[1].endsWith('*'), `${locale}: certificate remains required`); + assert.false(labels[2].endsWith('*'), `${locale}: chain remains optional`); + assert.strictEqual(this.model.get('key'), null, 'rendering never fills a masked private key'); + } + }); + + test('the metadata-preservation explanation exists in every supported locale', async function(assert) { + for (const locale of ['de-de', 'en-us', 'fa-ir', 'fil-ph', 'fr-fr', 'hu-hu', 'ja-jp', 'ko-kr', 'pt-br', 'ru-ru', 'uk-ua', 'zh-hans', 'zh-tw']) { + const messages = await (await fetch(`/translations/${locale}.json`)).json(); + assert.ok(messages['editCertificate.noteKeyWriteOnly'], `${locale}: existing hint is translated`); + } + }); +}); diff --git a/tests/unit/components/edit-certificate-metadata-validation-test.js b/tests/unit/components/edit-certificate-metadata-validation-test.js new file mode 100644 index 0000000000..9750852800 --- /dev/null +++ b/tests/unit/components/edit-certificate-metadata-validation-test.js @@ -0,0 +1,180 @@ +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { module, test } from 'qunit'; +import Certificate from 'ui/models/certificate'; +import EditCertificate from 'ui/components/edit-certificate/component'; +import CattleTransitioningResource from 'ui/mixins/cattle-transitioning-resource'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +const CERT = '-----BEGIN CERTIFICATE-----\nSYNTHETIC\n-----END CERTIFICATE-----\n'; +const CHAIN = '-----BEGIN CERTIFICATE-----\nCHAIN\n-----END CERTIFICATE-----\n'; +const KEY = '-----BEGIN PRIVATE KEY-----\nSYNTHETIC\n-----END PRIVATE KEY-----\n'; + +function fixture({original = {}, edits = {}, failure} = {}) { + const sent = [], events = [], records = []; + const labels = {'formNameDescription.name.label': 'Name', 'inputCertificate.cert.label': 'Certificate', 'inputCertificate.key.label': 'Private Key'}; + const intl = EmberObject.create({ + exists(key) { return Object.hasOwn(labels, key); }, + t(key, parameters) { return key === 'validation.required' ? `Required ${parameters.key}` : labels[key] || key; }, + }); + const resourceFields = { + name: {type: 'string', required: true, nullable: true}, + description: {type: 'string', nullable: true}, + cert: {type: 'string', required: true}, + key: {type: 'string', required: true}, + certChain: {type: 'string', nullable: true}, + }; + let source; + const Model = Certificate.extend(CattleTransitioningResource, { + save(options) { + sent.push(options); + if (failure === 'sync') { + throw new Error('synthetic save failure'); + } + if (failure === 'async') { + return Promise.reject(new Error('synthetic save failure')); + } + return Promise.resolve(store.createRecord({...source.serialize(), ...options.data})); + }, + }); + const store = EmberObject.create({ + getById(type, id) { return type === 'schema' && id === 'certificate' ? {resourceFields} : null; }, + hasRecord() { return false; }, + createRecord(values) { + const record = Model.create({...values, store: this, intl}); + records.push(record); + return record; + }, + }); + source = store.createRecord({id: '1c52', type: 'certificate', accountId: '1a2515', name: 'Original', description: 'Original description', cert: CERT, key: null, certChain: CHAIN, ...original}); + const component = createOwned(EditCertificate, { + renderer: inertRenderer(), intl, + modalService: EmberObject.create({modalOpts: source}), + send(action) { events.push(action); }, + }, 'component'); + component.get('model').setProperties({name: 'Edited', description: 'Edited description', ...edits}); + return {component, source, resourceFields, sent, events, destroy() { + destroyOwned(component); + run(() => records.forEach(record => record.destroy())); + }}; +} + +module('Unit | Component | edit-certificate metadata validation'); + +test('real cloned certificate validation preserves masked-key metadata edits before and after trimming', async function(assert) { + for (const key of [null, undefined, '', ' \n ']) { + const f = fixture({edits: {key}}); + const model = f.component.get('model'); + assert.notStrictEqual(model, f.source, 'the editor uses the real resource clone'); + assert.strictEqual(model.get('id'), f.source.get('id')); + assert.true(f.component.isMetadataOnlyUpdate(), 'before validation'); + assert.true(f.component.validate(), 'required-key exception is opt-in and does not need writeOnly'); + assert.true(f.component.isMetadataOnlyUpdate(), 'after validation trimmed the clone'); + assert.strictEqual(model.get('cert'), CERT.trim()); + assert.strictEqual(model.get('certChain'), CHAIN.trim()); + assert.strictEqual(f.source.get('cert'), CERT, 'original PEM bytes remain intact'); + assert.strictEqual(f.source.get('certChain'), CHAIN); + assert.true(f.resourceFields.key.required, 'shared schema is not changed'); + await f.component.doSave(); + assert.deepEqual(f.sent[0].data, {name: 'Edited', description: 'Edited description'}, 'no masked or trimmed material is sent'); + assert.strictEqual(f.source.get('cert'), CERT, 'simulated metadata response preserves material'); + assert.strictEqual(f.source.get('certChain'), CHAIN); + f.destroy(); + } +}); + +test('a missing optional chain stays metadata-only across empty-string normalization', function(assert) { + const f = fixture({original: {certChain: null}, edits: {certChain: ''}}); + assert.true(f.component.isMetadataOnlyUpdate()); + assert.true(f.component.validate()); + assert.true(f.component.isMetadataOnlyUpdate()); + f.destroy(); +}); + +test('new and material-changing certificates still require certificate and key', function(assert) { + for (const options of [ + {original: {id: null}}, + {edits: {cert: ''}}, + {edits: {cert: 'REPLACEMENT'}}, + {edits: {certChain: ''}}, + {edits: {certChain: 'REPLACEMENT CHAIN'}}, + {original: {key: KEY}, edits: {key: ''}}, + ]) { + const f = fixture(options); + assert.false(f.component.isMetadataOnlyUpdate()); + assert.false(f.component.validate(), 'the ordinary schema validator is still used'); + assert.ok(f.component.get('errors').includes('Required Private Key')); + assert.strictEqual(f.sent.length, 0); + f.destroy(); + } + const f = fixture({original: {id: null, cert: ''}}); + assert.false(f.component.validate()); + assert.deepEqual(f.component.get('errors'), ['Required Certificate', 'Required Private Key']); + f.destroy(); +}); + +test('metadata exception cannot hide name errors or cross an identity/type boundary', function(assert) { + const f = fixture({edits: {name: ''}}); + assert.false(f.component.validate()); + assert.deepEqual(f.component.get('errors'), ['Required Name']); + f.destroy(); + for (const edits of [{id: '1c53'}, {type: 'secret'}]) { + const changed = fixture({edits}); + assert.false(changed.component.isMetadataOnlyUpdate()); + changed.destroy(); + } +}); + +test('explicit replacements keep the full editable body and encrypted keys remain rejected', async function(assert) { + const f = fixture({edits: {cert: 'REPLACEMENT CERT', key: KEY, certChain: ''}}); + assert.false(f.component.isMetadataOnlyUpdate()); + assert.true(f.component.validate()); + await f.component.doSave(); + assert.deepEqual(f.sent[0].data, {name: 'Edited', description: 'Edited description', cert: 'REPLACEMENT CERT', key: KEY.trim(), certChain: null}); + f.destroy(); + const encrypted = fixture({edits: {key: '-----BEGIN ENCRYPTED PRIVATE KEY-----\nSYNTHETIC\n'}}); + assert.false(encrypted.component.validate()); + assert.deepEqual(encrypted.component.get('errors'), ['certificatesPage.encryptedKeyError']); + assert.strictEqual(encrypted.sent.length, 0); + encrypted.destroy(); +}); + +test('material changes between validation and doSave are not silently omitted', async function(assert) { + const f = fixture(); + assert.true(f.component.validate()); + f.component.get('model').setProperties({cert: 'REPLACEMENT CERT', key: KEY}); + assert.false(f.component.isMetadataOnlyUpdate()); + await f.component.doSave(); + assert.deepEqual(f.sent[0].data, {name: 'Edited', description: 'Edited description', cert: 'REPLACEMENT CERT', key: KEY, certChain: CHAIN.trim()}); + f.destroy(); +}); + +test('willSave and the real save lifecycle dispatch metadata exactly once and settle the callback', async function(assert) { + const f = fixture(); + const callbacks = []; + const operation = f.component.get('actions').save.call(f.component, success => callbacks.push(success)); + await operation; + assert.strictEqual(f.sent.length, 1); + assert.deepEqual(f.sent[0].data, {name: 'Edited', description: 'Edited description'}); + assert.deepEqual(callbacks, [true]); + assert.deepEqual(f.events, ['cancel']); + assert.false(f.component.get('saving')); + assert.strictEqual(f.component._saveOwner, null); + f.destroy(); +}); + +test('synchronous and asynchronous save failures settle the same metadata lifecycle', async function(assert) { + for (const failure of ['sync', 'async']) { + const f = fixture({failure}); + const callbacks = []; + const result = await f.component.get('actions').save.call(f.component, success => callbacks.push(success)); + assert.false(result.saved); + assert.strictEqual(f.sent.length, 1); + assert.deepEqual(callbacks, [false]); + assert.deepEqual(f.events, ['error']); + assert.false(f.component.get('saving')); + assert.strictEqual(f.component._saveOwner, null); + f.destroy(); + } +}); diff --git a/tests/unit/mixins/cattle-transitioning-resource-validation-test.js b/tests/unit/mixins/cattle-transitioning-resource-validation-test.js index 4bc54f49b8..68b46ba9a1 100644 --- a/tests/unit/mixins/cattle-transitioning-resource-validation-test.js +++ b/tests/unit/mixins/cattle-transitioning-resource-validation-test.js @@ -15,19 +15,21 @@ const labels = { 'newSecret.value.label': '機密資料值', }; -function errorsFor(type, fields, translations = labels) { +function errorsFor(type, fields, translations = labels, context = {}) { let Subject = EmberObject.extend(CattleTransitioningResource, { trimValues() {}, }); let subject = Subject.create({ type, + id: context.id, + ...context.values, intl: EmberObject.create({ exists(key) { return Object.prototype.hasOwnProperty.call(translations, key); }, t(key, params) { if ( key === 'validation.required' ) { return `${params.key} 必須設定`; } - return translations[key]; + return translations[key] || key; }, }), store: EmberObject.create({ @@ -37,13 +39,13 @@ function errorsFor(type, fields, translations = labels) { } let resourceFields = {}; fields.forEach((field) => { - resourceFields[field] = {type: 'string', required: true, nullable: true}; + resourceFields[field] = {type: 'string', required: true, nullable: true, ...context.fields?.[field]}; }); return {resourceFields}; }, }), }); - let errors = subject.validationErrors(); + let errors = subject.validationErrors(context.options); subject.destroy(); return errors; } @@ -68,6 +70,31 @@ test('required errors use the visible translated labels of the affected forms', assert.deepEqual(errorsFor('container', ['name']), ['名稱 必須設定']); }); +test('only explicitly omitted empty update fields bypass required validation', function(assert) { + const fields = ['name', 'cert', 'key']; + const values = {name: 'Existing', cert: 'CERT'}; + const options = {updateOmittedFields: ['key']}; + + assert.deepEqual(errorsFor('certificate', fields, labels, {id: '1c52', values}), ['私鑰 必須設定'], 'existing ID alone changes nothing'); + assert.deepEqual(errorsFor('certificate', fields, labels, {values, options}), ['私鑰 必須設定'], 'new certificates retain the required key'); + assert.deepEqual(errorsFor('certificate', fields, labels, {values: {name: 'New'}, options}), + ['憑證 必須設定', '私鑰 必須設定'], 'new certificates retain both required materials'); + for (const key of [null, undefined, '']) { + assert.deepEqual(errorsFor('certificate', fields, labels, {id: '1c52', values: {...values, key}, options}), [], 'explicitly omitted empty update key'); + } + assert.deepEqual(errorsFor('certificate', fields, labels, {id: '1c52', values: {cert: 'CERT'}, options}), ['名稱 必須設定'], 'other required fields still fail'); + for (const invalid of [undefined, {updateOmittedFields: 'key'}, {updateOmittedFields: ['cert']}]) { + assert.deepEqual(errorsFor('certificate', fields, labels, {id: '1c52', values, options: invalid}), ['私鑰 必須設定'], 'only a field-name array opts in'); + } +}); + +test('omission options do not weaken checks on a supplied update value', function(assert) { + const context = {id: '1c52', values: {key: 'a!'}, options: {updateOmittedFields: ['key']}, fields: {key: {minLength: 4, validChars: 'a-z'}}}; + assert.deepEqual(errorsFor('certificate', ['key'], labels, context), errorsFor('certificate', ['key'], labels, {...context, options: undefined})); + assert.deepEqual(errorsFor('certificate', ['key'], labels, context), ['validation.stringLength.min', 'validation.chars'], + 'minimum length and invalid-character checks both remain'); +}); + test('model-specific labels retain priority and unknown fields retain their fallback', function(assert) { assert.deepEqual(errorsFor('secret', ['name', 'unknownField'], { ...labels, diff --git a/tests/unit/mixins/new-or-edit-test.js b/tests/unit/mixins/new-or-edit-test.js index b771de3133..ed8b875c70 100644 --- a/tests/unit/mixins/new-or-edit-test.js +++ b/tests/unit/mixins/new-or-edit-test.js @@ -7,6 +7,18 @@ import NewOrEdit from 'ui/mixins/new-or-edit'; module('Unit | Mixin | new or edit'); +test('validation forwards explicit options and leaves the default strict', function(assert) { + const received = []; + const options = {updateOmittedFields: ['key']}; + const Subject = EmberObject.extend(NewOrEdit); + const subject = Subject.create({model: EmberObject.create({validationErrors(value) { received.push(value); return A([]); }})}); + + assert.true(subject.validate()); + assert.true(subject.validate(options)); + assert.deepEqual(received, [undefined, options]); + run(() => subject.destroy()); +}); + test('the save error action uses optional localized formatting', function(assert) { let intl = {t(key) { return { diff --git a/translations/de-de.yaml b/translations/de-de.yaml index 59d081dffe..1d55b20b2e 100644 --- a/translations/de-de.yaml +++ b/translations/de-de.yaml @@ -1751,8 +1751,7 @@ editCertificate: description: placeholder: z.B. EV Zertifikat für mydomain.com noteKeyWriteOnly: >- - Hinweis: Der Private Key ist bewusst leer, da dieses Feld write-only ist. Sie müssen den Private Key erneut - eintragen um das Zertifikat zu aktualisieren, auch wenn er sich nicht verändert hat. + Der private Schlüssel wird nicht zurückgegeben und erscheint leer. Wenn Sie nur den Namen oder die Beschreibung ändern, lassen Sie Zertifikat, Zertifikatskette und Schlüssel unverändert. Zum Ersetzen des Zertifikats oder der Kette ist der zugehörige private Schlüssel erforderlich. editContainer: title: vm: VM bearbeiten diff --git a/translations/en-us.yaml b/translations/en-us.yaml index c4b0b2edbd..6891163a7e 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2066,7 +2066,7 @@ editCertificate: placeholder: e.g. mydomain.com description: placeholder: e.g. EV cert for mydomain.com - noteKeyWriteOnly: "Note: The Private Key is intentionally blank because the field is write-only. You will need to provide the Private Key again to update the certificate, even if it hasn't changed." + noteKeyWriteOnly: "The private key is not returned and appears blank. To change only the name or description, leave the certificate, chain and key unchanged. Replacing the certificate or chain requires the corresponding private key." editContainer: title: diff --git a/translations/fa-ir.yaml b/translations/fa-ir.yaml index 9897aa7b7f..330213820b 100644 --- a/translations/fa-ir.yaml +++ b/translations/fa-ir.yaml @@ -1686,7 +1686,7 @@ editCertificate: description: placeholder: 'برای مثال: گواهی EV برای mydomain.com' noteKeyWriteOnly: >- - توجه: کلید خصوصی به‌عمد خالی است، زیرا این فیلد فقط قابل نوشتن است. برای به‌روزرسانی گواهی، حتی اگر کلید تغییر نکرده باشد، باید آن را دوباره وارد کنید. + کلید خصوصی بازگردانده نمی‌شود و خالی نمایش داده می‌شود. برای تغییر فقط نام یا توضیح، گواهی، زنجیره و کلید را بدون تغییر نگه دارید. جایگزینی گواهی یا زنجیره به کلید خصوصی متناظر نیاز دارد. editContainer: title: vm: ویرایش VM diff --git a/translations/fil-ph.yaml b/translations/fil-ph.yaml index ba2abfcc5f..1b4725874d 100644 --- a/translations/fil-ph.yaml +++ b/translations/fil-ph.yaml @@ -1718,8 +1718,7 @@ editCertificate: description: placeholder: hal. EV cert para sa mydomain.com noteKeyWriteOnly: >- - Tandaan: Ang Private Key ay sadyang blangko dahil ang field ay write-only. Kakailanganin mong ibigay muli ang - Pribadong Key upang i-update ang certificate, kahit na hindi ito nagbago. + Hindi ibinabalik ang pribadong key kaya blangko ito. Kung pangalan o paglalarawan lamang ang babaguhin, huwag baguhin ang certificate, chain at key. Kailangan ang katugmang pribadong key kapag papalitan ang certificate o chain. editContainer: title: vm: I-edit ang VM diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 512d6b44cc..4afec493e6 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -1726,7 +1726,7 @@ editCertificate: placeholder: par exemple mondomaine.com description: placeholder: par exemple certificat EV (vérification approfondie) pour mondomaine.com - noteKeyWriteOnly: "Remarque\_: La clé privée est intentionnellement vide car le champ ne peut être réutilisé. Vous devrez fournir la clé privée à nouveau pour mettre à jour le certificat, même si elle n’a pas changé." + noteKeyWriteOnly: "La clé privée n’est pas renvoyée et apparaît vide. Pour modifier uniquement le nom ou la description, laissez le certificat, la chaîne et la clé inchangés. Le remplacement du certificat ou de la chaîne nécessite la clé privée correspondante." editContainer: title: vm: Modifier la Machine Virtuelle (VM) diff --git a/translations/hu-hu.yaml b/translations/hu-hu.yaml index 122a198783..8614835e37 100644 --- a/translations/hu-hu.yaml +++ b/translations/hu-hu.yaml @@ -1735,8 +1735,7 @@ editCertificate: description: placeholder: pl. EV tanúsítvány a sajat-domain-nevem.hu webhelyhez noteKeyWriteOnly: >- - Megjegyzés: A privát kulcs szándékosan üres, mivel a mező csak írható. A tanúsítvány módosításához szükséged lesz a - privát kulcsra ismét, akkor is ha az nem változott. + A privát kulcs nem kerül visszaadásra, ezért üresen jelenik meg. Ha csak a nevet vagy a leírást módosítja, hagyja változatlanul a tanúsítványt, a láncot és a kulcsot. A tanúsítvány vagy a lánc cseréjéhez a hozzá tartozó privát kulcs szükséges. editContainer: title: vm: VM szerkesztése diff --git a/translations/ja-jp.yaml b/translations/ja-jp.yaml index 615fe5e915..d4adf6ebae 100644 --- a/translations/ja-jp.yaml +++ b/translations/ja-jp.yaml @@ -1938,7 +1938,7 @@ editCertificate: placeholder: '例: mydomain.com' description: placeholder: '例: mydomain.com の EV 証明書' - noteKeyWriteOnly: 'Note: プライベートキーは書き込み専用のためあえて空にしています。証明書を更新するには変更が無かったとしても再度プライベートキーを入力する必要があります。' + noteKeyWriteOnly: 秘密鍵は返されないため、空欄で表示されます。名前または説明のみを変更する場合は、証明書・証明書チェーン・秘密鍵を変更しないでください。証明書またはチェーンを置き換える場合は、対応する秘密鍵が必要です。 editContainer: title: vm: VM を編集 diff --git a/translations/ko-kr.yaml b/translations/ko-kr.yaml index 9b4e5d9e14..45a7cd7abe 100644 --- a/translations/ko-kr.yaml +++ b/translations/ko-kr.yaml @@ -1638,7 +1638,7 @@ editCertificate: placeholder: 예를 들어 mydomain.com description: placeholder: 예를 들어 EV mydomain.com에 대한 인증서 - noteKeyWriteOnly: '참고: 필드가 쓰기 전용이므로 개인 키는 의도적으로 비어 있습니다. 인증서가 변경되지 않은 경우에도 인증서를 업데이트하려면 개인 키를 다시 제공해야 합니다.' + noteKeyWriteOnly: 개인 키는 반환되지 않아 빈칸으로 표시됩니다. 이름이나 설명만 변경하려면 인증서, 인증서 체인 및 키를 변경하지 마세요. 인증서나 체인을 교체하려면 해당 개인 키가 필요합니다. editContainer: title: vm: 편집 VM diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index 7d5e902eb6..7dace58e6d 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -1715,8 +1715,7 @@ editCertificate: description: placeholder: p.ex. EV cert para meudominio.com noteKeyWriteOnly: >- - Nota: A chave privada é intencionalmente em branco porque o campo é somente leitura. Você precisará fornecer a - chave privada novamente para atualizar o certificado, mesmo que não tenha mudado. + A chave privada não é retornada e aparece em branco. Para alterar apenas o nome ou a descrição, mantenha o certificado, a cadeia e a chave inalterados. A substituição do certificado ou da cadeia exige a chave privada correspondente. editContainer: title: vm: Editar VM diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 872f862788..b8425cfa6f 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -1747,8 +1747,7 @@ editCertificate: description: placeholder: например EV сертификат для mydomain.com noteKeyWriteOnly: >- - Примечание. Закрытый ключ намеренно пуст, поскольку поле предназначено только для записи. Вам нужно будет еще раз - предоставить закрытый ключ для обновления сертификата, даже если он не изменился. + Закрытый ключ не возвращается и отображается пустым. Если нужно изменить только имя или описание, оставьте сертификат, цепочку и ключ без изменений. Для замены сертификата или цепочки требуется соответствующий закрытый ключ. editContainer: title: vm: Редактировать Виртуальную Машины diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index a47809bc14..6e4bc2c562 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -1749,8 +1749,7 @@ editCertificate: description: placeholder: напр. EV cert для mydomain.com noteKeyWriteOnly: >- - Примітка. Закритий ключ навмисно порожній, оскільки це поле призначене лише для запису. Вам потрібно буде знову - надати закритий ключ, щоб оновити сертифікат, навіть якщо він не змінився. + Закритий ключ не повертається й відображається порожнім. Якщо потрібно змінити лише назву або опис, залиште сертифікат, ланцюжок і ключ без змін. Для заміни сертифіката або ланцюжка потрібен відповідний закритий ключ. editContainer: title: vm: Редагувати Віртуальну Машину diff --git a/translations/zh-hans.yaml b/translations/zh-hans.yaml index 1ba63118ce..d951b3b461 100644 --- a/translations/zh-hans.yaml +++ b/translations/zh-hans.yaml @@ -1667,7 +1667,7 @@ editCertificate: placeholder: '例如:mydomain.com' description: placeholder: '例如:mydomain.com的EV证书' - noteKeyWriteOnly: '注意:Private Key部分为只写(write only)因而显示为空白。更新证书时您需要重新上传Private Key,即便Private Key未发生变化。' + noteKeyWriteOnly: 私钥不会返回,因此显示为空白。仅修改名称或描述时,请保持证书、证书链和私钥字段不变;更换证书或证书链时,必须提供对应的私钥。 editContainer: title: vm: 编辑虚拟机 diff --git a/translations/zh-tw.yaml b/translations/zh-tw.yaml index 69e910dd86..1d93b36259 100644 --- a/translations/zh-tw.yaml +++ b/translations/zh-tw.yaml @@ -1977,7 +1977,7 @@ editCertificate: placeholder: 例如:mydomain.com description: placeholder: 例如:mydomain.com 的 EV 憑證 - noteKeyWriteOnly: 注意:私密金鑰 部分為只寫(write only)因而顯示為空白。更新憑證時您需要重新上傳 私密金鑰,即便 私密金鑰 未發生變化。 + noteKeyWriteOnly: 私鑰不會回傳,因此顯示為空白。只修改名稱或描述時,請保持憑證、憑證鏈與私鑰欄位不變;更換憑證或憑證鏈時,必須提供對應的私鑰。 editContainer: title: vm: 編輯虛擬機器 From 39ac872a63d815ce1aad816e908c56584af33175 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Wed, 30 Sep 2026 23:34:12 +0800 Subject: [PATCH 2/3] build: align certificate patch version checks --- COMPATIBILITY.md | 8 ++++++++ scripts/check-modernization-blockers | 4 ++-- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 0b26cb673f..563e9dc857 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,14 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +Web Console `1.6.161` preserves an existing Certificate's masked key when only +name/description are changed. Explicit update-validation options apply only to +persisted records and explicitly omitted fields; other required and supplied +value checks remain strict. The editor omits unchanged material from metadata +PUTs, without modifying authorization, storage, create or replacement contracts. +Its existing hint and key marker distinguish metadata edits from replacements. +The earlier failed native editor receipt is not promoted to a pass. + Web Console `1.6.160` recognizes the established post-authorization Certificate in-use response (`405`, `InvalidAction`, and the known API message prefix), showing reviewed English, Traditional Chinese or Japanese copy that explains diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index adaae6b7c5..8defb663b2 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.160" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.160" +if [[ "$version" != "1.6.161" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.161" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index d9fd1f39e8..40987082d5 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.160 browser-session broker-broadcast + 1.6.161 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 8ec9140c60..ea03e020a5 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.160": +if package.get("version") != "1.6.161": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") From 2ad068d62b5afd3cd213cde8addc5ebbef738130 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Wed, 30 Sep 2026 23:39:24 +0800 Subject: [PATCH 3/3] build: align reviewed lock baseline with 1.6.161 --- ...m-package-lock.sass-replacement.node24-ignore-scripts.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index a85968a51b..937d12900b 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.160", + "version": "1.6.161", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.160", + "version": "1.6.161", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1"