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: