feat: 🎸 Add checks for cancel permissions in desktop sessions (#1980)

* feat: 🎸 Add checks for cancel permissions in desktop sessions

* chore: 🤖 Move model hook from session to session.index
This commit is contained in:
Zhihe Li authored and GitHub committed 2023-11-02 13:42:51 -04:00
1 parent a574023c05
commit 1ddabf4980
13 files changed
+373 -198

No files matched your search

+14 -2
View File
@@ -14,13 +14,25 @@ export default class SessionAbility extends ModelAbility {
// =permissions // =permissions
/** /**
* Only "active" or "pending" sessions may be read. * Only available sessions may be read.
* @type {boolean} * @type {boolean}
*/ */
get canRead() { get canRead() {
return ( return (
(this.model.isActive || this.model.isPending) && this.model.isAvailable &&
(this.hasAuthorizedAction('read:self') || super.canRead) (this.hasAuthorizedAction('read:self') || super.canRead)
); );
} }
/**
* Only available sessions may be canceled.
* @type {boolean}
*/
get canCancel() {
return (
this.model.isAvailable &&
(this.hasAuthorizedAction('cancel:self') ||
this.hasAuthorizedAction('cancel'))
);
}
} }
+3 -3
View File
@@ -4,7 +4,6 @@
*/ */
import GeneratedSessionModel from '../generated/models/session'; import GeneratedSessionModel from '../generated/models/session';
import { computed } from '@ember/object';
import { equal } from '@ember/object/computed'; import { equal } from '@ember/object/computed';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { A } from '@ember/array'; import { A } from '@ember/array';
@@ -127,7 +126,6 @@ export default class SessionModel extends GeneratedSessionModel {
/** /**
* @type {boolean} * @type {boolean}
*/ */
@computed('isActive', 'isPending')
get isAvailable() { get isAvailable() {
return this.isActive || this.isPending; return this.isActive || this.isPending;
} }
@@ -136,7 +134,6 @@ export default class SessionModel extends GeneratedSessionModel {
* The full proxy address and port if address exists, otherwise null. * The full proxy address and port if address exists, otherwise null.
* @type {?string} * @type {?string}
*/ */
@computed('proxy_address', 'proxy_port')
get proxy() { get proxy() {
return this.proxy_address return this.proxy_address
? `${this.proxy_address}:${this.proxy_port}` ? `${this.proxy_address}:${this.proxy_port}`
@@ -148,6 +145,9 @@ export default class SessionModel extends GeneratedSessionModel {
* @type {TargetModel} * @type {TargetModel}
*/ */
get target() { get target() {
if (!this.target_id) {
return null;
}
return this.store.peekRecord('target', this.target_id); return this.store.peekRecord('target', this.target_id);
} }
+3 -1
View File
@@ -7,6 +7,7 @@ import factory from '../generated/factories/session';
import { trait } from 'miragejs'; import { trait } from 'miragejs';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import generateId from '../helpers/id'; import generateId from '../helpers/id';
import permissions from '../helpers/permissions';
const statusStrings = ['pending', 'active', 'canceling', 'terminated']; const statusStrings = ['pending', 'active', 'canceling', 'terminated'];
@@ -15,7 +16,8 @@ export const pickRandomStatusString = () =>
export default factory.extend({ export default factory.extend({
id: () => generateId('ss_'), id: () => generateId('ss_'),
authorized_actions: () =>
permissions.authorizedActionsFor('session') || ['read', 'cancel'],
status: pickRandomStatusString(), status: pickRandomStatusString(),
/** /**
@@ -108,4 +108,85 @@ module('Unit | Ability | session', function (hooks) {
assert.true(canService.can('read session', session)); assert.true(canService.can('read session', session));
}); });
test('can cancel a session that is active', function (assert) {
assert.expect(1);
const session = store.createRecord('session', {
authorized_actions: ['cancel'],
status: STATUS_SESSION_ACTIVE,
});
assert.true(canService.can('cancel session', session));
});
test('can cancel a session that has cancel:self', function (assert) {
assert.expect(1);
const session = store.createRecord('session', {
authorized_actions: ['cancel:self'],
status: STATUS_SESSION_ACTIVE,
});
assert.true(canService.can('cancel session', session));
});
test('can cancel a session that is pending', function (assert) {
assert.expect(1);
const session = store.createRecord('session', {
authorized_actions: ['cancel'],
status: STATUS_SESSION_PENDING,
});
assert.true(canService.can('cancel session', session));
});
test('cannot cancel a session that is canceling', function (assert) {
assert.expect(1);
const session = store.createRecord('session', {
authorized_actions: ['cancel'],
status: STATUS_SESSION_CANCELING,
});
assert.false(canService.can('cancel session', session));
});
test('cannot cancel a session that is terminated', function (assert) {
assert.expect(1);
const session = store.createRecord('session', {
authorized_actions: ['cancel'],
status: STATUS_SESSION_TERMINATED,
});
assert.false(canService.can('cancel session', session));
});
test('cannot cancel 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('cancel session', activeSession));
assert.false(canService.can('cancel session', pendingSession));
assert.false(canService.can('cancel session', cancelingSession));
assert.false(canService.can('cancel session', terminatedSession));
});
}); });
@@ -8,7 +8,7 @@
.rose-layout-body-content { .rose-layout-body-content {
display: grid; display: grid;
grid-template-areas: 'body body sidebar'; grid-template-areas: 'body body sidebar';
grid-template-columns: 1fr 1fr 300px; grid-template-columns: 1fr 1fr fit-content(300px);
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
gap: 2rem; gap: 2rem;
@@ -34,98 +34,105 @@
</Hds::ApplicationState> </Hds::ApplicationState>
{{/if}} {{/if}}
</bc.Body> </bc.Body>
<bc.Sidebar>
<div class='details-sidebar'> {{#if (or @model.host @model.target)}}
{{#if @model.host}} <bc.Sidebar>
<div <div class='details-sidebar'>
class='title hds-typography-display-200 hds-font-weight-semibold' {{#if @model.host}}
> <div
{{t 'resources.host.details'}} class='title hds-typography-display-200 hds-font-weight-semibold'
</div> >
<Rose::MetadataList @orientation='vertical' as |list|> {{t 'resources.host.details'}}
{{! Host name }} </div>
{{#if @model.host.name}} <Rose::MetadataList @orientation='vertical' as |list|>
<list.Item @icon='monitor' @color='faint'> {{! Host name }}
{{@model.host.name}} {{#if @model.host.name}}
</list.Item> <list.Item @icon='monitor' @color='faint'>
{{/if}} {{@model.host.name}}
{{! Host description }} </list.Item>
{{#if @model.host.description}}
<list.Item @icon='message-circle-fill' @color='faint'>
{{@model.host.description}}
</list.Item>
{{/if}}
{{! Host address }}
{{#if @model.host.address}}
<list.Item @icon='network-alt' @color='faint'>
{{@model.host.address}}
</list.Item>
{{/if}}
{{! Host ID }}
<list.Item @icon='hash' @color='faint'>
<Copyable
@text={{@model.host.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
{{@model.host.id}}
</Copyable>
</list.Item>
</Rose::MetadataList>
{{/if}}
{{#if @model.target}}
<div
class='title hds-typography-display-200 hds-font-weight-semibold'
>
{{t 'resources.target.details'}}
</div>
<Rose::MetadataList @orientation='vertical' as |list|>
{{! Target name }}
<list.Item @icon='target' @color='faint'>
{{@model.target.name}}
</list.Item>
{{! Project name }}
<list.Item @icon='grid' @color='faint'>
{{@model.target.project.displayName}}
</list.Item>
{{! Target description }}
{{#if @model.target.description}}
<list.Item @icon='message-circle-fill' @color='faint'>
{{@model.target.description}}
</list.Item>
{{/if}}
{{! Target type }}
<list.Item class='resource-type' @icon='terminal' @color='faint'>
{{#if @model.target.isSSH}}
{{t 'resources.target.types.ssh'}}
{{else}}
{{t 'resources.target.types.tcp'}}
{{/if}} {{/if}}
</list.Item> {{! Host description }}
{{! Target address }} {{#if @model.host.description}}
{{#if @model.target.address}} <list.Item @icon='message-circle-fill' @color='faint'>
{{@model.host.description}}
</list.Item>
{{/if}}
{{! Host address }}
{{#if @model.host.address}}
<list.Item @icon='network-alt' @color='faint'>
{{@model.host.address}}
</list.Item>
{{/if}}
{{! Host ID }}
<list.Item @icon='hash' @color='faint'>
<Copyable
@text={{@model.host.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
{{@model.host.id}}
</Copyable>
</list.Item>
</Rose::MetadataList>
{{/if}}
{{#if @model.target}}
<div
class='title hds-typography-display-200 hds-font-weight-semibold'
>
{{t 'resources.target.details'}}
</div>
<Rose::MetadataList @orientation='vertical' as |list|>
{{! Target name }}
<list.Item @icon='target' @color='faint'>
{{@model.target.name}}
</list.Item>
{{! Project name }}
<list.Item @icon='grid' @color='faint'>
{{@model.target.project.displayName}}
</list.Item>
{{! Target description }}
{{#if @model.target.description}}
<list.Item @icon='message-circle-fill' @color='faint'>
{{@model.target.description}}
</list.Item>
{{/if}}
{{! Target type }}
<list.Item <list.Item
class='resource-type' class='resource-type'
@icon='network-alt' @icon='terminal'
@color='faint' @color='faint'
> >
{{@model.target.address}} {{#if @model.target.isSSH}}
{{t 'resources.target.types.ssh'}}
{{else}}
{{t 'resources.target.types.tcp'}}
{{/if}}
</list.Item> </list.Item>
{{/if}} {{! Target address }}
{{! Target ID }} {{#if @model.target.address}}
<list.Item @icon='hash' @color='faint'> <list.Item
<Copyable class='resource-type'
@text={{@model.target.id}} @icon='network-alt'
@buttonText={{t 'actions.copy-to-clipboard'}} @color='faint'
@acknowledgeText={{t 'states.copied'}} >
> {{@model.target.address}}
{{@model.target.id}} </list.Item>
</Copyable> {{/if}}
</list.Item> {{! Target ID }}
</Rose::MetadataList> <list.Item @icon='hash' @color='faint'>
{{/if}} <Copyable
</div> @text={{@model.target.id}}
</bc.Sidebar> @buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
{{@model.target.id}}
</Copyable>
</list.Item>
</Rose::MetadataList>
{{/if}}
</div>
</bc.Sidebar>
{{/if}}
</Rose::Layout::BodyContent> </Rose::Layout::BodyContent>
</T.Panel> </T.Panel>
<T.Panel> <T.Panel>
@@ -7,7 +7,6 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { notifySuccess, notifyError } from 'core/decorators/notify'; import { notifySuccess, notifyError } from 'core/decorators/notify';
import { resourceFilter } from 'core/decorators/resource-filter';
import { loading } from 'ember-loading'; import { loading } from 'ember-loading';
export default class ScopesScopeProjectsSessionsRoute extends Route { export default class ScopesScopeProjectsSessionsRoute extends Route {
@@ -16,23 +15,8 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
@service store; @service store;
@service ipc; @service ipc;
@service session; @service session;
@service resourceFilterStore;
@service router; @service router;
@service can;
// =attributes
@resourceFilter({
allowed: ['active', 'pending', 'canceling', 'terminated'],
defaultValue: ['active', 'pending', 'canceling'],
})
status;
@resourceFilter({
allowed: (route) => route.modelFor('scopes.scope.projects'),
serialize: ({ id }) => id,
findBySerialized: ({ id }, value) => id === value,
})
project;
// =methods // =methods
@@ -43,45 +27,6 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
if (!this.session.isAuthenticated) this.router.transitionTo('index'); if (!this.session.isAuthenticated) this.router.transitionTo('index');
} }
/**
* Loads all sessions under current scope for the current user.
*
* NOTE: previously, sessions were filtered only with API filter queries.
* In an effort to offload processing from the controller, sessions
* are now filtered on the client by projects and status,
* while user_id filtering remains server side.
*
* @return {Promise{[SessionModel]}}
*/
async model() {
const { id: scope_id } = this.modelFor('scopes.scope');
const queryOptions = {
scope_id,
recursive: true,
include_terminated: this.status?.includes('terminated'),
};
// Recursively query sessions within the current scope for the current user
let sessions = await this.resourceFilterStore.queryBy(
'session',
{ user_id: this.session.data.authenticated.user_id },
queryOptions
);
// If project filters are selected, filter sessions by the selected projects
if (this.project?.length) {
const projectIDs = this.project.map((p) => p?.id);
sessions = sessions.filter((s) => projectIDs.includes(s?.scopeID));
}
// If status filters are selected, filter sessions by the selected statuses
if (this.status?.length) {
sessions = sessions.filter((s) => this.status.includes(s?.status));
}
return sessions;
}
// =actions // =actions
/** /**
@@ -93,10 +38,14 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
@notifyError(({ message }) => message, { catch: true }) @notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.canceled-success') @notifySuccess('notifications.canceled-success')
async cancelSession(session) { async cancelSession(session) {
let updatedSession = session;
// fetch session from API to verify we have most up to date record // fetch session from API to verify we have most up to date record
const updatedSession = await this.store.findRecord('session', session.id, { if (this.can.can('read session', session)) {
reload: true, updatedSession = await this.store.findRecord('session', session.id, {
}); reload: true,
});
}
await updatedSession.cancelSession(); await updatedSession.cancelSession();
await this.ipc.invoke('stop', { session_id: session.id }); await this.ipc.invoke('stop', { session_id: session.id });
@@ -108,25 +57,6 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
} }
} }
/**
* 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 = [];
this.project = [];
}
/** /**
* refreshes session data. * refreshes session data.
*/ */
@@ -4,5 +4,88 @@
*/ */
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { resourceFilter } from 'core/decorators/resource-filter';
export default class ScopesScopeProjectsSessionsIndexRoute extends Route {} export default class ScopesScopeProjectsSessionsIndexRoute extends Route {
// =services
@service session;
@service resourceFilterStore;
// =attributes
@resourceFilter({
allowed: ['active', 'pending', 'canceling', 'terminated'],
defaultValue: ['active', 'pending', 'canceling'],
})
status;
@resourceFilter({
allowed: (route) => route.modelFor('scopes.scope.projects'),
serialize: ({ id }) => id,
findBySerialized: ({ id }, value) => id === value,
})
project;
// =methods
/**
* Loads all sessions under current scope for the current user.
*
* NOTE: previously, sessions were filtered only with API filter queries.
* In an effort to offload processing from the controller, sessions
* are now filtered on the client by projects and status,
* while user_id filtering remains server side.
*
* @return {Promise{[SessionModel]}}
*/
async model() {
const { id: scope_id } = this.modelFor('scopes.scope');
const queryOptions = {
scope_id,
recursive: true,
include_terminated: this.status?.includes('terminated'),
};
// Recursively query sessions within the current scope for the current user
let sessions = await this.resourceFilterStore.queryBy(
'session',
{ user_id: this.session.data.authenticated.user_id },
queryOptions
);
// If project filters are selected, filter sessions by the selected projects
if (this.project?.length) {
const projectIDs = this.project.map((p) => p?.id);
sessions = sessions.filter((s) => projectIDs.includes(s?.scopeID));
}
// If status filters are selected, filter sessions by the selected statuses
if (this.status?.length) {
sessions = sessions.filter((s) => this.status.includes(s?.status));
}
return sessions;
}
/**
* 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 = [];
this.project = [];
}
}
@@ -178,6 +178,7 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
id: session_id, id: session_id,
proxy_address: address, proxy_address: address,
proxy_port: port, proxy_port: port,
target_id: model.id,
}, },
], ],
}); });
@@ -18,7 +18,7 @@
<toolbar.primary> <toolbar.primary>
<Rose::Form as |form|> <Rose::Form as |form|>
{{#let {{#let
(resource-filter 'scopes.scope.projects.sessions' 'status') (resource-filter 'scopes.scope.projects.sessions.index' 'status')
as |filter| as |filter|
}} }}
<Rose::Dropdown <Rose::Dropdown
@@ -45,7 +45,7 @@
{{/let}} {{/let}}
{{#let {{#let
(resource-filter 'scopes.scope.projects.sessions' 'project') (resource-filter 'scopes.scope.projects.sessions.index' 'project')
as |filter| as |filter|
}} }}
<Rose::Dropdown <Rose::Dropdown
@@ -95,7 +95,7 @@
{{#if {{#if
(has-resource-filter-selections (has-resource-filter-selections
'scopes.scope.projects.sessions' 'status' 'project' 'scopes.scope.projects.sessions.index' 'status' 'project'
) )
}} }}
<Rose::Button <Rose::Button
@@ -143,7 +143,7 @@
@acknowledgeText={{t 'states.copied'}} @acknowledgeText={{t 'states.copied'}}
> >
<LinkTo <LinkTo
@route='scopes.scope.projects.sessions.session' @route='scopes.scope.projects.sessions.session.index'
@model={{B.data.id}} @model={{B.data.id}}
data-test-session-detail-link data-test-session-detail-link
> >
@@ -201,11 +201,12 @@
<SessionStatus @model={{B.data}} /> <SessionStatus @model={{B.data}} />
</B.Td> </B.Td>
<B.Td> <B.Td>
{{#if B.data.isAvailable}} {{#if (can 'cancel session' B.data)}}
<Hds::Button <Hds::Button
@text={{t 'actions.cancel'}} @text={{t 'actions.cancel'}}
@color='secondary' @color='secondary'
{{on 'click' (route-action 'cancelSession' B.data)}} {{on 'click' (route-action 'cancelSession' B.data)}}
data-test-session-cancel-button
/> />
{{/if}} {{/if}}
</B.Td> </B.Td>
@@ -214,7 +215,7 @@
</Hds::Table> </Hds::Table>
{{else if (has-resource-filter-selections {{else if (has-resource-filter-selections
'scopes.scope.projects.sessions' 'status' 'project' 'scopes.scope.projects.sessions.index' 'status' 'project'
)}} )}}
<Rose::Layout::Centered> <Rose::Layout::Centered>
<Rose::Message @title={{t 'titles.no-results'}} as |message|> <Rose::Message @title={{t 'titles.no-results'}} as |message|>
@@ -15,13 +15,15 @@
</page.header> </page.header>
<page.actions> <page.actions>
<Hds::Button {{#if (can 'cancel session' @model)}}
data-test-session-detail-cancel-button <Hds::Button
@text={{t 'resources.session.actions.end'}} data-test-session-detail-cancel-button
@icon='x' @text={{t 'resources.session.actions.end'}}
@color='secondary' @icon='x'
{{on 'click' (route-action 'cancelSession' @model)}} @color='secondary'
/> {{on 'click' (route-action 'cancelSession' @model)}}
/>
{{/if}}
</page.actions> </page.actions>
@@ -191,14 +191,7 @@ module('Acceptance | projects | sessions', function (hooks) {
test('can link to an active session with read:self permissions', async function (assert) { test('can link to an active session with read:self permissions', async function (assert) {
assert.expect(1); assert.expect(1);
this.server.schema.sessions.all().destroy(); instances.session.update({ authorized_actions: ['read:self'] });
this.server.create('session', {
authorized_actions: ['read:self'],
scope: instances.scopes.project,
target: instances.target,
status: STATUS_SESSION_ACTIVE,
user: instances.user,
});
await visit(urls.sessions); await visit(urls.sessions);
@@ -222,6 +215,19 @@ module('Acceptance | projects | sessions', function (hooks) {
.isVisible(); .isVisible();
}); });
test('can link to session even without read permissions', async function (assert) {
assert.expect(1);
instances.session.update({ authorized_actions: [] });
await visit(urls.sessions);
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) { test('cannot link to a canceling session', async function (assert) {
assert.expect(2); assert.expect(2);
instances.session.update({ status: STATUS_SESSION_CANCELING }); instances.session.update({ status: STATUS_SESSION_CANCELING });
@@ -255,6 +261,38 @@ module('Acceptance | projects | sessions', function (hooks) {
.isVisible(); .isVisible();
}); });
test('can cancel an active session with cancel permissions', async function (assert) {
assert.expect(1);
await visit(urls.sessions);
assert
.dom('tbody tr:first-child [data-test-session-cancel-button]')
.isVisible();
});
test('can cancel an active session with cancel:self permissions', async function (assert) {
assert.expect(1);
instances.session.update({ authorized_actions: ['cancel:self'] });
await visit(urls.sessions);
assert
.dom('tbody tr:first-child [data-test-session-cancel-button]')
.isVisible();
});
test('cannot click cancel button without cancel permissions', async function (assert) {
assert.expect(1);
instances.session.update({ authorized_actions: [] });
await visit(urls.sessions);
assert
.dom('tbody tr:first-child [data-test-session-cancel-button]')
.isNotVisible();
});
test('cancelling a session shows success alert', async function (assert) { test('cancelling a session shows success alert', async function (assert) {
assert.expect(1); assert.expect(1);
stubs.ipcService.withArgs('stop'); stubs.ipcService.withArgs('stop');
@@ -123,6 +123,11 @@ module('Acceptance | projects | sessions | session', function (hooks) {
stubs.ipcService = sinon.stub(ipcService, 'invoke'); stubs.ipcService = sinon.stub(ipcService, 'invoke');
}); });
hooks.afterEach(function () {
// reset onUncaughtException to original state
QUnit.onUncaughtException = originalUncaughtException;
});
test('visiting session detail', async function (assert) { test('visiting session detail', async function (assert) {
assert.expect(1); assert.expect(1);
@@ -132,11 +137,6 @@ module('Acceptance | projects | sessions | session', function (hooks) {
assert.strictEqual(currentURL(), urls.session); assert.strictEqual(currentURL(), urls.session);
}); });
hooks.afterEach(function () {
// reset onUncaughtException to original state
QUnit.onUncaughtException = originalUncaughtException;
});
test('visiting session with no credentials', async function (assert) { test('visiting session with no credentials', async function (assert) {
assert.expect(4); assert.expect(4);
stubs.ipcService.withArgs('cliExists').returns(true); stubs.ipcService.withArgs('cliExists').returns(true);
@@ -199,6 +199,24 @@ module('Acceptance | projects | sessions | session', function (hooks) {
assert.strictEqual(currentURL(), urls.session); assert.strictEqual(currentURL(), urls.session);
}); });
test('can cancel a session with cancel:self permissions', async function (assert) {
assert.expect(1);
instances.session.update({ authorized_actions: ['cancel:self'] });
await visit(urls.session);
assert.dom('[data-test-session-detail-cancel-button]').isVisible();
});
test('cannot cancel a session without cancel permissions', async function (assert) {
assert.expect(1);
instances.session.update({ authorized_actions: [] });
await visit(urls.session);
assert.dom('[data-test-session-detail-cancel-button]').isNotVisible();
});
test('cancelling a session shows success alert', async function (assert) { test('cancelling a session shows success alert', async function (assert) {
assert.expect(1); assert.expect(1);
stubs.ipcService.withArgs('stop'); stubs.ipcService.withArgs('stop');