diff --git a/addons/api/addon/models/session.js b/addons/api/addon/models/session.js index 713b618dc..37376633a 100644 --- a/addons/api/addon/models/session.js +++ b/addons/api/addon/models/session.js @@ -151,6 +151,17 @@ export default class SessionModel extends GeneratedSessionModel { return this.store.peekRecord('target', this.target_id); } + /** + * The user associated with this session (if loaded). + * @type {UserModel} + */ + get user() { + if (!this.user_id) { + return null; + } + return this.store.peekRecord('user', this.user_id); + } + // =methods /** diff --git a/addons/api/tests/unit/models/session-test.js b/addons/api/tests/unit/models/session-test.js index ef431f046..ee1ec8f5a 100644 --- a/addons/api/tests/unit/models/session-test.js +++ b/addons/api/tests/unit/models/session-test.js @@ -72,4 +72,88 @@ module('Unit | Model | session', function (hooks) { assert.false(modelA.isUnknownStatus); assert.true(modelB.isUnknownStatus); }); + + test('it has target property and returns the null with no target_id set', function (assert) { + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: 's_123', + type: 'session', + attributes: { target_id: null }, + }, + }); + const session = store.peekRecord('session', 's_123'); + + assert.strictEqual(session.target, null); + }); + + test('it has target property and returns the correct target', function (assert) { + const store = this.owner.lookup('service:store'); + const targetId = 'ttcp_123'; + store.push({ + data: { + id: targetId, + type: 'target', + attributes: { + name: 'admin', + scope: { + scope_id: 'o_1', + type: 'scope', + }, + }, + }, + }); + store.push({ + data: { + id: 's_123', + type: 'session', + attributes: { target_id: targetId }, + }, + }); + const session = store.peekRecord('session', 's_123'); + + assert.strictEqual(session.target.id, targetId); + }); + + test('it has user property and returns the null with no user_id set', function (assert) { + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: 's_123', + type: 'session', + attributes: { user_id: null }, + }, + }); + const session = store.peekRecord('session', 's_123'); + + assert.strictEqual(session.user, null); + }); + + test('it has user property and returns the correct user', function (assert) { + const store = this.owner.lookup('service:store'); + const userId = 'u_123'; + store.push({ + data: { + id: userId, + type: 'user', + attributes: { + name: 'admin', + scope: { + scope_id: 'o_1', + type: 'scope', + }, + }, + }, + }); + store.push({ + data: { + id: 's_123', + type: 'session', + attributes: { user_id: userId }, + }, + }); + const session = store.peekRecord('session', 's_123'); + + assert.strictEqual(session.user.id, userId); + }); }); diff --git a/addons/rose/addon/styles/hds/overrides.scss b/addons/rose/addon/styles/hds/overrides.scss index 7adf8a2ce..56aa72c46 100644 --- a/addons/rose/addon/styles/hds/overrides.scss +++ b/addons/rose/addon/styles/hds/overrides.scss @@ -31,6 +31,13 @@ color: var(--token-form-indicator-optional-color); } + // Override for sortable HDS table columns that ignore selected theme in app + // and use the system default theme causing color mismatch if selected theme + // is not the system default theme + .hds-table__th-sort button { + color: var(--token-color-foreground-strong); + } + // TODO: This is a temporary hack to fix an issue causing weird whitespace // and scrollbars from an accessible class in HDS. We have to use !important // as they already forced it with !important and we can't override it otherwise diff --git a/ui/admin/app/controllers/scopes/scope/sessions/index.js b/ui/admin/app/controllers/scopes/scope/sessions/index.js new file mode 100644 index 000000000..67c4cfbf2 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/sessions/index.js @@ -0,0 +1,115 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { + STATUS_SESSION_ACTIVE, + STATUS_SESSION_PENDING, + STATUS_SESSION_CANCELING, + statusTypes, +} from 'api/models/session'; +import { action } from '@ember/object'; +import { debounce } from 'core/decorators/debounce'; +import { notifySuccess, notifyError } from 'core/decorators/notify'; + +export default class ScopesScopeSessionsIndexController extends Controller { + // =services + + @service store; + @service intl; + + // =attributes + + queryParams = [ + 'search', + { users: { type: 'array' } }, + { targets: { type: 'array' } }, + { status: { type: 'array' } }, + 'page', + 'pageSize', + ]; + + @tracked search = ''; + @tracked users = []; + @tracked targets = []; + @tracked status = [ + STATUS_SESSION_ACTIVE, + STATUS_SESSION_PENDING, + STATUS_SESSION_CANCELING, + ]; + @tracked page = 1; + @tracked pageSize = 10; + + /** + * Returns object of filters to be used for displaying selected filters + * @returns {object} + */ + get filters() { + return { + allFilters: { + users: this.model.associatedUsers, + targets: this.model.associatedTargets, + status: this.sessionStatusOptions, + }, + selectedFilters: { + users: this.users, + targets: this.targets, + status: this.status, + }, + }; + } + + /** + * Returns all status types for sessions + * @returns {[object]} + */ + get sessionStatusOptions() { + return statusTypes.map((status) => ({ + id: status, + name: this.intl.t(`resources.session.status.${status}`), + })); + } + + // =actions + + /** + * Handles input on each keystroke and the search queryParam + * @param {object} event + */ + @action + @debounce(250) + handleSearchInput(event) { + const { value } = event.target; + this.search = value; + this.page = 1; + } + + /** + * Sets the selected items for the given paramKey and sets the page to 1 + * @param {string} paramKey + * @param {[string]} selectedItems + */ + @action + applyFilter(paramKey, selectedItems) { + this[paramKey] = [...selectedItems]; + this.page = 1; + } + + /** + * Refreshes the all data for the current page + */ + @action + refresh() { + this.send('refreshAll'); + } + + /** + * Cancels the specified session and notifies the user of success or error + * @param {SessionModel} session + */ + @action + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.canceled-success') + async cancelSession(session) { + await session.cancelSession(); + } +} diff --git a/ui/admin/app/routes/scopes/scope/sessions.js b/ui/admin/app/routes/scopes/scope/sessions.js index 92e8b692a..de8259604 100644 --- a/ui/admin/app/routes/scopes/scope/sessions.js +++ b/ui/admin/app/routes/scopes/scope/sessions.js @@ -5,137 +5,17 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { action } from '@ember/object'; -import { all, hash } from 'rsvp'; -import runEvery from 'ember-pollster/decorators/route/run-every'; -import { notifySuccess, notifyError } from 'core/decorators/notify'; -import config from '../../../config/environment'; -import { resourceFilter } from 'core/decorators/resource-filter'; -import orderBy from 'lodash/orderBy'; - -const POLL_TIMEOUT_SECONDS = config.sessionPollingTimeoutSeconds; export default class ScopesScopeSessionsRoute extends Route { // =services - @service store; - @service intl; @service session; - @service resourceFilterStore; @service router; - // =attributes - - @resourceFilter({ - allowed: ['active', 'pending', 'canceling', 'terminated'], - defaultValue: ['active', 'pending', 'canceling'], - }) - status; - - @runEvery(POLL_TIMEOUT_SECONDS * 1000) - poller() { - return this.refresh(); - } - - // =methods - /** * If arriving here unauthenticated, redirect to index for further processing. */ beforeModel() { if (!this.session.isAuthenticated) this.router.transitionTo('index'); } - - /** - * Loads all sessions under the current scope and encapsulates them into - * an array of objects containing their associated users and targets. - * @return {Promise{[{session: SessionModel, user: UserModel, target: TargetModel}]}} - */ - async model() { - const { id: scope_id } = this.modelFor('scopes.scope'); - const filters = { status: this.status }; - const options = { - scope_id, - include_terminated: filters.status?.includes('terminated'), - }; - const sessions = await this.resourceFilterStore.queryBy( - 'session', - filters, - options, - ); - - const sessionAggregates = await all( - sessions.map((session) => - hash({ - session, - user: session.user_id - ? this.store.peekRecord('user', session.user_id) || - this.store.findRecord('user', session.user_id) - : null, - target: session.target_id - ? this.store.peekRecord('target', session.target_id) || - this.store.findRecord('target', session.target_id) - : null, - }), - ), - ); - - // Sort sessions by created time descending (newest on top) - let sortedSessionAggregates = orderBy( - sessionAggregates, - 'created_time', - 'desc', - ); - - // Move active sessions to the top... - sortedSessionAggregates = [ - ...sortedSessionAggregates.filter( - (aggregate) => aggregate.session.status === 'active', - ), - ...sortedSessionAggregates.filter( - (aggregate) => aggregate.session.status !== 'active', - ), - ]; - return sortedSessionAggregates; - } - - // =actions - - /** - * Cancels the specified session and notifies user of success or error. - * @param {SessionModel} - */ - @action - @notifyError(({ message }) => message, { catch: true }) - @notifySuccess('notifications.canceled-success') - async cancelSession(session) { - await session.cancelSession(); - } - - /** - * Sets the specified resource filter field to the specified value. - * @param {string} field - * @param value - */ - @action - filterBy(field, value) { - this[field] = value; - } - - /** - * Clears and filter selections. - */ - @action - clearAllFilters() { - this.status = []; - } - - /** - * refreshes session data. - */ - - @action - refreshSessions() { - return super.refresh(...arguments); - } } diff --git a/ui/admin/app/routes/scopes/scope/sessions/index.js b/ui/admin/app/routes/scopes/scope/sessions/index.js index eef98acda..7b516c272 100644 --- a/ui/admin/app/routes/scopes/scope/sessions/index.js +++ b/ui/admin/app/routes/scopes/scope/sessions/index.js @@ -4,5 +4,173 @@ */ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; -export default class ScopesScopeSessionsIndexRoute extends Route {} +export default class ScopesScopeSessionsIndexRoute extends Route { + // =services + + @service store; + + // =attributes + + queryParams = { + search: { + refreshModel: true, + replace: true, + }, + users: { + refreshModel: true, + replace: true, + }, + targets: { + refreshModel: true, + replace: true, + }, + status: { + refreshModel: true, + replace: true, + }, + page: { + refreshModel: true, + }, + pageSize: { + refreshModel: true, + }, + }; + + allSessions; + associatedUsers; + associatedTargets; + + /** + * Loads all sessions under the current scope and encapsulates them into + * an array of objects containing their associated users and targets. + * @return {Promise{[{sessions: [SessionModel], allSessions: [SessionModel], associatedUsers: [UserModel], associatedTargets: [TargetModel], totalItems: number}]}} + */ + async model({ search, users, targets, status, page, pageSize }) { + const { id: scope_id, scope: parentScope } = this.modelFor('scopes.scope'); + const filters = { + scope_id: [{ equals: scope_id }], + status: [], + user_id: [], + target_id: [], + }; + users.forEach((user) => { + filters.user_id.push({ equals: user }); + }); + targets.forEach((target) => { + filters.target_id.push({ equals: target }); + }); + status.forEach((item) => { + filters.status.push({ equals: item }); + }); + + const queryOptions = { + query: { search, filters }, + page, + pageSize, + }; + + const sessions = await this.store.query('session', queryOptions); + const totalItems = sessions.meta?.totalItems; + + // Query all sessions, users, and targets for defining filtering values if entering route for the first time + if (!this.allSessions) { + await this.getAllSessions(scope_id); + } + if (!this.associatedUsers) { + await this.getAssociatedUsers(parentScope.id); + } + if (!this.associatedTargets) { + await this.getAssociatedTargets(scope_id); + } + + return { + sessions, + allSessions: this.allSessions, + associatedUsers: this.associatedUsers, + associatedTargets: this.associatedTargets, + totalItems, + }; + } + + /** + * Get all the sessions but only load them once when entering the route. + * @param scope_id + * @returns {Promise} + */ + async getAllSessions(scope_id) { + const allSessionsQuery = { + query: { filters: { scope_id: [{ equals: scope_id }] } }, + }; + this.allSessions = await this.store.query('session', allSessionsQuery, { + pushToStore: false, + }); + } + + /** + * Get all the users but only load them once when entering the route. + * @returns {Promise} + */ + async getAssociatedUsers(parentScopeId) { + const uniqueSessionUserIds = new Set( + this.allSessions + .filter((session) => session.user_id) + .map((session) => session.user_id), + ); + // Users can belong to the global scope or the org scope + // the project is under so we need to query for both. + const filters = { + id: { values: [] }, + scope_id: [{ equals: 'global' }, { equals: parentScopeId }], + }; + uniqueSessionUserIds.forEach((userId) => { + filters.id.values.push({ equals: userId }); + }); + const associatedUsersQuery = { + query: { filters }, + }; + this.associatedUsers = await this.store.query('user', associatedUsersQuery); + } + + /** + * Get all the targets but only load them once when entering the route. + * @param scope_id + * @returns {Promise} + */ + async getAssociatedTargets(scope_id) { + const uniqueSessionTargetIds = new Set( + this.allSessions + .filter((session) => session.target_id) + .map((session) => session.target_id), + ); + const filters = { id: { values: [] }, scope_id: [{ equals: scope_id }] }; + uniqueSessionTargetIds.forEach((targetId) => { + filters.id.values.push({ equals: targetId }); + }); + const associatedTargetsQuery = { + query: { filters }, + }; + this.associatedTargets = await this.store.query( + 'target', + associatedTargetsQuery, + ); + } + + // =actions + + /** + * refreshes all session route data. + */ + @action + async refreshAll() { + const { id: scope_id } = this.modelFor('scopes.scope'); + + await this.getAllSessions(scope_id); + await this.getAssociatedUsers(); + await this.getAssociatedTargets(scope_id); + + return super.refresh(...arguments); + } +} diff --git a/ui/admin/app/templates/scopes/scope/sessions/index.hbs b/ui/admin/app/templates/scopes/scope/sessions/index.hbs index 336996e99..827a7570d 100644 --- a/ui/admin/app/templates/scopes/scope/sessions/index.hbs +++ b/ui/admin/app/templates/scopes/scope/sessions/index.hbs @@ -17,134 +17,143 @@

