From 46d477ea5bf8cdc27516ad2c248021d9ae262526 Mon Sep 17 00:00:00 2001 From: hashicc <191911133+hashicc@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:53:17 -0700 Subject: [PATCH] Refresh target active sessions for admin and desktop (#3001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 🐛 refresh sessions for targets list on admin and desktop Refresh the sessions for the targets list pages on admin and desktop so that the active sessions column is up to date ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646 * test: 💍 add test for targets list refreshing sessions on admin ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646 * refactor: 💡 allow ipcStub to return current mirage data ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646 * test: 💍 ensure that desktop targets sessions are refreshed ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646 * test: 💍 update existing tests affected by sessions refresh ipc ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646 * refactor: 💡 remove force_refresh from sessions sync ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646 --- .../handlers/cache-daemon-search.js | 45 +++++----- .../app/routes/scopes/scope/targets/index.js | 16 ++++ .../tests/acceptance/targets/list-test.js | 61 ++++++++++++- .../tests/acceptance/targets/selectors.js | 4 + .../scopes/scope/projects/targets/index.js | 23 +++-- .../projects/sessions/index-test.js | 1 + .../acceptance/projects/targets/index-test.js | 87 +++++++++++++++++-- .../projects/targets/target-test.js | 2 +- ui/desktop/tests/acceptance/scopes-test.js | 15 +++- .../scope/projects/targets/index-test.js | 8 +- 10 files changed, 217 insertions(+), 45 deletions(-) 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;