refactor: 💡 allow target connection even with no read ability (#1984)

* refactor: 💡 allow target connection even with no read ability

* Update ui/desktop/tests/acceptance/projects/targets/index-test.js

Co-authored-by: Dharini Jeeva <dharini@hashicorp.com>

* refactor: 💡 remove unneeded await from transitionTo

---------

Co-authored-by: Dharini Jeeva <dharini@hashicorp.com>
This commit is contained in:
Cameron PereraandDharini Jeeva authored and GitHub committed 2023-11-03 16:01:51 -05:00
1 parent 0129784322
commit f54d48c380
4 files changed
+199 -93

No files matched your search

@@ -0,0 +1,103 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
export default class ScopesScopeProjectsTargetsIndexController extends Controller {
// =services
@service confirm;
@service ipc;
@service router;
@service session;
@service store;
// =methods
/**
* Quick connect method used to call main connect method and handle
* connection errors unique to this route
* @param {TargetModel} target
*/
@action
async quickConnect(target) {
try {
await this.connect(target);
} catch (error) {
this.confirm
.confirm(error.message, { isConnectError: true })
// Retry
.then(() => this.quickConnect(target));
}
}
/**
* Establish a session to current target.
* @param {TargetModel} target
* @param {HostModel} host
*/
@action
@loading
async connect(target, host) {
// Check for CLI
const cliExists = await this.ipc.invoke('cliExists');
if (!cliExists) throw new Error('Cannot find Boundary CLI.');
const options = {
target_id: target.id,
token: this.session.data.authenticated.token,
};
if (host) options.host_id = host.id;
// Create target session
const connectionDetails = await this.ipc.invoke('connect', options);
// Associate the connection details with the session
let session;
const { session_id, address, port, credentials } = connectionDetails;
try {
session = await this.store.findRecord('session', session_id);
} catch (error) {
/**
* if the user cannot read or fetch the session we add the important
* information returned from the connect command to allow the user
* to still continue their work with the information they need
*/
this.store.pushPayload('session', {
sessions: [
{
id: session_id,
proxy_address: address,
proxy_port: port,
target_id: target.id,
},
],
});
session = this.store.peekRecord('session', session_id);
}
// Flag the session has been open in the desktop client
session.started_desktop_client = true;
/**
* Update the session record with proxy information from the CLI
* In the future, it may make sense to push this off to the API so that
* we don't have to manually persist the proxy details.
*/
session.proxy_address = address;
session.proxy_port = port;
if (credentials) {
credentials.forEach((cred) => session.addCredential(cred));
}
this.router.transitionTo(
'scopes.scope.projects.sessions.session',
session_id
);
}
}
@@ -6,16 +6,12 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
// =services
@service store;
@service confirm;
@service ipc;
@service router;
@service session;
// =attributes
@@ -98,6 +94,34 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
return true;
}
/**
* Connect method that calls parent connect method and handles
* connection errors unique to this route
* @param {TargetModel} target
* @param {HostModel} host
*/
@action
async connect(target, host) {
/* eslint-disable-next-line ember/no-controller-access-in-routes */
const parentController = this.controllerFor(
'scopes.scope.projects.targets.index'
);
try {
await parentController.connect(target, host);
} catch (error) {
this.isConnectionError = true;
this.confirm
.confirm(error.message, { isConnectError: true })
// Retry
.then(() => this.connect(target, host))
.catch(() => {
// Reset the flag as this was user initiated and we're not
// in a transition or have a host modal open
this.isConnectionError = false;
});
}
}
/**
* Establish a session to current target.
* @param {TargetModel} target
@@ -107,6 +131,7 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
@action
async hostConnect(target, host) {
await this.connect(target, host);
if (this.isConnectionError) {
/* eslint-disable-next-line ember/no-controller-access-in-routes */
const controller = this.controllerFor(
@@ -120,9 +145,9 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
/**
* Determine if we show host modal or quick connect based on target attributes.
* @param {TargetModel} model
* @param {function} toggleModal
*/
@action
@loading
async preConnect(model, toggleModal) {
/**
* if hosts length is 1 or less we will try to
@@ -136,83 +161,4 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route {
toggleModal(true);
}
}
/**
* Establish a session to current target.
* @param {TargetModel} model
* @param {HostModel} host
*/
@action
@loading
async connect(model, host) {
// TODO: Connect: move this logic into the target model
try {
// Check for CLI
const cliExists = await this.ipc.invoke('cliExists');
if (!cliExists) throw new Error('Cannot find Boundary CLI.');
const options = {
target_id: model.id,
token: this.session.data.authenticated.token,
};
if (host) options.host_id = host.id;
// Create target session
const connectionDetails = await this.ipc.invoke('connect', options);
// Associate the connection details with the session
let session;
const { session_id, address, port, credentials } = connectionDetails;
try {
session = await this.store.findRecord('session', session_id);
} catch (error) {
/**
* if the user cannot read or fetch the session we add the important
* information returned from the connect command to allow the user
* to still continue their work with the information they need
*/
this.store.pushPayload('session', {
sessions: [
{
id: session_id,
proxy_address: address,
proxy_port: port,
target_id: model.id,
},
],
});
session = this.store.peekRecord('session', session_id);
}
// Flag the session has been open in the desktop client
session.started_desktop_client = true;
/**
* Update the session record with proxy information from the CLI
* In the future, it may make sense to push this off to the API so that
* we don't have to manually persist the proxy details.
*/
session.proxy_address = address;
session.proxy_port = port;
if (credentials) {
credentials.forEach((cred) => session.addCredential(cred));
}
await this.router.transitionTo(
'scopes.scope.projects.sessions.session',
session_id
);
} catch (e) {
this.isConnectionError = true;
this.confirm
.confirm(e.message, { isConnectError: true })
// Retry
.then(() => this.connect(model, host))
.catch(() => {
// Reset the flag as this was user initiated and we're not
// in a transition or have a host modal open
this.isConnectionError = false;
});
}
}
}
@@ -160,14 +160,23 @@
</B.Td>
<B.Td>
{{#if (can 'connect target' B.data)}}
<Hds::Button
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
@route='scopes.scope.projects.targets.target'
@model={{B.data.id}}
@query={{hash isConnecting=true}}
/>
{{#if (can 'read target' B.data)}}
<Hds::Button
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
@route='scopes.scope.projects.targets.target'
@model={{B.data.id}}
@query={{hash isConnecting=true}}
/>
{{else}}
<Hds::Button
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
{{on 'click' (fn this.quickConnect B.data)}}
/>
{{/if}}
{{/if}}
</B.Td>
</B.Tr>
@@ -29,10 +29,15 @@ module('Acceptance | projects | targets', function (hooks) {
let getTargetCount;
const ROSE_APP_STATE_TITLE = '.rose-message-title';
const APP_STATE_TITLE = '.hds-application-state__title';
const TARGET_DETAILS_ROUTE_NAME =
'scopes.scope.projects.targets.target.index';
const ROSE_DIALOG_MODAL = '.rose-dialog-error';
const CHOOSE_HOST_MODAL = '[data-test-host-modal]';
const ROSE_DIALOG_MODAL_BUTTONS = '.rose-dialog-footer button';
const ROSE_DIALOG_RETRY_BUTTON = '.rose-dialog footer .rose-button-primary';
const ROSE_DIALOG_CANCEL_BUTTON =
'.rose-dialog footer .rose-button-secondary';
const instances = {
scopes: {
@@ -225,7 +230,7 @@ module('Acceptance | projects | targets', function (hooks) {
.doesNotExist();
});
test('user is redirected to target details page when unable to connect from list view', async function (assert) {
test('user is redirected to target details page when unable to connect from list view if they have read and authorize-session permissions', async function (assert) {
assert.expect(3);
stubs.ipcService.withArgs('cliExists').returns(true);
stubs.ipcService.withArgs('connect').rejects();
@@ -240,4 +245,47 @@ module('Acceptance | projects | targets', function (hooks) {
assert.dom(CHOOSE_HOST_MODAL).doesNotExist();
assert.strictEqual(currentRouteName(), TARGET_DETAILS_ROUTE_NAME);
});
test('user can connect without target read permissions', async function (assert) {
assert.expect(2);
instances.target.authorized_actions =
instances.target.authorized_actions.filter((item) => item !== 'read');
stubs.ipcService.withArgs('cliExists').returns(true);
stubs.ipcService.withArgs('connect').returns({
session_id: instances.session.id,
address: 'a_123',
port: 'p_123',
protocol: 'tcp',
});
await visit(urls.targets);
await click(`[data-test-targets-connect-button="${instances.target.id}"]`);
assert.strictEqual(
currentURL(),
`${urls.projects}/sessions/${instances.session.id}`
);
assert.dom(APP_STATE_TITLE).hasText('Connected');
});
test('user can retry connect without target read permissions', async function (assert) {
assert.expect(5);
instances.target.authorized_actions =
instances.target.authorized_actions.filter((item) => item !== 'read');
stubs.ipcService.withArgs('cliExists').returns(true);
stubs.ipcService.withArgs('connect').rejects();
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
await visit(urls.targets);
await click(`[data-test-targets-connect-button="${instances.target.id}"]`);
assert.strictEqual(currentURL(), urls.targets);
assert.dom(ROSE_DIALOG_MODAL).exists();
assert.dom(ROSE_DIALOG_MODAL_BUTTONS).exists({ count: 2 });
assert.dom(ROSE_DIALOG_RETRY_BUTTON).hasText('Retry');
assert.dom(ROSE_DIALOG_CANCEL_BUTTON).hasText('Cancel');
});
});