From 5652a393a5c50b47a3eb72e8343876ca344e7f85 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 28 Aug 2026 17:14:54 +0800 Subject: [PATCH] fix: stop the date transform from nulling relative API key expirations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api-keys console assigns the expiration dropdown's relative strings ('never', 'immediately', 'in 1 hour', ...) directly to expires_at, but the attribute was declared @attr('date') and Ember Data's date transform serializes any non-Date value to null — so every option reached the API as expires_at: null and no expiry was ever persisted. The roll path was unaffected because it reads the raw attribute and PATCHes it outside the serializer. Add an `expiration` transform that passes strings through for the server to resolve (core-api's ApiCredential::setExpiresAtAttribute owns the relative-time parsing), serializes real Dates to ISO strings, and deserializes datetimes like the date transform so the display path (expiresAt computed, list column) keeps receiving Date instances. Also make the engine's own test suite runnable, since nothing covered this path (or ran at all): - eager-load the engine for its own `ember test` runs (fleetops pattern) so the dummy app can resolve engine modules - add @ember/legacy-built-in-components so ember-engines' LinkToExternal has an extensible base class under ember-source 5.4 - add prismjs and skip the ember-prism component config for self-test builds, where ember-cli-node-assets registers imports without funneling the files and the vendor concat fails - regression tests: relative strings survive model.serialize(), Dates serialize to ISO, datetimes deserialize to Dates for display --- addon/models/api-credential.js | 2 +- addon/transforms/expiration.js | 50 +++++++++++++++ app/transforms/expiration.js | 1 + index.js | 23 ++++--- package.json | 2 + pnpm-lock.yaml | 77 ++++++++++++++++-------- tests/unit/models/api-credential-test.js | 26 ++++++++ tests/unit/transforms/expiration-test.js | 41 +++++++++++++ 8 files changed, 188 insertions(+), 34 deletions(-) create mode 100644 addon/transforms/expiration.js create mode 100644 app/transforms/expiration.js create mode 100644 tests/unit/transforms/expiration-test.js diff --git a/addon/models/api-credential.js b/addon/models/api-credential.js index ac82093..ca20df1 100644 --- a/addon/models/api-credential.js +++ b/addon/models/api-credential.js @@ -17,7 +17,7 @@ export default class ApiCredentialModel extends Model { /** @dates */ @attr('date') last_used_at; - @attr('date') expires_at; + @attr('expiration') expires_at; @attr('date') deleted_at; @attr('date') created_at; @attr('date') updated_at; diff --git a/addon/transforms/expiration.js b/addon/transforms/expiration.js new file mode 100644 index 0000000..5df4b71 --- /dev/null +++ b/addon/transforms/expiration.js @@ -0,0 +1,50 @@ +import Transform from '@ember-data/serializer/transform'; + +/** + * Transform for expiration attributes which the API accepts as either a + * datetime or a relative expiration string ('never', 'immediately', + * 'in 1 hour', 'in 24 hours', ...) resolved server side. + * + * Deserializes like the standard `date` transform so date reads keep + * returning `Date` instances, but serializes strings untouched — the + * `date` transform serializes any non-Date value to `null`, which + * silently discards a selected relative expiration. + */ +export default class ExpirationTransform extends Transform { + deserialize(serialized) { + const type = typeof serialized; + + if (type === 'string') { + let offset = serialized.indexOf('+'); + + if (offset !== -1 && serialized.length - 5 === offset) { + offset += 3; + return new Date(serialized.slice(0, offset) + ':' + serialized.slice(offset)); + } + + return new Date(serialized); + } + + if (type === 'number') { + return new Date(serialized); + } + + if (serialized === null || serialized === undefined) { + return serialized; + } + + return null; + } + + serialize(deserialized) { + if (typeof deserialized === 'string') { + return deserialized; + } + + if (deserialized instanceof Date && !isNaN(deserialized)) { + return deserialized.toISOString(); + } + + return null; + } +} diff --git a/app/transforms/expiration.js b/app/transforms/expiration.js new file mode 100644 index 0000000..03dad7f --- /dev/null +++ b/app/transforms/expiration.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/transforms/expiration'; diff --git a/index.js b/index.js index d8134c2..f8ba24a 100644 --- a/index.js +++ b/index.js @@ -2,22 +2,31 @@ const { buildEngine } = require('ember-engines/lib/engine-addon'); const { name } = require('./package'); +// The engine's own test suite (`ember test` run from this package) needs the +// engine modules loaded eagerly so the dummy app can resolve them; hosts always +// get the lazy engine. Same pattern as the fleetops engine. +const isRunningOwnTests = process.argv.includes('test') && process.cwd() === __dirname; + module.exports = buildEngine({ name, lazyLoading: { - enabled: true, + enabled: !isRunningOwnTests, }, included(app) { this._super.included.apply(this, arguments); - // Configure ember-prism for the addon - app.options = app.options || {}; - app.options['ember-prism'] = { - components: ['json', 'javascript'], - plugins: ['line-highlight', 'line-numbers'], - }; + // Configure ember-prism for the addon; skipped for the eager dummy-app + // test build, where ember-cli-node-assets registers the component and + // plugin imports without funneling the files in and the vendor concat fails + if (!isRunningOwnTests) { + app.options = app.options || {}; + app.options['ember-prism'] = { + components: ['json', 'javascript'], + plugins: ['line-highlight', 'line-numbers'], + }; + } }, isDevelopingAddon() { diff --git a/package.json b/package.json index 0ee9458..883cb37 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "devDependencies": { "@babel/eslint-parser": "^7.22.15", "@babel/plugin-proposal-decorators": "^7.23.2", + "@ember/legacy-built-in-components": "^0.4.2", "@ember/optional-features": "^2.0.0", "@ember/test-helpers": "^3.2.0", "@embroider/test-setup": "^3.0.2", @@ -91,6 +92,7 @@ "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-qunit": "^8.0.1", "loader.js": "^4.7.0", + "prismjs": "^1.29.0", "prettier": "^3.0.3", "qunit": "^2.20.0", "qunit-dom": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0dc0e7..7ce9537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@babel/plugin-proposal-decorators': specifier: ^7.23.2 version: 7.28.0(@babel/core@7.28.5) + '@ember/legacy-built-in-components': + specifier: ^0.4.2 + version: 0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) '@ember/optional-features': specifier: ^2.0.0 version: 2.3.0 @@ -107,7 +110,7 @@ importers: version: 4.12.8(@babel/core@7.28.5)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0))(webpack@5.103.0) ember-engines: specifier: ^0.9.0 - version: 0.9.0(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) + version: 0.9.0(@ember/legacy-built-in-components@0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.28.5) @@ -156,6 +159,9 @@ importers: prettier: specifier: ^3.0.3 version: 3.7.4 + prismjs: + specifier: ^1.29.0 + version: 1.30.0 qunit: specifier: ^2.20.0 version: 2.24.3 @@ -1136,6 +1142,12 @@ packages: '@ember/edition-utils@1.2.0': resolution: {integrity: sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog==} + '@ember/legacy-built-in-components@0.4.2': + resolution: {integrity: sha512-rJulbyVQIVe1zEDQDqAQHechHy44DsS2qxO24+NmU/AYxwPFSzWC/OZNCDFSfLU+Y5BVd/00qjxF0pu7Nk+TNA==} + engines: {node: 12.* || 14.* || >= 16} + peerDependencies: + ember-source: '*' + '@ember/optional-features@2.3.0': resolution: {integrity: sha512-+M8CkPledQEaDbfIlwlq6Phgpm5jdT3a6WVDJk7b/zadw5xAJkuQKVK7DgR0SFgHGiWlyn6a8AU5p2mCA706RA==} engines: {node: 10.* || 12.* || >= 14} @@ -9700,6 +9712,17 @@ snapshots: '@ember/edition-utils@1.2.0': {} + '@ember/legacy-built-in-components@0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0))': + dependencies: + '@embroider/macros': 1.19.5 + ember-cli-babel: 7.26.11 + ember-cli-htmlbars: 5.7.2 + ember-cli-typescript: 4.2.1 + ember-source: 5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0) + transitivePeerDependencies: + - '@glint/template' + - supports-color + '@ember/optional-features@2.3.0': dependencies: chalk: 4.1.2 @@ -9785,9 +9808,9 @@ snapshots: babel-import-util: 3.0.1 ember-cli-babel: 7.26.11 find-up: 5.0.0 - lodash: 4.17.21 + lodash: 4.18.1 resolve: 1.22.11 - semver: 7.7.3 + semver: 7.8.4 transitivePeerDependencies: - supports-color @@ -9802,9 +9825,9 @@ snapshots: ember-rfc176-data: 0.3.18 fs-extra: 9.1.0 js-string-escape: 1.0.1 - lodash: 4.17.21 + lodash: 4.18.1 resolve-package-path: 4.0.3 - semver: 7.7.3 + semver: 7.8.4 typescript-memoize: 1.1.1 '@embroider/shared-internals@2.9.2': @@ -9832,12 +9855,12 @@ snapshots: fs-extra: 9.1.0 is-subdir: 1.2.0 js-string-escape: 1.0.1 - lodash: 4.17.21 - minimatch: 3.1.2 - pkg-entry-points: 1.1.1 + lodash: 4.18.1 + minimatch: 3.1.5 + pkg-entry-points: 1.1.2 resolve-package-path: 4.0.3 resolve.exports: 2.0.3 - semver: 7.7.3 + semver: 7.8.4 typescript-memoize: 1.1.1 transitivePeerDependencies: - supports-color @@ -10805,7 +10828,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 10.5.0 '@types/http-errors@2.0.5': {} @@ -10830,7 +10853,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 7.4.6 + minimatch: 9.0.5 '@types/minimist@1.2.5': {} @@ -11295,7 +11318,7 @@ snapshots: async@2.6.4: dependencies: - lodash: 4.17.21 + lodash: 4.18.1 async@3.2.6: {} @@ -11342,8 +11365,8 @@ snapshots: convert-source-map: 1.9.0 debug: 2.6.9 json5: 0.5.1 - lodash: 4.17.21 - minimatch: 3.1.2 + lodash: 4.18.1 + minimatch: 3.1.5 path-is-absolute: 1.0.1 private: 0.1.8 slash: 1.0.0 @@ -11358,7 +11381,7 @@ snapshots: babel-types: 6.26.0 detect-indent: 4.0.0 jsesc: 1.3.0 - lodash: 4.17.21 + lodash: 4.18.1 source-map: 0.5.7 trim-right: 1.0.1 @@ -11487,7 +11510,7 @@ snapshots: babel-runtime: 6.26.0 core-js: 2.6.12 home-or-tmp: 2.0.0 - lodash: 4.17.21 + lodash: 4.18.1 mkdirp: 0.5.6 source-map-support: 0.4.18 transitivePeerDependencies: @@ -11504,7 +11527,7 @@ snapshots: babel-traverse: 6.26.0 babel-types: 6.26.0 babylon: 6.18.0 - lodash: 4.17.21 + lodash: 4.18.1 transitivePeerDependencies: - supports-color @@ -11518,7 +11541,7 @@ snapshots: debug: 2.6.9 globals: 9.18.0 invariant: 2.2.4 - lodash: 4.17.21 + lodash: 4.18.1 transitivePeerDependencies: - supports-color @@ -11526,7 +11549,7 @@ snapshots: dependencies: babel-runtime: 6.26.0 esutils: 2.0.3 - lodash: 4.17.21 + lodash: 4.18.1 to-fast-properties: 1.0.3 babel6-plugin-strip-class-callcheck@6.0.0: {} @@ -11873,7 +11896,7 @@ snapshots: fast-ordered-set: 1.0.3 fs-tree-diff: 0.5.9 heimdalljs: 0.2.6 - minimatch: 3.1.2 + minimatch: 3.1.5 mkdirp: 0.5.6 path-posix: 1.0.0 rimraf: 2.7.1 @@ -12140,7 +12163,7 @@ snapshots: debug: 3.2.7 ensure-posix-path: 1.1.1 fs-extra: 5.0.0 - minimatch: 3.1.2 + minimatch: 3.1.5 resolve: 1.22.11 rsvp: 4.8.5 symlink-or-copy: 1.3.1 @@ -13382,7 +13405,7 @@ snapshots: hash-for-dep: 1.5.1 heimdalljs-logger: 0.1.10 json-stable-stringify: 1.3.0 - semver: 7.7.3 + semver: 7.8.4 silent-error: 1.1.1 strip-bom: 4.0.0 walk-sync: 2.2.0 @@ -13555,7 +13578,7 @@ snapshots: fs-extra: 9.1.0 resolve: 1.22.11 rsvp: 4.8.5 - semver: 7.7.3 + semver: 7.8.4 stagehand: 1.0.1 walk-sync: 2.2.0 transitivePeerDependencies: @@ -13915,7 +13938,7 @@ snapshots: transitivePeerDependencies: - supports-color - ember-engines@0.9.0(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)): + ember-engines@0.9.0(@ember/legacy-built-in-components@0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)): dependencies: '@embroider/macros': 1.19.5 amd-name-resolver: 1.3.1 @@ -13935,6 +13958,8 @@ snapshots: ember-cli-version-checker: 5.1.2 ember-source: 5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0) lodash: 4.17.21 + optionalDependencies: + '@ember/legacy-built-in-components': 0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) transitivePeerDependencies: - '@glint/template' - supports-color @@ -15838,7 +15863,7 @@ snapshots: cli-width: 2.2.1 external-editor: 3.1.0 figures: 2.0.0 - lodash: 4.17.21 + lodash: 4.18.1 mute-stream: 0.0.7 run-async: 2.4.1 rxjs: 6.6.7 @@ -16819,7 +16844,7 @@ snapshots: dependencies: growly: 1.3.0 is-wsl: 2.2.0 - semver: 7.7.3 + semver: 7.8.4 shellwords: 0.1.1 uuid: 8.3.2 which: 2.0.2 diff --git a/tests/unit/models/api-credential-test.js b/tests/unit/models/api-credential-test.js index 294e7f3..cdb8df7 100644 --- a/tests/unit/models/api-credential-test.js +++ b/tests/unit/models/api-credential-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { format as formatDate } from 'date-fns'; module('Unit | Model | api credential', function (hooks) { setupTest(hooks); @@ -10,4 +11,29 @@ module('Unit | Model | api credential', function (hooks) { let model = store.createRecord('api-credential', {}); assert.ok(model); }); + + test('it serializes relative expiration strings untouched for the API to resolve', function (assert) { + const store = this.owner.lookup('service:store'); + + for (const option of ['never', 'immediately', 'in 1 hour', 'in 24 hours', 'in 3 days', 'in 7 days']) { + const model = store.createRecord('api-credential', { expires_at: option }); + assert.strictEqual(model.serialize().expires_at, option); + model.unloadRecord(); + } + }); + + test('it serializes an expiration date to an ISO string', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.createRecord('api-credential', { expires_at: new Date('2026-08-28T12:00:00.000Z') }); + + assert.strictEqual(model.serialize().expires_at, '2026-08-28T12:00:00.000Z'); + }); + + test('it deserializes an expiration datetime for display', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.push(store.normalize('api-credential', { uuid: 'api_credential_uuid', expires_at: '2026-08-28T12:00:00.000Z' })); + + assert.true(model.expires_at instanceof Date); + assert.strictEqual(model.expiresAt, formatDate(new Date('2026-08-28T12:00:00.000Z'), 'yyyy-MM-dd HH:mm')); + }); }); diff --git a/tests/unit/transforms/expiration-test.js b/tests/unit/transforms/expiration-test.js new file mode 100644 index 0000000..36a8750 --- /dev/null +++ b/tests/unit/transforms/expiration-test.js @@ -0,0 +1,41 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Transform | expiration', function (hooks) { + setupTest(hooks); + + test('it serializes relative expiration strings untouched', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + + for (const option of ['never', 'immediately', 'in 1 hour', 'in 24 hours', 'in 3 days', 'in 7 days']) { + assert.strictEqual(transform.serialize(option), option); + } + }); + + test('it serializes dates to ISO strings', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + const date = new Date('2026-08-28T12:00:00.000Z'); + + assert.strictEqual(transform.serialize(date), '2026-08-28T12:00:00.000Z'); + }); + + test('it serializes empty and invalid values to null', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + + assert.strictEqual(transform.serialize(null), null); + assert.strictEqual(transform.serialize(undefined), null); + assert.strictEqual(transform.serialize(new Date('not a date')), null); + assert.strictEqual(transform.serialize(12345), null); + }); + + test('it deserializes datetime strings and timestamps to dates', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + + assert.strictEqual(transform.deserialize('2026-08-28T12:00:00.000Z').getTime(), Date.parse('2026-08-28T12:00:00.000Z')); + assert.strictEqual(transform.deserialize('2026-08-28T12:00:00+0000').getTime(), Date.parse('2026-08-28T12:00:00+00:00')); + assert.strictEqual(transform.deserialize(1756382400000).getTime(), 1756382400000); + assert.strictEqual(transform.deserialize(null), null); + assert.strictEqual(transform.deserialize(undefined), undefined); + assert.strictEqual(transform.deserialize({}), null); + }); +});