{{t 'resources.session.description'}}

- - - - - {{#let - (resource-filter 'scopes.scope.sessions' 'status') - as |filter| - }} - + {{#if @model.allSessions}} +
+ + + + - - - - - - - {{/let}} + {{#each itemOptions as |itemOption|}} + + {{itemOption.name}} + + {{/each}} + + + + + + + + + + + + +
- {{#if - (has-resource-filter-selections 'scopes.scope.sessions' 'status') + + + {{#if @model.sessions}} + {{t 'actions.clear-all-filters'}} - - {{/if}} -
-
- - - - - -
- - {{#if @model}} - - - <:body as |B|> - - - - - - - {{B.data.user.id}} - {{#if B.data.user.accountName}} -
- - {{/if}} -
- {{B.data.target.displayName}} - - - - - - - - {{#if (can 'cancel session' B.data.session)}} - - {{/if}} - -
- -
- - {{else if - (has-resource-filter-selections 'scopes.scope.sessions' 'status') - }} - - - - {{t 'descriptions.no-results'}} - - - - {{else}} - - - - {{t 'resources.session.messages.none.description'}} - - - + <:body as |B|> + + + + + + {{B.data.user.displayName}} + + + {{B.data.target.displayName}} + + + + + + + + + {{#if (can 'cancel session' B.data)}} + + {{/if}} + + + + + + {{else}} + + + + + {{/if}} + {{else}} + + + + {{/if}}
+ \ No newline at end of file diff --git a/ui/admin/tests/acceptance/sessions-test.js b/ui/admin/tests/acceptance/sessions/list-test.js similarity index 54% rename from ui/admin/tests/acceptance/sessions-test.js rename to ui/admin/tests/acceptance/sessions/list-test.js index 4f2e8a631..cf6d46cd2 100644 --- a/ui/admin/tests/acceptance/sessions-test.js +++ b/ui/admin/tests/acceptance/sessions/list-test.js @@ -4,9 +4,17 @@ */ import { module, test } from 'qunit'; -import { visit, currentURL, click } from '@ember/test-helpers'; +import { + visit, + currentURL, + click, + fillIn, + waitUntil, + findAll, +} from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; import { Response } from 'miragejs'; import { @@ -15,10 +23,21 @@ import { //currentSession, //invalidateSession, } from 'ember-simple-auth/test-support'; +import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target'; -module('Acceptance | sessions', function (hooks) { +module('Acceptance | sessions | list', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); + + const SEARCH_INPUT_SELECTOR = '.search-filtering [type="search"]'; + const NO_RESULTS_MSG_SELECTOR = '[data-test-no-session-results]'; + const SESSION_ID_SELECTOR = (id) => `[data-test-session="${id}"]`; + const FILTER_TOGGLE_SELECTOR = (name) => + `[data-test-sessions-bar] div[name="${name}"] button`; + const FILTER_APPLY_BUTTON = (name) => + `[data-test-sessions-bar] div[name="${name}"] div div:last-child button[type="button"]`; + const instances = { scopes: { global: null, @@ -26,6 +45,10 @@ module('Acceptance | sessions', function (hooks) { project: null, }, sessions: null, + tcpTarget: null, + sshTarget: null, + admin: null, + dev: null, }; const urls = { orgScope: null, @@ -35,6 +58,14 @@ module('Acceptance | sessions', function (hooks) { hooks.beforeEach(function () { instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.admin = this.server.create('user', { + scopeId: 'global', + name: 'admin', + }); + instances.dev = this.server.create('user', { + scopeId: 'global', + name: 'dev', + }); instances.scopes.org = this.server.create('scope', { type: 'org', scope: { id: 'global', type: 'global' }, @@ -49,10 +80,14 @@ module('Acceptance | sessions', function (hooks) { { scope: instances.scopes.org }, 'withMembers', ); - this.server.createList( + instances.tcpTarget = this.server.create( 'target', - 1, - { scope: instances.scopes.project }, + { scope: instances.scopes.project, type: TYPE_TARGET_TCP }, + 'withAssociations', + ); + instances.sshTarget = this.server.create( + 'target', + { scope: instances.scopes.project, type: TYPE_TARGET_SSH }, 'withAssociations', ); instances.sessions = this.server.createList( @@ -156,4 +191,67 @@ module('Acceptance | sessions', function (hooks) { .dom(`[href="https://boundaryproject.io/help/admin-ui/sessions"]`) .exists(); }); + + test('user can search for a specific session by id', async function (assert) { + await visit(urls.projectScope); + const sessionId = instances.sessions[0].id; + + await click(`[href="${urls.sessions}"]`); + await fillIn(SEARCH_INPUT_SELECTOR, sessionId); + await waitUntil(() => findAll(SESSION_ID_SELECTOR(sessionId)).length === 1); + + assert.dom(SESSION_ID_SELECTOR(sessionId)).hasText(sessionId); + }); + + test('user can search for sessions and get no results', async function (assert) { + await visit(urls.projectScope); + const sessionId = 'fake session that does not exist'; + + await click(`[href="${urls.sessions}"]`); + await fillIn(SEARCH_INPUT_SELECTOR, sessionId); + await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1); + + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); + + test('user can filter for sessions by user', async function (assert) { + await visit(urls.projectScope); + instances.sessions[2].update({ + userId: instances.dev.id, + }); + + await click(`[href="${urls.sessions}"]`); + await click(FILTER_TOGGLE_SELECTOR('User')); + await click(`input[value="${instances.dev.id}"]`); + await click(FILTER_APPLY_BUTTON('User')); + + assert.dom(SESSION_ID_SELECTOR(instances.sessions[2].id)).exists(); + assert.dom('tbody tr').exists({ count: 1 }); + }); + + test('user can filter for sessions by target', async function (assert) { + await visit(urls.projectScope); + instances.sessions[2].update({ + targetId: instances.sshTarget.id, + }); + + await click(`[href="${urls.sessions}"]`); + await click(FILTER_TOGGLE_SELECTOR('Target')); + await click(`input[value="${instances.sshTarget.id}"]`); + await click(FILTER_APPLY_BUTTON('Target')); + + assert.dom(SESSION_ID_SELECTOR(instances.sessions[2].id)).exists(); + assert.dom('tbody tr').exists({ count: 1 }); + }); + + test('user can filter for sessions by status', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.sessions}"]`); + await click(FILTER_TOGGLE_SELECTOR('Status')); + await click('input[value="active"]'); + await click(FILTER_APPLY_BUTTON('Status')); + + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); }); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/sessions/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/sessions/index-test.js new file mode 100644 index 000000000..65dfbf06c --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/sessions/index-test.js @@ -0,0 +1,127 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; +import { waitUntil } from '@ember/test-helpers'; + +module('Unit | Controller | scopes/scope/sessions/index', function (hooks) { + setupTest(hooks); + + const session = { + id: 's_123', + status: 'active', + created_time: '2024-03-12T20:44:14.808699Z', + }; + const user = { id: 'u_123', name: 'admin' }; + const target = { id: 't_123', name: 'target' }; + const model = { + sessions: [session], + associatedUsers: [user], + associatedTargets: [target], + allSessions: [session], + totalItems: 1, + }; + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/sessions/index', + ); + assert.ok(controller); + }); + + test('filters returns expected entries', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/sessions/index', + ); + controller.set('model', model); + assert.deepEqual(controller.filters.allFilters, { + status: [ + { + id: 'active', + name: 'Active', + }, + { + id: 'pending', + name: 'Pending', + }, + { + id: 'canceling', + name: 'Canceling', + }, + { + id: 'terminated', + name: 'Terminated', + }, + ], + targets: [ + { + id: 't_123', + name: 'target', + }, + ], + users: [ + { + id: 'u_123', + name: 'admin', + }, + ], + }); + assert.deepEqual(controller.filters.selectedFilters, { + users: [], + targets: [], + status: ['active', 'pending', 'canceling'], + }); + }); + + test('sessionStatusOptions returns expected object', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/sessions/index', + ); + assert.deepEqual(controller.sessionStatusOptions, [ + { id: 'active', name: 'Active' }, + { id: 'pending', name: 'Pending' }, + { id: 'canceling', name: 'Canceling' }, + { id: 'terminated', name: 'Terminated' }, + ]); + }); + + test('handleSearchInput action sets expected values correctly', async function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/sessions/index', + ); + const searchValue = 'test'; + controller.handleSearchInput({ target: { value: searchValue } }); + await waitUntil(() => controller.search === searchValue); + + assert.strictEqual(controller.page, 1); + assert.strictEqual(controller.search, searchValue); + }); + + test('applyFilter action sets expected values correctly', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/sessions/index', + ); + const selectedItems = ['admin']; + controller.applyFilter('users', selectedItems); + + assert.strictEqual(controller.page, 1); + assert.deepEqual(controller.users, selectedItems); + }); + + test('refresh action calls refreshAll', async function (assert) { + assert.expect(2); + let controller = this.owner.lookup( + 'controller:scopes/scope/sessions/index', + ); + controller.set('target', { + send(actionName, ...args) { + assert.strictEqual(actionName, 'refreshAll'); + assert.deepEqual( + args, + [], + 'refreshAll was called with the correct arguments', + ); + }, + }); + + await controller.refresh(); + }); +});