From 340429fdb538c519559898985a2aa92ba9584c74 Mon Sep 17 00:00:00 2001 From: Cameron Perera Date: Thu, 10 Aug 2023 15:38:10 -0500 Subject: [PATCH] Icu 10521 add route logic from sessions list to session detail screen (#1834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add canRead ability for session model * refactor: 💡 create constant values for session status types * feat: 🎸 add link to session detail from sessions list ✅ Closes: ICU-10521 --- addons/api/addon/abilities/session.js | 23 ++++ addons/api/addon/models/session.js | 15 ++- addons/api/app/abilities/session.js | 6 + .../api/tests/unit/abilities/session-test.js | 100 ++++++++++++++++ ui/desktop/app/styles/app.scss | 4 - .../scopes/scope/projects/sessions/index.hbs | 47 ++++---- .../acceptance/projects/sessions-test.js | 111 ++++++++++++++---- 7 files changed, 254 insertions(+), 52 deletions(-) create mode 100644 addons/api/addon/abilities/session.js create mode 100644 addons/api/app/abilities/session.js create mode 100644 addons/api/tests/unit/abilities/session-test.js diff --git a/addons/api/addon/abilities/session.js b/addons/api/addon/abilities/session.js new file mode 100644 index 000000000..dbe7d26a1 --- /dev/null +++ b/addons/api/addon/abilities/session.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ModelAbility from './model'; + +/** + * Provides abilities for sessions. + */ +export default class SessionAbility extends ModelAbility { + // =services + + // =permissions + + /** + * Only "active" or "pending" sessions may be read. + * @type {boolean} + */ + get canRead() { + return (this.model.isActive || this.model.isPending) && super.canRead; + } +} diff --git a/addons/api/addon/models/session.js b/addons/api/addon/models/session.js index 733181fae..696da71fa 100644 --- a/addons/api/addon/models/session.js +++ b/addons/api/addon/models/session.js @@ -10,7 +10,16 @@ import { tracked } from '@glimmer/tracking'; import { A } from '@ember/array'; import { inject as service } from '@ember/service'; -export const statusTypes = ['active', 'pending', 'canceling', 'terminated']; +export const STATUS_SESSION_ACTIVE = 'active'; +export const STATUS_SESSION_PENDING = 'pending'; +export const STATUS_SESSION_CANCELING = 'canceling'; +export const STATUS_SESSION_TERMINATED = 'terminated'; +export const statusTypes = [ + STATUS_SESSION_ACTIVE, + STATUS_SESSION_PENDING, + STATUS_SESSION_CANCELING, + STATUS_SESSION_TERMINATED, +]; /** * @@ -103,8 +112,8 @@ export default class SessionModel extends GeneratedSessionModel { @tracked proxy_port; credentials = A(); - @equal('status', 'active') isActive; - @equal('status', 'pending') isPending; + @equal('status', STATUS_SESSION_ACTIVE) isActive; + @equal('status', STATUS_SESSION_PENDING) isPending; /** * True if status is an unknown string. diff --git a/addons/api/app/abilities/session.js b/addons/api/app/abilities/session.js new file mode 100644 index 000000000..f9cda9954 --- /dev/null +++ b/addons/api/app/abilities/session.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default } from 'api/abilities/session'; diff --git a/addons/api/tests/unit/abilities/session-test.js b/addons/api/tests/unit/abilities/session-test.js new file mode 100644 index 000000000..f983491e6 --- /dev/null +++ b/addons/api/tests/unit/abilities/session-test.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { + STATUS_SESSION_ACTIVE, + STATUS_SESSION_PENDING, + STATUS_SESSION_CANCELING, + STATUS_SESSION_TERMINATED, +} from 'api/models/session'; + +module('Unit | Ability | session', function (hooks) { + setupTest(hooks); + + let canService; + let store; + + hooks.beforeEach(function () { + canService = this.owner.lookup('service:can'); + store = this.owner.lookup('service:store'); + }); + + test('it exists', function (assert) { + const ability = this.owner.lookup('ability:session'); + assert.ok(ability); + }); + + test('can read a session that is active', function (assert) { + assert.expect(1); + + const session = store.createRecord('session', { + authorized_actions: ['read'], + status: STATUS_SESSION_ACTIVE, + }); + + assert.true(canService.can('read session', session)); + }); + + test('can read a session that is pending', function (assert) { + assert.expect(1); + + const session = store.createRecord('session', { + authorized_actions: ['read'], + status: STATUS_SESSION_PENDING, + }); + + assert.true(canService.can('read session', session)); + }); + + test('cannot read a session that is not authorized', function (assert) { + assert.expect(4); + + const activeSession = store.createRecord('session', { + authorized_actions: [], + status: STATUS_SESSION_ACTIVE, + }); + const pendingSession = store.createRecord('session', { + authorized_actions: [], + status: STATUS_SESSION_PENDING, + }); + const cancelingSession = store.createRecord('session', { + authorized_actions: [], + status: STATUS_SESSION_CANCELING, + }); + const terminatedSession = store.createRecord('session', { + authorized_actions: [], + status: STATUS_SESSION_TERMINATED, + }); + + assert.false(canService.can('read session', activeSession)); + assert.false(canService.can('read session', pendingSession)); + assert.false(canService.can('read session', cancelingSession)); + assert.false(canService.can('read session', terminatedSession)); + }); + + test('cannot read a session that is canceling', function (assert) { + assert.expect(1); + + const session = store.createRecord('session', { + authorized_actions: ['read'], + status: STATUS_SESSION_CANCELING, + }); + + assert.false(canService.can('read session', session)); + }); + + test('cannot read a session that is terminated', function (assert) { + assert.expect(1); + + const session = store.createRecord('session', { + authorized_actions: ['read'], + status: STATUS_SESSION_TERMINATED, + }); + + assert.false(canService.can('read session', session)); + }); +}); diff --git a/ui/desktop/app/styles/app.scss b/ui/desktop/app/styles/app.scss index 409f4682f..e849ee1e9 100644 --- a/ui/desktop/app/styles/app.scss +++ b/ui/desktop/app/styles/app.scss @@ -233,10 +233,6 @@ color: var(--ui-gray-subtler-2); } -.session-status-active { - color: var(--success); -} - .nowrap { white-space: nowrap; } diff --git a/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs index 59a2daf15..13795b56d 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs @@ -130,31 +130,38 @@ <:body as |B|> - {{#if (can 'read model' B.data)}} - {{#if B.data.isActive}} - - {{else}} - - {{/if}} + {{#if (can 'read session' B.data)}} + - {{B.data.id}} + + {{B.data.id}} + + + {{else}} + + + {{B.data.id}} {{/if}} diff --git a/ui/desktop/tests/acceptance/projects/sessions-test.js b/ui/desktop/tests/acceptance/projects/sessions-test.js index 9205c98fe..e08b783f1 100644 --- a/ui/desktop/tests/acceptance/projects/sessions-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions-test.js @@ -4,7 +4,7 @@ */ import { module, test } from 'qunit'; -import { visit, currentURL, click, find, findAll } from '@ember/test-helpers'; +import { visit, currentURL, click } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import { Response } from 'miragejs'; @@ -15,6 +15,12 @@ import { invalidateSession, } from 'ember-simple-auth/test-support'; import sinon from 'sinon'; +import { + STATUS_SESSION_ACTIVE, + STATUS_SESSION_PENDING, + STATUS_SESSION_CANCELING, + STATUS_SESSION_TERMINATED, +} from 'api/models/session'; module('Acceptance | projects | sessions', function (hooks) { setupApplicationTest(hooks); @@ -57,6 +63,7 @@ module('Acceptance | projects | sessions', function (hooks) { }, projects: null, sessions: null, + session: null, }; const setDefaultClusterUrl = (test) => { @@ -99,7 +106,7 @@ module('Acceptance | projects | sessions', function (hooks) { { scope: instances.scopes.project, target: instances.target, - status: 'active', + status: STATUS_SESSION_ACTIVE, user: instances.user, }, 'withAssociations' @@ -111,6 +118,7 @@ module('Acceptance | projects | sessions', function (hooks) { urls.authenticate.methods.global = `${urls.authenticate.global}/${instances.authMethods.global.id}`; urls.projects = `${urls.scopes.org}/projects`; urls.sessions = `${urls.projects}/sessions`; + urls.session = `${urls.projects}/sessions/${instances.session.id}`; class MockIPC { clusterUrl = null; @@ -153,8 +161,10 @@ module('Acceptance | projects | sessions', function (hooks) { test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) { invalidateSession(); assert.expect(2); + await visit(urls.sessions); await a11yAudit(); + assert.notOk(currentSession().isAuthenticated); assert.strictEqual(currentURL(), urls.authenticate.methods.global); }); @@ -162,79 +172,130 @@ module('Acceptance | projects | sessions', function (hooks) { test('visiting index', async function (assert) { assert.expect(2); const sessionsCount = this.server.schema.sessions.all().models.length; + await visit(urls.sessions); + assert.strictEqual(currentURL(), urls.sessions); - assert.strictEqual(findAll('tbody tr').length, sessionsCount); + assert.dom('tbody tr').exists({ count: sessionsCount }); }); test('visiting empty sessions', async function (assert) { assert.expect(1); this.server.get('/sessions', () => new Response(200)); + await visit(urls.sessions); + await click('button.rose-button-inline-link-action'); // clear all filters await a11yAudit(); - assert.ok( - find('.rose-message-title').textContent.trim(), - 'No Sessions Available' - ); + + assert.dom('.rose-message-title').hasText('No Sessions Available'); }); test('visiting sessions without targets is OK', async function (assert) { assert.expect(2); instances.session.update({ targetId: undefined }); const sessionsCount = this.server.schema.sessions.all().models.length; + await visit(urls.sessions); await a11yAudit(); + assert.strictEqual(currentURL(), urls.sessions); - assert.strictEqual(findAll('tbody tr').length, sessionsCount); + assert.dom('tbody tr').exists({ count: sessionsCount }); }); - test('can identify active sessions', async function (assert) { + test('visiting a session', async function (assert) { assert.expect(1); + await visit(urls.sessions); - assert.ok( - find('tbody tr:first-child td:first-child .session-status-active') - ); + await click('[data-test-session-detail-link]'); + + assert.strictEqual(currentURL(), urls.session); }); - test('cannot identify pending sessions', async function (assert) { + test('can link to an active session', async function (assert) { assert.expect(1); - instances.session.update({ status: 'pending' }); + await visit(urls.sessions); - assert.notOk( - find('tbody tr:first-child th:first-child .session-status-active') - ); + + assert + .dom( + 'tbody tr:first-child td:first-child [data-test-session-detail-link]' + ) + .isVisible(); }); - test('cannot identify terminated sessions', async function (assert) { + test('can link to a pending session', async function (assert) { assert.expect(1); - instances.session.update({ status: 'terminated' }); + instances.session.update({ status: STATUS_SESSION_PENDING }); + await visit(urls.sessions); - assert.notOk( - find('tbody tr:first-child th:first-child .session-status-active') - ); + + assert + .dom( + 'tbody tr:first-child td:first-child [data-test-session-detail-link]' + ) + .isVisible(); + }); + + test('cannot link to a canceling session', async function (assert) { + assert.expect(2); + instances.session.update({ status: STATUS_SESSION_CANCELING }); + + await visit(urls.sessions); + + assert + .dom( + 'tbody tr:first-child td:first-child [data-test-session-detail-link]' + ) + .doesNotExist(); + assert + .dom('tbody tr:first-child td:first-child [data-test-session-id-copy]') + .isVisible(); + }); + + test('cannot link to a terminated session', async function (assert) { + assert.expect(2); + instances.session.update({ status: STATUS_SESSION_TERMINATED }); + + await visit(urls.sessions); + await click('button.rose-button-inline-link-action'); // clear all filters + + assert + .dom( + 'tbody tr:first-child td:first-child [data-test-session-detail-link]' + ) + .doesNotExist(); + assert + .dom('tbody tr:first-child td:first-child [data-test-session-id-copy]') + .isVisible(); }); test('cancelling a session', async function (assert) { assert.expect(1); stubs.ipcService.withArgs('stop'); + await visit(urls.sessions); await click('tbody tr:first-child td:last-child button'); - assert.ok(find('[role="alert"].is-success')); + + assert.dom('[role="alert"].is-success').isVisible(); }); test('cancelling a session with error shows notification', async function (assert) { assert.expect(1); this.server.post('/sessions/:id_method', () => new Response(400)); + await visit(urls.sessions); await click('tbody tr:first-child td:last-child button'); - assert.ok(find('[role="alert"].is-error')); + + assert.dom('[role="alert"].is-error').isVisible(); }); test('cancelling a session with ipc error shows notification', async function (assert) { assert.expect(1); stubs.ipcService.withArgs('stop').throws(); + await visit(urls.sessions); await click('tbody tr:first-child td:last-child button'); - assert.ok(find('[role="alert"].is-error')); + + assert.dom('[role="alert"].is-error').isVisible(); }); });