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:
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user