From 10dab0d742935c23a3ed994646b66e7dfd0f9c45 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:54:23 +0800 Subject: [PATCH 1/2] feat(socket-auth): show channel authorization failures in the sockets viewer The sockets viewer (including channels opened from the custom-channel modal, which lands on the same view) now logs a refused subscription instead of showing "Awaiting events..." forever. Authorization refusals from the socket server (AuthError) show their reason; other failures show their message. Kick-outs are logged too, and a later resubscribe (the socket service retries channels lost for token reasons) clears the failure state. Event content is now rendered as escaped text with preserved whitespace instead of through html-safe, since it carries server-provided strings and published payloads. --- addon/controllers/sockets/view.js | 99 +++++++--- addon/templates/sockets/view.hbs | 6 +- tests/unit/controllers/sockets/view-test.js | 189 +++++++++++++++++++- translations/ar-ae.yaml | 3 + translations/bg-bg.yaml | 3 + translations/en-us.yaml | 3 + translations/es-es.yaml | 3 + translations/fr-fr.yaml | 3 + translations/mn-mn.yaml | 3 + translations/pt-br.yaml | 3 + translations/ru-ru.yaml | 3 + translations/uk-ua.yaml | 3 + translations/vi-vn.yaml | 3 + translations/zh-cn.yaml | 3 + 14 files changed, 295 insertions(+), 32 deletions(-) diff --git a/addon/controllers/sockets/view.js b/addon/controllers/sockets/view.js index 864dadc..9801172 100644 --- a/addon/controllers/sockets/view.js +++ b/addon/controllers/sockets/view.js @@ -43,6 +43,54 @@ export default class SocketsViewController extends BaseController { return window.history.back(); } + /** + * Whether the last subscription attempt was refused. Cleared when the + * channel subscribes (the socket service resubscribes channels it lost + * for token reasons) and when leaving the page. + * + * @memberof SocketsViewController + */ + @tracked subscriptionFailed = false; + + /** + * The "Awaiting events..." indicator only makes sense while the channel can + * still deliver something. + * + * @memberof SocketsViewController + */ + get isAwaitingEvents() { + return this.events.length > 0 && !this.subscriptionFailed; + } + + /** + * Appends a line to the console output. + * + * @param {String} content + * @param {String} color tailwind color name + * @memberof SocketsViewController + */ + logEvent(content, color) { + // Reassigned rather than mutated so the tracked property invalidates. + this.events = [...this.events, { time: format(new Date(), this.consoleDateFormat), content, color }]; + } + + /** + * Describes a refused subscription. Authorization refusals from the socket + * server arrive as an `AuthError` with a short snake_case `reason`. + * + * @param {String} channelName + * @param {Error} error + * @return {String} + * @memberof SocketsViewController + */ + describeSubscribeFailure(channelName, error) { + if (error && error.name === 'AuthError') { + return this.intl.t('developers.sockets.view.socket-subscribe-denied', { modelName: channelName, reason: error.reason || error.message }); + } + + return this.intl.t('developers.sockets.view.socket-subscribe-failed', { modelName: channelName, message: error ? error.message : '' }); + } + /** * Opens socket and logs all incoming events. * @@ -56,12 +104,7 @@ export default class SocketsViewController extends BaseController { (async () => { // eslint-disable-next-line no-unused-vars for await (let event of socket.listener('error')) { - // Push an event or notification for socket connection here - this.events.pushObject({ - time: format(new Date(), this.consoleDateFormat), - content: this.intl.t('developers.sockets.view.socket-connection-error'), - color: 'red', - }); + this.logEvent(this.intl.t('developers.sockets.view.socket-connection-error'), 'red'); } })(); @@ -69,39 +112,44 @@ export default class SocketsViewController extends BaseController { (async () => { // eslint-disable-next-line no-unused-vars for await (let event of socket.listener('connect')) { - // Push an event or notification for socket connection here - this.events.pushObject({ - time: format(new Date(), this.consoleDateFormat), - content: this.intl.t('developers.sockets.view.socket-connected'), - color: 'green', - }); + this.logEvent(this.intl.t('developers.sockets.view.socket-connected'), 'green'); } })(); - // Listed on company channel + // Subscribe to the channel const channel = socket.subscribe(model.name); // Listen for channel subscription (async () => { // eslint-disable-next-line no-unused-vars for await (let event of channel.listener('subscribe')) { - // Push an event or notification for channel subscription here - this.events.pushObject({ - time: format(new Date(), this.consoleDateFormat), - content: this.intl.t('developers.sockets.view.socket-subscribed', { modelName: model.name }), - color: 'blue', - }); + this.subscriptionFailed = false; + this.logEvent(this.intl.t('developers.sockets.view.socket-subscribed', { modelName: model.name }), 'blue'); } })(); - // Listen for channel subscription + // Listen for a refused subscription, e.g. a channel this user is not + // authorized to see. Without this the page would wait silently forever. + (async () => { + for await (let { error } of channel.listener('subscribeFail')) { + this.subscriptionFailed = true; + this.logEvent(this.describeSubscribeFailure(model.name, error), 'red'); + } + })(); + + // Listen for the server removing the subscription (for example when the + // socket token expired). The socket service resubscribes when it can, and + // the subscribe listener above logs that. + (async () => { + for await (let { message } of channel.listener('kickOut')) { + this.logEvent(this.intl.t('developers.sockets.view.socket-kicked-out', { modelName: model.name, reason: message }), 'red'); + } + })(); + + // Listen for channel data (async () => { for await (let data of channel) { - this.events.pushObject({ - time: format(new Date(), this.consoleDateFormat), - content: JSON.stringify(data, undefined, 2), - color: 'green', - }); + this.logEvent(JSON.stringify(data, undefined, 2), 'green'); } })(); @@ -109,6 +157,7 @@ export default class SocketsViewController extends BaseController { this.hostRouter.on('routeWillChange', () => { channel.close(); this.events = []; + this.subscriptionFailed = false; }); } } diff --git a/addon/templates/sockets/view.hbs b/addon/templates/sockets/view.hbs index 86ebc5e..2d06057 100644 --- a/addon/templates/sockets/view.hbs +++ b/addon/templates/sockets/view.hbs @@ -20,7 +20,7 @@
{{t "developers.sockets.view.output"}}
- {{#if this.events.length}} + {{#if this.isAwaitingEvents}}
@@ -37,9 +37,7 @@
[{{event.time}}]:
-
- {{html-safe event.content}} -
+
{{event.content}}
{{else}}
diff --git a/tests/unit/controllers/sockets/view-test.js b/tests/unit/controllers/sockets/view-test.js index de0da6a..a91539d 100644 --- a/tests/unit/controllers/sockets/view-test.js +++ b/tests/unit/controllers/sockets/view-test.js @@ -1,12 +1,195 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +class IntlStub { + t(key, params) { + return params ? `${key} ${JSON.stringify(params)}` : key; + } +} + +class HostRouterStub { + handlers = {}; + + on(eventName, handler) { + this.handlers[eventName] = handler; + } +} + +class EmptyServiceStub {} + +// An async-iterable event stream a test can push into. +function eventStream() { + const queue = []; + const waiting = []; + + return { + push(value) { + if (waiting.length) { + waiting.shift()({ done: false, value }); + } else { + queue.push(value); + } + }, + close() { + while (waiting.length) { + waiting.shift()({ done: true, value: undefined }); + } + }, + [Symbol.asyncIterator]() { + return { + next: () => (queue.length ? Promise.resolve({ done: false, value: queue.shift() }) : new Promise((resolve) => waiting.push(resolve))), + }; + }, + }; +} + +function fakeChannel() { + const listeners = {}; + const data = eventStream(); + + return { + closed: false, + listener(eventName) { + listeners[eventName] = listeners[eventName] || eventStream(); + return listeners[eventName]; + }, + emit(eventName, value) { + this.listener(eventName).push(value); + }, + publish(value) { + data.push(value); + }, + close() { + this.closed = true; + Object.values(listeners).forEach((stream) => stream.close()); + data.close(); + }, + [Symbol.asyncIterator]() { + return data[Symbol.asyncIterator](); + }, + }; +} + +// Stands in for the ember-core socket service: `instance()` returns a client +// whose subscribe hands back the test's channel. +class SocketStub { + channel = null; + subscribed = []; + clientListeners = {}; + + instance() { + return { + listener: (eventName) => { + this.clientListeners[eventName] = this.clientListeners[eventName] || eventStream(); + return this.clientListeners[eventName]; + }, + subscribe: (name) => { + this.subscribed.push(name); + return this.channel; + }, + }; + } +} + +function authError(reason) { + const error = new Error(`Subscription to channel denied: ${reason}`); + error.name = 'AuthError'; + error.reason = reason; + return error; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + module('Unit | Controller | sockets/view', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlStub); + this.owner.register('service:host-router', HostRouterStub); + this.owner.register('service:socket', SocketStub); + this.owner.register('service:universe', EmptyServiceStub); + + this.controller = this.owner.lookup('controller:sockets/view'); + this.channel = fakeChannel(); + this.socket = this.owner.lookup('service:socket'); + this.socket.channel = this.channel; + }); + test('it exists', function (assert) { - let controller = this.owner.lookup('controller:sockets/view'); - assert.ok(controller); + assert.ok(this.controller); + }); + + test('an authorization refusal is shown with its reason', function (assert) { + const message = this.controller.describeSubscribeFailure('order.abc', authError('forbidden')); + + assert.true(message.startsWith('developers.sockets.view.socket-subscribe-denied')); + assert.true(message.includes('"reason":"forbidden"')); + }); + + test('an authorization refusal without a reason falls back to the message', function (assert) { + const error = authError(undefined); + const message = this.controller.describeSubscribeFailure('order.abc', error); + + assert.true(message.includes(JSON.stringify(error.message).slice(1, -1))); + }); + + test('any other refusal is shown with its message', function (assert) { + const message = this.controller.describeSubscribeFailure('order.abc', new Error('Socket hung up')); + + assert.true(message.startsWith('developers.sockets.view.socket-subscribe-failed')); + assert.true(message.includes('Socket hung up')); + assert.true(this.controller.describeSubscribeFailure('order.abc', null).startsWith('developers.sockets.view.socket-subscribe-failed')); + }); + + test('a refused subscription is logged and stops the awaiting indicator', async function (assert) { + this.controller.watchSocket({ name: 'order.abc' }); + assert.deepEqual(this.socket.subscribed, ['order.abc']); + + this.channel.emit('subscribeFail', { error: authError('forbidden') }); + await flush(); + + assert.true(this.controller.subscriptionFailed); + assert.false(this.controller.isAwaitingEvents); + assert.strictEqual(this.controller.events.length, 1); + assert.strictEqual(this.controller.events[0].color, 'red'); + assert.true(this.controller.events[0].content.startsWith('developers.sockets.view.socket-subscribe-denied')); + }); + + test('a kick-out is logged and a later resubscribe clears the failure', async function (assert) { + this.controller.watchSocket({ name: 'order.abc' }); + + this.channel.emit('subscribeFail', { error: authError('token_expired') }); + this.channel.emit('kickOut', { message: 'token_expired' }); + await flush(); + this.channel.emit('subscribe', {}); + await flush(); + + assert.false(this.controller.subscriptionFailed); + assert.true(this.controller.isAwaitingEvents); + assert.deepEqual( + this.controller.events.map((event) => event.content.split(' ')[0]), + ['developers.sockets.view.socket-subscribe-denied', 'developers.sockets.view.socket-kicked-out', 'developers.sockets.view.socket-subscribed'] + ); + }); + + test('channel data is logged as JSON', async function (assert) { + this.controller.watchSocket({ name: 'order.abc' }); + + this.channel.publish({ event: 'order.updated' }); + await flush(); + + assert.strictEqual(this.controller.events[0].content, JSON.stringify({ event: 'order.updated' }, undefined, 2)); + }); + + test('leaving the page closes the channel and resets the output', async function (assert) { + this.controller.watchSocket({ name: 'order.abc' }); + this.channel.emit('subscribeFail', { error: authError('forbidden') }); + await flush(); + + this.owner.lookup('service:host-router').handlers.routeWillChange(); + + assert.true(this.channel.closed); + assert.deepEqual(this.controller.events, []); + assert.false(this.controller.subscriptionFailed); }); }); diff --git a/translations/ar-ae.yaml b/translations/ar-ae.yaml index 2f15983..1d12158 100644 --- a/translations/ar-ae.yaml +++ b/translations/ar-ae.yaml @@ -252,6 +252,9 @@ developers: socket-connection-error: خطأ في اتصال المقبس! socket-connected: المقبس متصل socket-subscribed: المقبس مشترك في القناة {modelName} + socket-subscribe-denied: غير مصرح بالاشتراك في القناة {modelName} ({reason}) + socket-subscribe-failed: "تعذر الاشتراك في القناة {modelName}: {message}" + socket-kicked-out: تمت الإزالة من القناة {modelName} ({reason}) output: الإخراج awaiting-events: في انتظار الأحداث... webhooks: diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml index 84db0bf..cd51973 100644 --- a/translations/bg-bg.yaml +++ b/translations/bg-bg.yaml @@ -264,6 +264,9 @@ developers: socket-connection-error: Грешка при свързване на сокет! socket-connected: Сокетът е свързан socket-subscribed: Сокетът е абониран за канал {modelName} + socket-subscribe-denied: Няма разрешение за абониране за канал {modelName} ({reason}) + socket-subscribe-failed: "Неуспешно абониране за канал {modelName}: {message}" + socket-kicked-out: Премахнат от канал {modelName} ({reason}) output: Изход awaiting-events: Очакване на събития... webhooks: diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 505aed4..6027255 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -240,6 +240,9 @@ developers: socket-connection-error: Socket connection error! socket-connected: Socket is connected socket-subscribed: Socket subscribed to channel {modelName} + socket-subscribe-denied: Not authorized to subscribe to channel {modelName} ({reason}) + socket-subscribe-failed: "Could not subscribe to channel {modelName}: {message}" + socket-kicked-out: Removed from channel {modelName} ({reason}) output: Output awaiting-events: Awaiting events... webhooks: diff --git a/translations/es-es.yaml b/translations/es-es.yaml index d1bf532..c6861f5 100644 --- a/translations/es-es.yaml +++ b/translations/es-es.yaml @@ -266,6 +266,9 @@ developers: socket-connection-error: ¡Error de conexión de socket! socket-connected: Socket conectado socket-subscribed: Socket suscrito al canal {modelName} + socket-subscribe-denied: No autorizado para suscribirse al canal {modelName} ({reason}) + socket-subscribe-failed: "No se pudo suscribir al canal {modelName}: {message}" + socket-kicked-out: Eliminado del canal {modelName} ({reason}) output: Salida awaiting-events: Esperando eventos... webhooks: diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 3715803..f72e0f5 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -266,6 +266,9 @@ developers: socket-connection-error: Erreur de connexion socket ! socket-connected: Socket connecté socket-subscribed: Socket abonné au canal {modelName} + socket-subscribe-denied: "Non autorisé à s'abonner au canal {modelName} ({reason})" + socket-subscribe-failed: "Impossible de s'abonner au canal {modelName} : {message}" + socket-kicked-out: Retiré du canal {modelName} ({reason}) output: Sortie awaiting-events: En attente d'événements... webhooks: diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml index 31ef24c..80c39d0 100644 --- a/translations/mn-mn.yaml +++ b/translations/mn-mn.yaml @@ -259,6 +259,9 @@ developers: socket-connection-error: Сокет холболтын алдаа! socket-connected: Сокет холбогдсон байна socket-subscribed: Сокет {modelName} сувгад захиалсан байна + socket-subscribe-denied: "{modelName} сувагт захиалах эрхгүй ({reason})" + socket-subscribe-failed: "{modelName} сувагт захиалж чадсангүй: {message}" + socket-kicked-out: "{modelName} сувгаас хасагдсан ({reason})" output: Гаралт awaiting-events: Үйл явдлуудыг хүлээж байна... webhooks: diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index c9a50c0..10c4856 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -261,6 +261,9 @@ developers: socket-connection-error: Erro na conexão do socket! socket-connected: Socket está conectado socket-subscribed: Socket inscrito no canal {modelName} + socket-subscribe-denied: Sem autorização para se inscrever no canal {modelName} ({reason}) + socket-subscribe-failed: "Não foi possível se inscrever no canal {modelName}: {message}" + socket-kicked-out: Removido do canal {modelName} ({reason}) output: Saída awaiting-events: Aguardando eventos... webhooks: diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 7832d35..e22bc3e 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -258,6 +258,9 @@ developers: socket-connection-error: Ошибка подключения сокета! socket-connected: Сокет подключен socket-subscribed: Сокет подписан на канал {modelName} + socket-subscribe-denied: Нет прав на подписку на канал {modelName} ({reason}) + socket-subscribe-failed: "Не удалось подписаться на канал {modelName}: {message}" + socket-kicked-out: Удалён из канала {modelName} ({reason}) output: Вывод awaiting-events: Ожидание событий... webhooks: diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index 7e7177d..62d5093 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -240,6 +240,9 @@ developers: socket-connection-error: Помилка підключення до сокета! socket-connected: Сокет підключено socket-subscribed: Сокет підписано на канал {modelName} + socket-subscribe-denied: Немає дозволу на підписку на канал {modelName} ({reason}) + socket-subscribe-failed: "Не вдалося підписатися на канал {modelName}: {message}" + socket-kicked-out: Видалено з каналу {modelName} ({reason}) output: Вивід awaiting-events: Очікування подій... webhooks: diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml index 2cbbdf5..72feb60 100644 --- a/translations/vi-vn.yaml +++ b/translations/vi-vn.yaml @@ -258,6 +258,9 @@ developers: socket-connection-error: Lỗi kết nối Socket! socket-connected: Socket đã kết nối socket-subscribed: Socket đã đăng ký kênh {modelName} + socket-subscribe-denied: Không được phép đăng ký kênh {modelName} ({reason}) + socket-subscribe-failed: "Không thể đăng ký kênh {modelName}: {message}" + socket-kicked-out: Đã bị xóa khỏi kênh {modelName} ({reason}) output: Đầu ra awaiting-events: Đang chờ sự kiện... webhooks: diff --git a/translations/zh-cn.yaml b/translations/zh-cn.yaml index d6f5944..7ff3cfc 100644 --- a/translations/zh-cn.yaml +++ b/translations/zh-cn.yaml @@ -240,6 +240,9 @@ developers: socket-connection-error: Socket 连接错误! socket-connected: Socket 已连接 socket-subscribed: Socket 已订阅频道 {modelName} + socket-subscribe-denied: 无权订阅频道 {modelName}({reason}) + socket-subscribe-failed: 无法订阅频道 {modelName}:{message} + socket-kicked-out: 已被移出频道 {modelName}({reason}) output: 输出 awaiting-events: 等待事件中... webhooks: From f63c64fe445564ae5d2bfc6e4eb98d48f9be79c8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 12:48:18 +0800 Subject: [PATCH 2/2] chore(release): v0.2.18 --- RELEASE.md | 11 ++++------- package.json | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index b5529a6..56c423d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,17 +1,14 @@ -> v0.2.17 ~ "Extensions can add columns, actions and buttons to developer pages" +> v0.2.18 ~ "The sockets viewer shows channel authorization failures" --- ## Highlights -- **Resource view registries.** Extensions can add the following for `api-key`, `webhook`, `event`, `log` and `socket`: - - columns, row actions, bulk actions and toolbar buttons, through `developers::table:`; - - header buttons and menu items on the detail pages, through `developers::details:`. -- **API keys, webhooks, events, logs and sockets use the standard table layout.** -- **Fix: search on the events and logs pages was always disabled.** +- **Channel authorization failures are visible.** The sockets viewer logs `subscribeFail` and `kickOut` with the server's reason, for example when a channel is not authorized, instead of waiting indefinitely. The custom-channel dialog opens the same view, so it is covered too. ([#51](https://github.com/fleetbase/dev-engine/pull/51)) +- **Fix: socket event payloads render as text.** Published payloads were rendered as raw HTML in the sockets viewer; they are now escaped. --- ## Upgrading -Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5. +Pairs with fleetbase/ember-core v0.3.26, which authenticates the console socket. No configuration changes. --- ## Need help? diff --git a/package.json b/package.json index 9989b72..a337825 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/dev-engine", - "version": "0.2.17", + "version": "0.2.18", "description": "Fleetbase Developers extension provides a module for managing developer resources such as API keys, webhooks, sockets, events and logs.", "fleetbase": { "route": "developers"