diff --git a/addons/api/addon/models/session.js b/addons/api/addon/models/session.js index 5ab2e3c39..dd27cd9c6 100644 --- a/addons/api/addon/models/session.js +++ b/addons/api/addon/models/session.js @@ -32,6 +32,14 @@ export default class SessionModel extends GeneratedSessionModel { : null; } + /** + * The target associated with this session (if loaded). + * @type {TargetModel} + */ + get target() { + return this.store.peekRecord('target', this.target_id); + } + // =methods /** diff --git a/addons/api/addon/models/target.js b/addons/api/addon/models/target.js index 8924ffbb4..6c5e1cfe8 100644 --- a/addons/api/addon/models/target.js +++ b/addons/api/addon/models/target.js @@ -45,6 +45,14 @@ export default class TargetModel extends GeneratedTargetModel { .filter(s => s && (s.target_id === this.id)); } + /** + * The project associated with this target (if already loaded). + * @type {ScopeModel} + */ + get project() { + return this.store.peekRecord('scope', this.scopeID); + } + /** * True if any sessions associated with this target are active or pending. * @type {boolean} diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index 5ae912f69..8f6503319 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -23,13 +23,16 @@ export default function() { // Scope resources - this.get('/scopes', ({ scopes }, { queryParams: { scope_id } }) => { + this.get('/scopes', ({ scopes }, { queryParams: { scope_id, recursive } }) => { // Default parent scope is global if (!scope_id) scope_id = 'global'; - const results = scopes.where(scope => { - return scope.scope ? scope.scope.id === scope_id : false - }); - return results; + if (recursive && scope_id === 'global') { + return scopes.all(); + } else { + return scopes.where(scope => { + return scope.scope ? scope.scope.id === scope_id : false + }); + } }); this.post('/scopes', function ({ scopes }) { // Parent scope comes through the payload via `scope_id`, but this needs @@ -287,7 +290,16 @@ export default function() { // target - this.get('/targets', function ({ targets }, { queryParams: { scope_id } }) { + this.get('/targets', function ({ targets }, { queryParams: { scope_id, recursive } }) { + if (recursive && scope_id === 'global') { + return targets.all(); + } else if (recursive) { + return targets.where(target => { + const targetModel = targets.find(target.id); + return (target.scopeId === scope_id) + || (targetModel?.scope?.scope?.id === scope_id) + }); + } return targets.where(target => target.scopeId === scope_id); }); this.post('/targets', function ({ targets }) { @@ -325,7 +337,7 @@ export default function() { // session - this.get('/sessions', function ({ sessions }, { queryParams: { scope_id } }) { + this.get('/sessions', function ({ sessions }, { queryParams: { scope_id, recursive } }) { // To simulate changes to `session.status` that may occur in the backend, // we quietly randomize the value of the field on GET. // To populate sessions for logged in user, @@ -333,7 +345,7 @@ export default function() { // But only if not in testing mode. // In tests, we need deterministic statuses. if (!Ember.testing) { - sessions.where(session => session.scopeId === scope_id) + sessions.all() .models .forEach(session => { session.update({ @@ -344,7 +356,16 @@ export default function() { }); }); } - return sessions.where(session => session.scopeId === scope_id) + if (recursive && scope_id === 'global') { + return sessions.all(); + } else if (recursive) { + return sessions.where(session => { + const sessionModel = sessions.find(session.id); + return (session.scopeId === scope_id) + || (sessionModel?.scope?.scope?.id === scope_id) + }); + } + return sessions.where(session => session.scopeId === scope_id); }); this.get('/sessions/:id', function ({ sessions }, { params: { id } }) { const session = sessions.find(id); diff --git a/addons/core/translations/errors/en-us.yaml b/addons/core/translations/errors/en-us.yaml index 0e2239e72..c814611d0 100644 --- a/addons/core/translations/errors/en-us.yaml +++ b/addons/core/translations/errors/en-us.yaml @@ -17,3 +17,5 @@ generic: title: Error authentication-failed: title: Authentication Failed +origin-verification-failed: + description: Unable to connect to the specified origin. Please check the URL and try again. diff --git a/ui/desktop/app/controllers/scopes/scope/projects/sessions/index.js b/ui/desktop/app/controllers/scopes/scope/projects/sessions/index.js new file mode 100644 index 000000000..babe28f5f --- /dev/null +++ b/ui/desktop/app/controllers/scopes/scope/projects/sessions/index.js @@ -0,0 +1,35 @@ +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeProjectsSessionsIndexController extends Controller { + + // =sessions + + @service session; + + // =attributes + + /** + * A list of sessions filtered to the current user and sorted by created time, + * and active/pending. + * @type {SessionModel[]} + */ + @computed('model.@each.{created_time,active}', 'session.data.authenticated.user_id') + get sorted() { + const userId = this.session.data.authenticated.user_id; + const sessions = this.model; + const sortedSessions = sessions + // filter sessions by current user + .filter(session => session.user_id === userId) + // sort by created time + .sortBy('session.created_time').reverse(); + return [ + // then move active sessions to the top + ...sortedSessions.filter((session) => session.isCancelable), + // and all others to the end + ...sortedSessions.filter((session) => !session.isCancelable), + ]; + } + +} diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/target/sessions.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/target/sessions.js new file mode 100644 index 000000000..dfab20739 --- /dev/null +++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/target/sessions.js @@ -0,0 +1,36 @@ +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { A } from '@ember/array'; + +export default class ScopesScopeProjectsTargetsTargetSessionsController extends Controller { + // =services + + @service session; + + /** + * Sort and filter to active sessions of current user + * @param {[SessionModel]} model + * @param {object} session + * @type {[SessionModel]} + */ + @computed('model.@each.{created_time,active}', 'session.data.authenticated.user_id') + get selfActiveSessions() { + const sessions = this.model; + const userId = this.session.data.authenticated.user_id; + const filteredSessions = + sessions.filter(session => + session.isCancelable && + session.user_id === userId + ); + + // Sort sessions + const sortedSessions = A(filteredSessions).sortBy('created_time').reverse(); + + // Move active sessions to top + return [ + ...sortedSessions.filter(session => session.isActive), + ...sortedSessions.filter(session => !session.isActive) + ] + } +} diff --git a/ui/desktop/app/routes/scopes/scope.js b/ui/desktop/app/routes/scopes/scope.js index 914aa2904..a0b817037 100644 --- a/ui/desktop/app/routes/scopes/scope.js +++ b/ui/desktop/app/routes/scopes/scope.js @@ -88,15 +88,4 @@ export default class ScopesScopeRoute extends Route { outlet: 'header-nav', }); } - - /** - * In global scope, redirect to first org on authentication. - */ - redirect(model) { - const { isAuthenticated } = this.session; - const { isGlobal } = model; - if (isAuthenticated && isGlobal && this.scopes.orgs?.length) { - this.replaceWith('scopes.scope', this.scopes.orgs.firstObject.id); - } - } } diff --git a/ui/desktop/app/routes/scopes/scope/projects.js b/ui/desktop/app/routes/scopes/scope/projects.js index 0d7ada97c..e579d7efb 100644 --- a/ui/desktop/app/routes/scopes/scope/projects.js +++ b/ui/desktop/app/routes/scopes/scope/projects.js @@ -1,6 +1,20 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; +/** + * TODO: This route is now vestigial. Desktop does not provide project-based + * navigation. This route formerly provided a way to aggregate + * all project scopes, which was necessary to then aggregate targets and + * sessions under those projects. Since the API now provides recursive listing, + * it's possible to load all targets and sessions with a single call, no client- + * side aggregation required. + * + * However, it _is_ necessary to prime the store with all project scopes. + * This allows the UI to display project names on associated resources. + * + * The priming function of this route could be moved to scopes or scopes/scope. + * It no longer makes sense as a dedicated route. + */ export default class ScopesScopeProjectsRoute extends Route { // =services @@ -16,14 +30,13 @@ export default class ScopesScopeProjectsRoute extends Route { } /** - * Loads project scopes for the current scope. - * @return {Promise + * Primes the store with _all project scopes_ under global. + * @return {Promise{ScopeModel}} */ model() { - const currentScope = this.modelFor('scopes.scope'); - return this.store.query('scope', { - scope_id: currentScope.id, - }); + return this.store + .query('scope', { recursive: true, scope_id: 'global' }) + .filter(({ isProject }) => isProject); } } diff --git a/ui/desktop/app/routes/scopes/scope/projects/sessions.js b/ui/desktop/app/routes/scopes/scope/projects/sessions.js index 1c0f133ef..149aedb7f 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/sessions.js +++ b/ui/desktop/app/routes/scopes/scope/projects/sessions.js @@ -1,8 +1,6 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { all, hash } from 'rsvp'; import { task, timeout } from 'ember-concurrency'; -import { A } from '@ember/array'; import config from '../../../../config/environment'; import { action } from '@ember/object'; import { notifySuccess, notifyError } from 'core/decorators/notify'; @@ -49,43 +47,9 @@ export default class ScopesScopeProjectsSessionsRoute extends Route { * @return {Promise{[{session: SessionModel, target: TargetModel}]}} */ async model() { - const projects = this.modelFor('scopes.scope.projects'); - const userId = this.session.data.authenticated.user_id; - let projectSessions = await all( - projects.map(({ id: scope_id }) => - this.store.query('session', { scope_id }) - ) - ); - - // Filter sessions to current user - let sessions = projectSessions.map( - sessions => sessions.filter( - session => session.user_id === userId - ) - ).flat(); - - const sessionAggregates = await all( - sessions.map(session => hash({ - session, - target: session.target_id - ? ( - this.store.peekRecord('target', session.target_id) || - this.store.findRecord('target', session.target_id) - ) - : null, - })) - ); - - // Sort sessions by time created... - let sortedSessionAggregates = - A(sessionAggregates).sortBy('session.created_time').reverse(); - // Then move active sessions to the top... - sortedSessionAggregates = [ - ...sortedSessionAggregates.filter((aggregate) => aggregate.session.status === 'active'), - ...sortedSessionAggregates.filter((aggregate) => aggregate.session.status !== 'active'), - ]; - - return sortedSessionAggregates; + const { id: scope_id } = this.modelFor('scopes.scope'); + await this.store.query('target', { recursive: true, scope_id }); + return await this.store.query('session', { recursive: true, scope_id }); } /** diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets.js b/ui/desktop/app/routes/scopes/scope/projects/targets.js index f02dd4f0f..867011648 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets.js @@ -1,6 +1,5 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { all } from 'rsvp'; import { action } from '@ember/object'; import { task, timeout } from 'ember-concurrency'; import { later } from '@ember/runloop'; @@ -51,20 +50,9 @@ export default class ScopesScopeProjectsTargetsRoute extends Route { * @return {Promise{[TargetModel]}} */ async model() { - const projects = this.modelFor('scopes.scope.projects'); - let projectTargets = await all( - projects.map(({ id: scope_id }) => - this.store.query('target', { scope_id }) - ) - ); - let targets = projectTargets.map((target) => target.toArray()).flat(); + const { id: scope_id } = this.modelFor('scopes.scope'); await this.refreshSessions(); - return targets.map((target) => { - return { - target, - project: this.store.peekRecord('scope', target.scopeID), - }; - }); + return this.store.query('target', { recursive: true, scope_id }); } /** @@ -73,13 +61,8 @@ export default class ScopesScopeProjectsTargetsRoute extends Route { * TODO: query only for sessions associated with the current user. */ async refreshSessions() { - const projects = this.modelFor('scopes.scope.projects'); - //const userId = this.session.data.authenticated.user_id; - await all( - projects.map(({ id: scope_id }) => - this.store.query('session', { scope_id }) - ) - ); + const { id: scope_id } = this.modelFor('scopes.scope'); + await this.store.query('session', { recursive: true, scope_id }); } /** @@ -115,7 +98,7 @@ export default class ScopesScopeProjectsTargetsRoute extends Route { target_id: model.id, token: this.session.data.authenticated.token }; - + if (host) options.host_id = host.id; // Create target session diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js index 0bcdbd2ec..3cf2343fe 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js @@ -1,36 +1,17 @@ import Route from '@ember/routing/route'; -import { inject as service } from '@ember/service'; -import { A } from '@ember/array'; export default class ScopesScopeProjectsTargetsTargetRoute extends Route { - // =services - - @service session; - // =methods /** - * Load a target and it's active sessions + * Load a target * @param {object} params * @param {string} params.target_id - * @return {{TargetModel, [SessionModel]}} + * @return {TargetModel} */ - async model({ target_id }) { - const userId = this.session.data.authenticated.user_id; - const target = await this.store.findRecord('target', target_id, { reload: true }); - const sessions = await this.store.query('session', { scope_id: target.scopeID }); - - // Filter to current user, current target, and active state - const filteredSessions = sessions.filter( - session => session.user_id === userId && - session.target_id === target.id && - session.isCancelable - ) - // Sort sessions by time created - const sortedSessions = - A(filteredSessions).sortBy('session.created_time').reverse(); - return { target, sessions: sortedSessions }; + model({ target_id }) { + return this.store.findRecord('target', target_id, { reload: true }); } /** diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target/hosts.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target/hosts.js index 0362726f5..ef2f5a8ab 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets/target/hosts.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target/hosts.js @@ -18,7 +18,7 @@ export default class ScopesScopeProjectsTargetsTargetHostsRoute extends Route { * @return {Promise{[HostModel]}} */ async model() { - const { target } = this.modelFor('scopes.scope.projects.targets.target'); + const target = this.modelFor('scopes.scope.projects.targets.target'); // Load all host-sets const hostSets = await all( target.host_sets.map(({ host_set_id }) => diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js index 4f0281e2f..203b74d62 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js @@ -3,6 +3,16 @@ import { action } from '@ember/object'; import { notifySuccess, notifyError } from 'core/decorators/notify'; export default class ScopesScopeProjectsTargetsTargetSessionsRoute extends Route { + + // =methods + + /** + * @return {[SessionModel]} + */ + model() { + return this.modelFor('scopes.scope.projects.targets.target').sessions; + } + // =actions /** diff --git a/ui/desktop/app/templates/application.hbs b/ui/desktop/app/templates/application.hbs index 7101dd522..3bca4c490 100644 --- a/ui/desktop/app/templates/application.hbs +++ b/ui/desktop/app/templates/application.hbs @@ -77,7 +77,7 @@ @icon="check-circle-fill" @style="success" @modal={{true}} - @dismiss={{accept}} + @dismiss={{deny}} as |dialog|> @@ -107,7 +107,7 @@ @icon="cancel-square-fill" @style="error" @modal={{true}} - @dismiss={{accept}} + @dismiss={{deny}} as |dialog|>

