diff --git a/addons/api/addon-test-support/handlers/cache-daemon-search.js b/addons/api/addon-test-support/handlers/cache-daemon-search.js index 36cf242bc..9dac554e0 100644 --- a/addons/api/addon-test-support/handlers/cache-daemon-search.js +++ b/addons/api/addon-test-support/handlers/cache-daemon-search.js @@ -56,32 +56,33 @@ export default function setupStubs(hooks) { stubTypes = types; types.forEach((type, i) => { - let models; - let resourceName; - if (typeOf(type) === 'object') { - models = type.func(); - resourceName = type.resource; - } else { - models = this.server.schema[type].all().models; - resourceName = type; - } - this.ipcStub .withArgs('searchCacheDaemon') .onCall(i) - .returns({ - [underscore(resourceNames[singularize(resourceName)])]: models.map( - (model) => { - // Use internal serializer to serialize the model correctly - // according to our mirage serializers - const modelData = - this.server.serializerOrRegistry.serialize(model); + .callsFake(() => { + let models; + let resourceName; + if (typeOf(type) === 'object') { + models = type.func(); + resourceName = type.resource; + } else { + models = this.server.schema[type].all().models; + resourceName = type; + } - // Serialize the data properly to standard JSON as that is what - // we're expecting from the cache daemon response - return JSON.parse(JSON.stringify(modelData)); - }, - ), + return { + [underscore(resourceNames[singularize(resourceName)])]: + models.map((model) => { + // Use internal serializer to serialize the model correctly + // according to our mirage serializers + const modelData = + this.server.serializerOrRegistry.serialize(model); + + // Serialize the data properly to standard JSON as that is what + // we're expecting from the cache daemon response + return JSON.parse(JSON.stringify(modelData)); + }), + }; }); }); }; diff --git a/ui/admin/app/routes/scopes/scope/targets/index.js b/ui/admin/app/routes/scopes/scope/targets/index.js index 2bff28bda..b45faa513 100644 --- a/ui/admin/app/routes/scopes/scope/targets/index.js +++ b/ui/admin/app/routes/scopes/scope/targets/index.js @@ -133,6 +133,22 @@ export default class ScopesScopeTargetsIndexRoute extends Route { }); totalItems = targets.meta?.totalItems; doTargetsExist = await this.getDoTargetsExist(scope_id, totalItems); + + // To correctly show targets with active sessions, the associated + // sessions need to be queried to sync all the session models in + // ember data and retrieve their updated `status` properties + await this.store.query( + 'session', + { + query: { + filters: { + scope_id: [{ equals: scope_id }], + target_id: targets.map((target) => ({ equals: target.id })), + }, + }, + }, + { pushToStore: true, peekDb: true }, + ); } return { targets, doTargetsExist, totalItems }; }, diff --git a/ui/admin/tests/acceptance/targets/list-test.js b/ui/admin/tests/acceptance/targets/list-test.js index 57773bbad..a045e798c 100644 --- a/ui/admin/tests/acceptance/targets/list-test.js +++ b/ui/admin/tests/acceptance/targets/list-test.js @@ -21,7 +21,10 @@ import { TYPE_TARGET_SSH, TYPE_TARGET_RDP, } from 'api/models/target'; -import { STATUS_SESSION_ACTIVE } from 'api/models/session'; +import { + STATUS_SESSION_ACTIVE, + STATUS_SESSION_TERMINATED, +} from 'api/models/session'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import * as selectors from './selectors'; import { faker } from '@faker-js/faker'; @@ -59,6 +62,7 @@ module('Acceptance | targets | list', function (hooks) { orgScope: null, projectScope: null, targets: null, + sessions: null, tcpTarget: null, sshTarget: null, rdpTarget: null, @@ -95,6 +99,7 @@ module('Acceptance | targets | list', function (hooks) { urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; urls.projectScope = `/scopes/${instances.scopes.project.id}`; urls.targets = `${urls.projectScope}/targets`; + urls.sessions = `${urls.projectScope}/sessions`; urls.tcpTarget = `${urls.targets}/${instances.tcpTarget.id}`; urls.sshTarget = `${urls.targets}/${instances.sshTarget.id}`; urls.rdpTarget = `${urls.targets}/${instances.rdpTarget.id}`; @@ -350,6 +355,60 @@ module('Acceptance | targets | list', function (hooks) { .doesNotExist(); }); + test('active sessions are refreshed when visiting the targets list page', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + enabled: false, + }, + }, + }); + + await visit(urls.targets); + + assert.strictEqual( + instances.session.targetId, + instances.sshTarget.id, + 'session is associated with correct target', + ); + const emberDataSessionModel = this.owner + .lookup('service:store') + .peekRecord('session', instances.session.id); + assert.strictEqual( + emberDataSessionModel.status, + STATUS_SESSION_ACTIVE, + 'ember data session model is active', + ); + + assert + .dom(selectors.TABLE_TARGETS_ROW(instances.sshTarget.id)) + .exists('the target is listed in the table'); + assert + .dom(selectors.TABLE_ACTIVE_SESSIONS(instances.sshTarget.id)) + .includesText('Yes', 'target shows the active session in the table'); + + await click(commonSelectors.HREF(urls.sessions)); + assert + .dom(selectors.TABLE_SESSIONS_STATUS(instances.session.id)) + .includesText('Active'); + + // simulate that the session was cancelled and reached a terminated status externally + instances.session.status = STATUS_SESSION_TERMINATED; + + await click(commonSelectors.HREF(urls.targets)); + assert + .dom(selectors.TABLE_TARGETS_ROW(instances.sshTarget.id)) + .exists('the target is still listed in the table'); + assert + .dom(selectors.TABLE_ACTIVE_SESSIONS(instances.sshTarget.id)) + .doesNotExist('the target does not have an active session'); + assert.strictEqual( + emberDataSessionModel.status, + STATUS_SESSION_TERMINATED, + 'the session ember data model status is updated', + ); + }); + test('user can navigate to active sessions from targets table', async function (assert) { setRunOptions({ rules: { diff --git a/ui/admin/tests/acceptance/targets/selectors.js b/ui/admin/tests/acceptance/targets/selectors.js index c3cdb4c47..4a10fba66 100644 --- a/ui/admin/tests/acceptance/targets/selectors.js +++ b/ui/admin/tests/acceptance/targets/selectors.js @@ -62,8 +62,12 @@ export const STORAGE_BUCKET_DROPDOWN = '[name=storage_bucket_id]'; export const NO_RESULTS_MSG = '[data-test-no-target-results]'; export const TABLE_ACTIVE_SESSIONS = (id) => `tbody [data-test-targets-table-row="${id}"] .hds-table__td:nth-child(3) a`; +export const TABLE_TARGETS_ROW = (id) => + `tbody [data-test-targets-table-row="${id}"]`; export const TABLE_SESSIONS_ID = (id) => `tbody [data-test-sessions-table-row="${id}"] .hds-table__td:first-child`; +export const TABLE_SESSIONS_STATUS = (id) => + `tbody [data-test-sessions-table-row="${id}"] .hds-table__td:nth-child(5)`; export const TABLE_CREDENTIAL_SOURCE_CHECKBOX = (type) => `tbody [data-test-credential-source="${type}"] input[type="checkbox"]`; diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/index.js b/ui/desktop/app/routes/scopes/scope/projects/targets/index.js index d6aff8ca8..33b096534 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets/index.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets/index.js @@ -156,12 +156,27 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route { }, }); - // Load the aliases for the targets on the current page + // To correctly show targets with active sessions, the associated + // sessions need to be queried to sync all the session models in + // ember data and retrieve their updated `status` properties + const sessionsPromise = this.store.query( + 'session', + { + query: { + filters: { + target_id: targets.map((target) => ({ equals: target.id })), + }, + }, + }, + { pushToStore: true }, + ); + + // Load the sessions and aliases for the targets on the current page try { - await aliasPromise; + await Promise.all([aliasPromise, sessionsPromise]); } catch (e) { __electronLog?.warn( - 'Could not retrieve aliases for targets', + 'Could not retrieve aliases and/or sessions for targets', e.message, ); // Separately await and catch the error here so we can continue loading @@ -180,8 +195,6 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route { ); async getSessions(orgScope, scopes, orgFilter) { - // Retrieve all sessions so that the session and activeSessions getters - // in the target model always retrieve the most up-to-date sessions. const sessionQuery = { recursive: true, scope_id: orgScope.id, diff --git a/ui/desktop/tests/acceptance/projects/sessions/index-test.js b/ui/desktop/tests/acceptance/projects/sessions/index-test.js index a5cc0042d..fb00da4cf 100644 --- a/ui/desktop/tests/acceptance/projects/sessions/index-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions/index-test.js @@ -536,6 +536,7 @@ module('Acceptance | projects | sessions | index', function (hooks) { 'sessions', 'targets', 'aliases', + 'sessions', { resource: 'sessions', func: () => [instances.session2], diff --git a/ui/desktop/tests/acceptance/projects/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js index 5cab4c301..519c5937a 100644 --- a/ui/desktop/tests/acceptance/projects/targets/index-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js @@ -20,6 +20,10 @@ import { } from 'ember-simple-auth/test-support'; import setupStubs from 'api/test-support/handlers/cache-daemon-search'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { + STATUS_SESSION_ACTIVE, + STATUS_SESSION_TERMINATED, +} from 'api/models/session'; module('Acceptance | projects | targets | index', function (hooks) { setupApplicationTest(hooks); @@ -74,6 +78,7 @@ module('Acceptance | projects | targets | index', function (hooks) { targets: null, target: null, session: null, + sessions: null, }; const setDefaultClusterUrl = (test) => { @@ -150,6 +155,7 @@ module('Acceptance | projects | targets | index', function (hooks) { urls.authenticate.methods.global = `${urls.authenticate.global}/${instances.authMethods.global.id}`; urls.projects = `${urls.scopes.org}/projects`; urls.targets = `${urls.projects}/targets`; + urls.sessions = `${urls.projects}/sessions`; urls.target = `${urls.targets}/${instances.target.id}`; urls.session = `${urls.projects}/sessions/${instances.session.id}`; @@ -161,7 +167,7 @@ module('Acceptance | projects | targets | index', function (hooks) { setDefaultClusterUrl(this); this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); }); test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) { @@ -217,7 +223,7 @@ module('Acceptance | projects | targets | index', function (hooks) { test('visiting targets list view with no targets', async function (assert) { this.server.schema.targets.all().destroy(); this.server.schema.sessions.all().destroy(); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.projects); @@ -238,7 +244,7 @@ module('Acceptance | projects | targets | index', function (hooks) { instances.target.authorized_actions = instances.target.authorized_actions.filter((item) => item !== 'read'); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.projects); @@ -285,7 +291,7 @@ module('Acceptance | projects | targets | index', function (hooks) { instances.target.authorized_actions.filter( (item) => item !== 'authorize-session', ); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.projects); @@ -335,7 +341,7 @@ module('Acceptance | projects | targets | index', function (hooks) { instances.target.authorized_actions = instances.target.authorized_actions.filter((item) => item !== 'read'); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); this.ipcStub.withArgs('cliExists').returns(true); this.ipcStub.withArgs('connect').returns({ session_id: instances.session.id, @@ -370,7 +376,7 @@ module('Acceptance | projects | targets | index', function (hooks) { instances.target.authorized_actions.filter((item) => item !== 'read'); this.ipcStub.withArgs('cliExists').returns(true); this.ipcStub.withArgs('connect').rejects(); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); const confirmService = this.owner.lookup('service:confirm'); confirmService.enabled = true; await visit(urls.projects); @@ -432,12 +438,17 @@ module('Acceptance | projects | targets | index', function (hooks) { 'sessions', 'targets', 'aliases', + 'sessions', { resource: 'sessions', func: () => [], }, 'targets', 'aliases', + { + resource: 'sessions', + func: () => [], + }, ); await visit(urls.projects); @@ -476,7 +487,7 @@ module('Acceptance | projects | targets | index', function (hooks) { instances.session.authorized_actions = instances.session.authorized_actions.filter((item) => item !== 'cancel'); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.projects); await click(`[href="${urls.targets}"]`); @@ -532,7 +543,7 @@ module('Acceptance | projects | targets | index', function (hooks) { instances.session.authorized_actions = instances.session.authorized_actions.filter((item) => item !== 'read'); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.projects); await click(`[href="${urls.targets}"]`); @@ -567,11 +578,13 @@ module('Acceptance | projects | targets | index', function (hooks) { 'targets', 'aliases', 'sessions', + 'sessions', { resource: 'targets', func: () => [instances.target], }, 'aliases', + 'sessions', ); await visit(urls.scopes.global); @@ -638,4 +651,62 @@ module('Acceptance | projects | targets | index', function (hooks) { .dom(`[data-test-target-aliases="${instances.target2.id}"]`) .hasNoText(); }); + + test('active sessions are refreshed when visiting the targets list page', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + enabled: false, + }, + }, + }); + + this.stubCacheDaemonSearch( + 'sessions', + 'targets', + 'aliases', + 'sessions', + + 'sessions', + 'sessions', + 'targets', + + 'sessions', + 'targets', + 'aliases', + 'sessions', + ); + + const activeSessionFlyoutButtonSelector = (id) => + `[data-test-targets-sessions-flyout-button="${id}"]`; + + assert.strictEqual(instances.session.status, STATUS_SESSION_ACTIVE); + await visit(urls.targets); + const emberDataSessionModel = this.owner + .lookup('service:store') + .peekRecord('session', instances.session.id); + + assert.strictEqual(emberDataSessionModel.status, STATUS_SESSION_ACTIVE); + + assert + .dom(activeSessionFlyoutButtonSelector(instances.session.targetId)) + .exists(); + + await click(`[href="${urls.sessions}"]`); + + assert + .dom('.hds-table tbody tr:first-child') + .includesText(instances.session.id); + assert.dom('.hds-table tbody tr:first-child').includesText('Active'); + + // simulate the session has been cancelled externally + instances.session.status = STATUS_SESSION_TERMINATED; + + await click(`[href="${urls.targets}"]`); + + assert.strictEqual(emberDataSessionModel.status, STATUS_SESSION_TERMINATED); + assert + .dom(activeSessionFlyoutButtonSelector(instances.session.targetId)) + .doesNotExist(); + }); }); diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js index 419e299f5..2d37f3233 100644 --- a/ui/desktop/tests/acceptance/projects/targets/target-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js @@ -156,7 +156,7 @@ module('Acceptance | projects | targets | target', function (hooks) { setDefaultClusterUrl(this); this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); }); test('user can connect to a target with an address', async function (assert) { diff --git a/ui/desktop/tests/acceptance/scopes-test.js b/ui/desktop/tests/acceptance/scopes-test.js index 8e866ddbc..333dab2b7 100644 --- a/ui/desktop/tests/acceptance/scopes-test.js +++ b/ui/desktop/tests/acceptance/scopes-test.js @@ -143,7 +143,7 @@ module('Acceptance | scopes', function (hooks) { setDefaultClusterUrl(this); this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); }); test('visiting index', async function (assert) { @@ -241,14 +241,21 @@ module('Acceptance | scopes', function (hooks) { 'targets', 'aliases', 'sessions', - 'targets', - 'aliases', + 'sessions', 'targets', 'aliases', + 'sessions', + 'sessions', 'targets', 'aliases', + 'sessions', + + 'sessions', + 'targets', + 'aliases', + 'sessions', ); await visit(urls.targets); @@ -299,7 +306,7 @@ module('Acceptance | scopes', function (hooks) { test('visiting empty targets', async function (assert) { this.server.schema.targets.all().destroy(); this.server.schema.sessions.all().destroy(); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.targets); diff --git a/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/index-test.js b/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/index-test.js index 312791648..b7b191d64 100644 --- a/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/index-test.js +++ b/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/index-test.js @@ -76,7 +76,7 @@ module( urls.targets = `${urls.projectScope}/targets`; this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); }); test('it exists', function (assert) { @@ -93,7 +93,7 @@ module( test('noResults returns truthy when no targets exist but there is a search term', async function (assert) { this.server.schema.targets.all().destroy(); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); controller.search = 'target that does not exist'; await visit(urls.targets); @@ -102,7 +102,7 @@ module( test('noTargets returns truthy when no targets exist', async function (assert) { this.server.schema.targets.all().destroy(); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.targets); assert.ok(controller.noTargets); @@ -143,7 +143,7 @@ module( 'target', instances.target.id, ); - this.stubCacheDaemonSearch('sessions', 'targets', 'aliases'); + this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); await visit(urls.targets); const sortedSessions = controller.sortedTargetSessions;