diff --git a/CHANGELOG.md b/CHANGELOG.md index 6444e27..f811d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 2.0.1 + +### Bug Fix: JWT logout drops completion callback, breaking `addLogoutHandler` + +`_sessionDestroyJwt`'s success and no-refresh-token early-return paths both forgot to invoke the user-supplied callback. The HMAC equivalent (`sessionDestroy` proper) does invoke it on both success and error. `addLogoutHandler` puts `location.href = '/'` in that callback, so in JWT mode the redirect never fired — clicking logout cleared tokens locally and revoked them server-side, but the user stayed on the post-logout page, looking like they were still logged in. + +Fix: invoke the completion callback on **all three** exit paths of `_sessionDestroyJwt` — success, error, and the `!refreshToken` early return. Matches the HMAC `sessionDestroy` contract. + +Regression coverage in `tests/kyte.test.js`: three new tests cover each path, mocking `$.ajax` and asserting the callback fires + tokens are cleared. + +Customer-visible impact: Shipyard 2.0.0 users on JWT mode (default) couldn't actually log out from the UI prior to 2.0.1. + ## 2.0.0 ### Major: dual-auth support (HMAC + JWT) diff --git a/kyte-source.js b/kyte-source.js index 3a5e88f..dc88e4c 100644 --- a/kyte-source.js +++ b/kyte-source.js @@ -17,7 +17,7 @@ **/ class Kyte { /** KyteJS Version # */ - static VERSION = '2.0.0'; + static VERSION = '2.0.1'; /** **************** */ /** @@ -763,6 +763,11 @@ class Kyte { * Clearing local first would leak the still-active refresh token * server-side if the request never goes out; doing it after means * a transient failure still cleans up locally on the next refresh. + * + * The `error` parameter is misnamed for historical reasons — it's + * really a "completion" callback invoked on both success and + * failure, matching HMAC sessionDestroy's contract. addLogoutHandler + * relies on this to redirect to the login page after logout. */ _sessionDestroyJwt = (error = null) => { var obj = this; @@ -778,7 +783,13 @@ class Kyte { }; if (!refreshToken) { + // No server-side token to revoke — just clear local state + // and notify the caller so they can redirect, matching the + // HMAC sessionDestroy contract. obj._jwtClearTokens(); + if (typeof error === 'function') { + error(null); + } return; } @@ -789,7 +800,12 @@ class Kyte { url: obj.url + '/jwt/logout', contentType: 'application/json', data: JSON.stringify({ refresh_token: refreshToken }), - success: function () { obj._jwtClearTokens(); }, + success: function (response) { + obj._jwtClearTokens(); + if (typeof error === 'function') { + error(response); + } + }, error: finish }); } diff --git a/package.json b/package.json index f2ce1c7..3e6b3ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@keyqcloud/kyte-api-js", - "version": "2.0.0", + "version": "2.0.1", "description": "Browser-side JavaScript SDK for the Kyte low-code platform", "private": true, "main": "kyte-source.js", diff --git a/tests/kyte.test.js b/tests/kyte.test.js index 2316e47..d7d797f 100644 --- a/tests/kyte.test.js +++ b/tests/kyte.test.js @@ -103,3 +103,73 @@ describe('Kyte — utilities', () => { expect(k.getNestedValue(obj, 'foo.missing.baz')).toBeUndefined(); }); }); + +// JWT logout — regression coverage for the addLogoutHandler contract. +// _sessionDestroyJwt accepts a completion callback (misnamed `error` for +// historical compatibility with HMAC sessionDestroy) and MUST invoke it on +// every exit path: success, error, and the no-refresh-token early return. +// Without all three, the addLogoutHandler() flow (`location.href = '/'` in +// the callback) silently no-ops on successful logout and users get stuck on +// the post-logout page with cleared tokens. +describe('Kyte — JWT sessionDestroy', () => { + it('invokes the completion callback when no refresh token is held', () => { + const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' }); + // No login happened → no refresh token in state. + expect(k.jwtRefreshToken).toBeFalsy(); + + const onComplete = vi.fn(); + k.sessionDestroy(onComplete); + + expect(onComplete).toHaveBeenCalledOnce(); + }); + + it('invokes the completion callback after successful POST /jwt/logout', () => { + const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' }); + k.jwtRefreshToken = 'kref_v1_fake_test_token'; + k.jwtAccessToken = 'header.payload.sig'; + k.jwtAccessExpiresAt = Date.now() + 900_000; + + // $.ajax is a jQuery function — replace it with our own and restore after. + // (vi.spyOn doesn't work on it because the descriptor isn't configurable.) + const realAjax = globalThis.$.ajax; + let captured; + globalThis.$.ajax = (opts) => { + captured = opts; + opts.success({ ok: true }); + }; + + try { + const onComplete = vi.fn(); + k.sessionDestroy(onComplete); + + expect(captured.url).toBe('https://api.test/jwt/logout'); + expect(JSON.parse(captured.data)).toEqual({ refresh_token: 'kref_v1_fake_test_token' }); + + expect(onComplete).toHaveBeenCalledOnce(); + expect(k.jwtRefreshToken).toBeNull(); + expect(k.jwtAccessToken).toBeNull(); + } finally { + globalThis.$.ajax = realAjax; + } + }); + + it('invokes the completion callback when POST /jwt/logout fails', () => { + const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' }); + k.jwtRefreshToken = 'kref_v1_fake_test_token'; + + const realAjax = globalThis.$.ajax; + globalThis.$.ajax = (opts) => { + opts.error({ status: 500, statusText: 'Internal Server Error' }); + }; + + try { + const onComplete = vi.fn(); + k.sessionDestroy(onComplete); + + expect(onComplete).toHaveBeenCalledOnce(); + expect(k.jwtRefreshToken).toBeNull(); + } finally { + globalThis.$.ajax = realAjax; + } + }); +});