diff --git a/ui/desktop/app/templates/scopes/scope/authenticate.hbs b/ui/desktop/app/templates/scopes/scope/authenticate.hbs index b01ed3a31..3d7d834bd 100644 --- a/ui/desktop/app/templates/scopes/scope/authenticate.hbs +++ b/ui/desktop/app/templates/scopes/scope/authenticate.hbs @@ -1,65 +1,58 @@ {{page-title (t "actions.authenticate")}} -{{!-- - -Change --}} - +

+ - - - {{t "titles.global"}} - - {{#each this.sortedScopes as |scope|}} - - {{scope.displayName}} + + + {{t "titles.global"}} - {{/each}} - + {{#each this.sortedScopes as |scope|}} + + {{scope.displayName}} + + {{/each}} + - - {{#each @model.authMethods as |authMethod|}} - - {{t (concat "resources.auth-method.types." authMethod.type)}} - - {{/each}} - + + {{#each @model.authMethods as |authMethod|}} + + {{t (concat "resources.auth-method.types." authMethod.type)}} + + {{/each}} + - {{#if @model.authMethods}} - {{outlet}} + {{#if @model.authMethods}} + {{outlet}} -

- {{t "origin.connected-to"}}: - {{this.origin}} -
- - {{t "actions.change-origin"}} - -

+

+ {{t "origin.connected-to"}}: + {{this.origin}} +
+ + {{t "actions.change-origin"}} + +

- {{/if}} - {{#unless @model.authMethods}} - - - - {{t "resources.auth-method.messages.none.description"}} - - - - {{/unless}} + {{/if}} + {{#unless @model.authMethods}} + + + + {{t "resources.auth-method.messages.none.description"}} + + + + {{/unless}} -
+ +
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 35c53bb63..3f6dccb72 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs @@ -6,7 +6,7 @@ - {{#if @model}} + {{#if this.sorted}} @@ -20,22 +20,22 @@ - {{#each @model as |sessionAggregate|}} + {{#each this.sorted as |model|}} - {{sessionAggregate.session.id}} + {{model.id}} - {{sessionAggregate.target.displayName}} + {{model.target.displayName}} {{!-- Two if's here because: @@ -48,29 +48,29 @@ from other devices (or even another tab), in which case we won't have the local proxy info available here. --}} - {{#if sessionAggregate.session.isCancelable}} - {{#if sessionAggregate.session.proxy}} + {{#if model.isCancelable}} + {{#if model.proxy}} - {{sessionAggregate.session.proxy}} + {{model.proxy}} {{/if}} {{/if}} - - {{sessionAggregate.session.status}} + {{model.status}} - {{#if sessionAggregate.session.isCancelable}} - + {{#if model.isCancelable}} + {{t "actions.cancel"}} {{/if}} diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs index b816ced05..af224473c 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs @@ -21,33 +21,34 @@ {{#each @model as |model|}} - - {{model.target.displayName}} + {{model.displayName}} -

{{model.target.description}}

+ {{#if model.target.description}} +

{{model.target.description}}

+ {{/if}} + {{#if model.target.isActive}} + + {{t "states.active"}} + + {{/if}}
- {{model.target.id}} + {{model.id}} - {{#if model.target.type}} + {{#if model.type}} - {{t (concat "resources.target.types." model.target.type)}} + {{t (concat "resources.target.types." model.type)}} {{/if}} @@ -66,7 +67,7 @@
- + {{t "resources.session.actions.connect"}} diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/target/-actions.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target/-actions.hbs index e59af9f97..b82595c94 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/target/-actions.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target/-actions.hbs @@ -1,3 +1,3 @@ - + {{t "resources.session.actions.connect"}} diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/target/-header.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target/-header.hbs index 8677dadd4..228a2887b 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/target/-header.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target/-header.hbs @@ -1,18 +1,19 @@

- {{@model.target.displayName}} - {{#if @model.target.isActive}} + {{@model.displayName}} + {{#if @model.isActive}} +   {{t "states.active"}} {{/if}}

-

{{@model.target.description}}

+

{{@model.description}}

- {{@model.target.id}} + {{@model.id}}

diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/target/sessions.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target/sessions.hbs index e273d7058..008e5dfb0 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/target/sessions.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target/sessions.hbs @@ -1,10 +1,6 @@ - {{!-- - TODO: sort these via the controller so that active and pending appear first - similar to the way the sessions routes are sorted. - --}} - {{#each @model.target.sessions as |session|}} + {{#each this.selfActiveSessions as |session|}} { @@ -19,7 +28,7 @@ module.exports = { name: "Boundary Desktop", appBundleId: "com.electron.boundary", // TODO: where should the client version number come from? - appVersion: `1.0.0 ${formattedCLIVersion}`, + appVersion: `${formattedAppVersion}${formattedCLIVersion}\n`, appCopyright: "Copyright © 2021 HashiCorp, Inc.", icon: "./config/macos/icon.icns", osxSign: { @@ -34,7 +43,8 @@ module.exports = { { name: "@electron-forge/maker-dmg", config: { - name: "Boundary Desktop" + name: "boundary-desktop", + title: "Boundary Desktop" } } ] diff --git a/ui/desktop/electron-app/src/boundary-cli.js b/ui/desktop/electron-app/src/boundary-cli.js index 241f288fc..2227687ed 100644 --- a/ui/desktop/electron-app/src/boundary-cli.js +++ b/ui/desktop/electron-app/src/boundary-cli.js @@ -49,8 +49,8 @@ module.exports = { if (gitRevision) gitRevision = gitRevision.groups.rev; if (versionNumber) versionNumber = versionNumber.groups.ver; const formatted = versionNumber - ? `CLI Version ${versionNumber}; CLI Git Rev ${gitRevision}` - : `CLI Git Rev ${gitRevision}`; + ? `CLI Version: ${versionNumber}\nCLI Commit: ${gitRevision}` + : `CLI Commit: ${gitRevision}`; return { gitRevision, versionNumber, formatted }; return rawOutput; } diff --git a/ui/desktop/tests/unit/controllers/scopes/scope/projects/sessions/index-test.js b/ui/desktop/tests/unit/controllers/scopes/scope/projects/sessions/index-test.js new file mode 100644 index 000000000..1a9f45f78 --- /dev/null +++ b/ui/desktop/tests/unit/controllers/scopes/scope/projects/sessions/index-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | scopes/scope/projects/sessions/index', function(hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function(assert) { + let controller = this.owner.lookup('controller:scopes/scope/projects/sessions/index'); + assert.ok(controller); + }); +}); diff --git a/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/target/sessions-test.js b/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/target/sessions-test.js new file mode 100644 index 000000000..f869024f9 --- /dev/null +++ b/ui/desktop/tests/unit/controllers/scopes/scope/projects/targets/target/sessions-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | scopes/scope/projects/targets/target/sessions', function(hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function(assert) { + let controller = this.owner.lookup('controller:scopes/scope/projects/targets/target/sessions'); + assert.ok(controller); + }); +});