diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js index b376de313..5b34bc5b7 100644 --- a/addons/api/mirage/scenarios/ipc.js +++ b/addons/api/mirage/scenarios/ipc.js @@ -246,6 +246,8 @@ export default function initializeMockIPC(server, config) { } pauseClientAgent() {} resumeClientAgent() {} + hasRunningSessions() {} + stopAll() {} } /** diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index deee3e030..554d81b9c 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -343,6 +343,12 @@ session: connected: title: Connected description: You can now access {targetDisplayName} + stop-sessions-and-signout: + title: Sign out of Boundary? + description: Signing out will stop all active and pending target sessions. Are you sure you want to sign out? + stop-sessions-before-quit: + title: Close sessions before quitting? + description: Quitting the app will stop all active and pending sessions. Are you sure you want to quit? actions: connect: Connect end: End session diff --git a/e2e-tests/desktop/tests/scope.spec.js b/e2e-tests/desktop/tests/scope.spec.js index fad0beda5..55e2b0afe 100644 --- a/e2e-tests/desktop/tests/scope.spec.js +++ b/e2e-tests/desktop/tests/scope.spec.js @@ -54,7 +54,7 @@ test.describe('Scope tests', () => { await expect(headerNavLocator).toBeVisible(); await expect( headerNavLocator.locator('.hds-dropdown-toggle-button'), - ).toHaveText('Global'); + ).toHaveText('Global', { ignoreCase: true }); await expect( authedPage.getByRole('link', { name: targetA.name }), diff --git a/ui/desktop/app/components/session/tabs/index.js b/ui/desktop/app/components/session/tabs/index.js index 589ffe4ac..de5575f93 100644 --- a/ui/desktop/app/components/session/tabs/index.js +++ b/ui/desktop/app/components/session/tabs/index.js @@ -39,7 +39,7 @@ export default class SessionTerminalTabsComponent extends Component { id; terminal; - removeListener; + removeTerminalListener; // =actions @@ -111,9 +111,7 @@ export default class SessionTerminalTabsComponent extends Component { } window.terminal?.remove(this.id); window.onresize = null; - if (this.removeListener) { - this.removeListener(); - } + this.removeTerminalListener?.(); } #setupTerminal(fitAddon, xterm, termContainer) { @@ -122,7 +120,7 @@ export default class SessionTerminalTabsComponent extends Component { xterm.onData((data) => window.terminal.send(data, this.id)); // Save the handler to cleanup the listener on the renderer process later - this.removeListener = window.terminal.receive((event, value) => { + this.removeTerminalListener = window.terminal.receive((value) => { xterm.write(value); }, this.id); diff --git a/ui/desktop/app/components/settings-card/user/index.hbs b/ui/desktop/app/components/settings-card/user/index.hbs index 3e8fefff7..f36f1bbe6 100644 --- a/ui/desktop/app/components/settings-card/user/index.hbs +++ b/ui/desktop/app/components/settings-card/user/index.hbs @@ -36,12 +36,12 @@ <:action> \ No newline at end of file diff --git a/ui/desktop/app/components/settings-card/user/index.js b/ui/desktop/app/components/settings-card/user/index.js index c341dc245..e47a4818c 100644 --- a/ui/desktop/app/components/settings-card/user/index.js +++ b/ui/desktop/app/components/settings-card/user/index.js @@ -5,7 +5,6 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; -import { action } from '@ember/object'; export default class SettingsUserInfoComponent extends Component { // =services @@ -49,14 +48,4 @@ export default class SettingsUserInfoComponent extends Component { const formattedAuthenticatorType = authenticator.split(':')[1]; return this.intl.t(`resources.account.types.${formattedAuthenticatorType}`); } - - // =actions - - /** - * Delegates invalidation to the session service. - */ - @action - invalidateSession() { - this.session.invalidate(); - } } diff --git a/ui/desktop/app/controllers/application.js b/ui/desktop/app/controllers/application.js index 34042ec39..dab1c3eba 100644 --- a/ui/desktop/app/controllers/application.js +++ b/ui/desktop/app/controllers/application.js @@ -7,22 +7,63 @@ import Controller from '@ember/controller'; import { service } from '@ember/service'; import { getOwner } from '@ember/application'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + export default class ApplicationController extends Controller { // =services - @service ipc; - @service session; @service clusterUrl; @service flashMessages; + @service ipc; + @service session; + @service('browser/window') window; + + @tracked isLoggingOut = false; + @tracked isAppQuitting = false; + + // =attributes + removeOnAppQuitListener; + + constructor() { + super(...arguments); + // Listen for when user attempts to quit app + // Setup removeOnAppQuitListener to destroy the listener afterwards + this.removeOnAppQuitListener = this.window.electron?.onAppQuit(() => { + this.isAppQuitting = true; + }); + } // =actions /** - * Delegates invalidation to the session service. + * Stop all active/pending target sessions + * Logout or close app */ @action - invalidateSession() { - this.session.invalidate(); + async confirmCloseSessions() { + await this.ipc.invoke('stopAll'); + if (this.isAppQuitting) { + this.isAppQuitting = false; + this.close(); + } else { + // this.session.invalidate() comes from Ember Simple Auth BaseSessionService + this.session.invalidate(); + } + + this.isLoggingOut = false; + } + + /** + * Only renders the signout modal if target sessions are running + */ + @action + async showModalOrLogout() { + const hasRunningSessions = await this.ipc.invoke('hasRunningSessions'); + if (hasRunningSessions) { + this.isLoggingOut = true; + } else { + this.session.invalidate(); + } } /** @@ -32,7 +73,7 @@ export default class ApplicationController extends Controller { @action disconnect() { this.clusterUrl.resetClusterUrl(); - this.invalidateSession(); + this.session.invalidate(); } @action @@ -75,4 +116,15 @@ export default class ApplicationController extends Controller { rootEl.classList.remove('rose-theme-light'); } } + + @action + cancel() { + this.isLoggingOut = false; + this.isAppQuitting = false; + } + + willDestroy() { + super.willDestroy(...arguments); + this.removeOnAppQuitListener?.(); + } } diff --git a/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js b/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js index f6502c0e1..c48e6f7ce 100644 --- a/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js +++ b/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js @@ -9,7 +9,8 @@ import { action } from '@ember/object'; export default class ScopesScopeProjectsSettingsIndexController extends Controller { @controller('application') application; - //actions + // =actions + /** * Calls the Application controller's toggleTheme method * @param {string} theme - "light", "dark", or nullish (system default) diff --git a/ui/desktop/app/templates/application.hbs b/ui/desktop/app/templates/application.hbs index 29b525a90..549df8352 100644 --- a/ui/desktop/app/templates/application.hbs +++ b/ui/desktop/app/templates/application.hbs @@ -63,8 +63,8 @@ /> {{/if}} {{t 'actions.deauthenticate'}} @@ -155,4 +155,40 @@ {{/if}} - \ No newline at end of file + + +{{#if (or this.isLoggingOut this.isAppQuitting)}} + + {{#if this.isAppQuitting}} + + {{t 'resources.session.messages.stop-sessions-before-quit.title'}} + + + {{t 'resources.session.messages.stop-sessions-before-quit.description'}} + + {{else}} + {{! If not rendering the isAppQuitting modal, render isLoggingOut modal }} + + {{t 'resources.session.messages.stop-sessions-and-signout.title'}} + + + {{t 'resources.session.messages.stop-sessions-and-signout.description'}} + + {{/if}} + + + + + + + +{{/if}} \ No newline at end of file diff --git a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs index 5220d737d..53b9c073b 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 }} - + diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js index 7c0b939c5..28eea08af 100644 --- a/ui/desktop/electron-app/src/index.js +++ b/ui/desktop/electron-app/src/index.js @@ -13,7 +13,6 @@ const { const { session, app, - dialog, protocol, BrowserWindow, Menu, @@ -22,7 +21,6 @@ const { } = require('electron'); require('./ipc/handlers.js'); const log = require('electron-log/main'); -const fs = require('fs'); const { generateCSPHeader } = require('./config/content-security-policy.js'); const runtimeSettings = require('./services/runtime-settings.js'); @@ -40,6 +38,7 @@ const { version } = require('./cli/index.js'); const isDev = require('electron-is-dev'); // Register the custom file protocol +// This will render as the default cluster URL: serve://boundary const emberAppProtocol = 'serve'; const emberAppName = 'boundary'; const emberAppURL = `${emberAppProtocol}://${emberAppName}`; @@ -129,7 +128,7 @@ const createWindow = async (partition, closeWindowCB) => { const browserWindow = new BrowserWindow(browserWindowOptions); // If the user-specified cluster URL changes, reload the page so that - // the CSP can be refreshed with the this source allowed + // the content security policy (CSP) can be refreshed with the source allowed runtimeSettings.onClusterUrlChange( async () => await browserWindow.loadURL(emberAppURL), ); @@ -143,6 +142,8 @@ const createWindow = async (partition, closeWindowCB) => { await browserWindow.loadURL(emberAppURL); }); + // Emitted when the renderer process unexpectedly disappears. + // This is normally because it was crashed or killed. browserWindow.webContents.on('render-process-gone', () => { console.log( 'Your Ember app (or other code) in the main window has crashed.', @@ -160,7 +161,7 @@ const createWindow = async (partition, closeWindowCB) => { }); // Opens external links in the host default browser. - // We allow developer.hashicorp.com domain to open on external window + // We allow developer.hashicorp.com domain to open on an external window // and releases.hashicorp.com domain to download the desktop app or // link to the release page for the desktop app. browserWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -204,15 +205,15 @@ app.on('window-all-closed', () => { app.on('ready', async () => { // Setup a partition - // Must be prefixed with persist: in order to save things like localStorage + // Must be prefixed with "persist:" in order to save things like localStorage // data. Without this, the session is in-memory only. // https://www.electronjs.org/docs/api/session#sessionfrompartitionpartition-options const partition = `persist:${emberAppName}`; const ses = session.fromPartition(partition); // Register custom protocol - // This file protocol is the exclusive protocol for this application. All - // other protocols are disabled. + // This file protocol is the exclusive protocol for this application. + // All other protocols are disabled. ses.protocol.handle(emberAppProtocol, (request) => { /* eng-disable PROTOCOL_HANDLER_JS_CHECK */ const isDir = request.url.endsWith('/'); @@ -270,8 +271,8 @@ app.on('ready', async () => { /** * Need for Mac OS behavior of closing the window but not the app. - * This allows to reopen the window if the user clicks Boundary icon in the dock - * while the app is not close. + * This allows a user to reopen the window when clicking the Boundary icon in the dock, + * only when the app has not been closed. * More info: https://www.electronjs.org/docs/latest/tutorial/quick-start#open-a-window-if-none-are-open-macos */ app.on('activate', async () => { @@ -284,18 +285,13 @@ app.on('ready', async () => { }); /** - * Prompt for closing spawned processes + * Prompt user to confirm action before closing spawned processes + * This interaction lives in the ember app using Hds::Modal */ -app.on('before-quit', (event) => { +app.on('before-quit', async (event) => { if (sessionManager.hasRunningSessions) { - const dialogOpts = { - type: 'question', - buttons: ['Close', 'Cancel'], - detail: 'Close sessions before quitting?', - }; - - const buttonId = dialog.showMessageBoxSync(null, dialogOpts); - buttonId === 0 ? sessionManager.stopAll() : event.preventDefault(); + mainWindow.webContents.send('onAppQuit'); + event.preventDefault(); } }); @@ -312,7 +308,7 @@ app.on('quit', () => { // from the exception can cause additional unforeseen and unpredictable issues. // // Attempting to resume normally after an uncaught exception can be similar to pulling out -// of the power cord when upgrading a computer -- nine out of ten times nothing happens - +// the power cord when upgrading a computer -- nine out of ten times nothing happens - // but the 10th time, the system becomes corrupted. // // The correct use of 'uncaughtException' is to perform synchronous cleanup of allocated diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js index 2807be586..c8f203a23 100644 --- a/ui/desktop/electron-app/src/ipc/handlers.js +++ b/ui/desktop/electron-app/src/ipc/handlers.js @@ -76,10 +76,15 @@ handle('connect', ({ target_id, token, host_id }) => ); /** - * Cancel an established boundary session spawned process. + * Stop an established boundary session spawned process. */ handle('stop', ({ session_id }) => sessionManager.stopById(session_id)); +/** + * Stop all active and pending target sessions. + */ +handle('stopAll', async () => sessionManager.stopAll()); + /** * Check for OS window chrome. Enabled on MacOS only. * Window chrome is disabled regardless of platform for @@ -116,6 +121,12 @@ handle('toggleFullscreenWindow', () => { */ handle('closeWindow', () => app.quit()); +/** + * Check if session manager has running sessions + * Return boolean + */ +handle('hasRunningSessions', () => sessionManager.hasRunningSessions); + /** * Focus the window */ diff --git a/ui/desktop/electron-app/src/models/session-manager.js b/ui/desktop/electron-app/src/models/session-manager.js index b56a4284e..20399470d 100644 --- a/ui/desktop/electron-app/src/models/session-manager.js +++ b/ui/desktop/electron-app/src/models/session-manager.js @@ -39,8 +39,13 @@ class SessionManager { return session?.stop?.(); } + /** + * Stop all active and pending target sessions + * Returning Promise.all() ensures all sessions in the list have been + * stopped before calling the next fn + */ stopAll() { - this.#sessions.forEach((session) => session.stop()); + return Promise.all(this.#sessions.map((session) => session.stop())); } } diff --git a/ui/desktop/electron-app/src/preload.js b/ui/desktop/electron-app/src/preload.js index 04db76453..c87707144 100644 --- a/ui/desktop/electron-app/src/preload.js +++ b/ui/desktop/electron-app/src/preload.js @@ -22,11 +22,12 @@ contextBridge.exposeInMainWorld('terminal', { }, receive: (callback, id) => { const incomingDataChannel = `terminalIncomingData-${id}`; - ipcRenderer.on(incomingDataChannel, callback); + const listenerCallback = (_event, value) => callback(value); + ipcRenderer.on(incomingDataChannel, listenerCallback); // Return a function for the caller to handle cleaning up the listener return () => { - return ipcRenderer.removeListener(incomingDataChannel, callback); + return ipcRenderer.removeListener(incomingDataChannel, listenerCallback); }; }, create: (vars) => { @@ -61,3 +62,18 @@ process.once('loaded', () => { } }); }); + +/** + * Listener on electron app when user triggers before-quit event + */ +contextBridge.exposeInMainWorld('electron', { + onAppQuit: (callback) => { + // Don't pass in callback directly so users can't access passed in event for security + const listenerCallback = () => callback(); + ipcRenderer.on('onAppQuit', listenerCallback); + + return () => { + return ipcRenderer.removeListener('onAppQuit', listenerCallback); + }; + }, +}); diff --git a/ui/desktop/tests/acceptance/authentication-test.js b/ui/desktop/tests/acceptance/authentication-test.js index fbc5e0a44..afd2bb75f 100644 --- a/ui/desktop/tests/acceptance/authentication-test.js +++ b/ui/desktop/tests/acceptance/authentication-test.js @@ -4,7 +4,13 @@ */ import { module, test } from 'qunit'; -import { visit, currentURL, fillIn, click, find } from '@ember/test-helpers'; +import { + visit, + currentURL, + fillIn, + click, + triggerEvent, +} 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 +21,8 @@ import { invalidateSession, } from 'ember-simple-auth/test-support'; import WindowMockIPC from '../helpers/window-mock-ipc'; +import Service from '@ember/service'; +import sinon from 'sinon'; module('Acceptance | authentication', function (hooks) { setupApplicationTest(hooks); @@ -58,6 +66,13 @@ module('Acceptance | authentication', function (hooks) { sessions: null, }; + const SIGNOUT_BTN = '[data-test-nav-signout-btn]'; + const MODAL_CLOSE_SESSIONS = '[data-test-close-sessions-modal]'; + const MODAL_CONFIRM_BTN = '.hds-modal__footer .hds-button--color-primary'; + const MODAL_CANCEL_BTN = '.hds-modal__footer .hds-button--color-secondary'; + const HEADER_DROPDOWN_BTN = + '.rose-header-utilities .header-dropdown-button-override button'; + const setDefaultClusterUrl = (test) => { const windowOrigin = window.location.origin; const clusterUrl = test.owner.lookup('service:clusterUrl'); @@ -71,6 +86,9 @@ module('Acceptance | authentication', function (hooks) { await invalidateSession(); + const ipcService = this.owner.lookup('service:ipc'); + this.ipcStub = sinon.stub(ipcService, 'invoke'); + // create scopes instances.scopes.global = this.server.create('scope', { id: 'global' }); stubs.global = { id: 'global', type: 'global' }; @@ -128,6 +146,10 @@ module('Acceptance | authentication', function (hooks) { setDefaultClusterUrl(this); }); + hooks.afterEach(async function () { + sinon.restore(); + }); + test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) { assert.expect(2); await visit(urls.index); @@ -183,16 +205,11 @@ module('Acceptance | authentication', function (hooks) { assert.ok(currentSession().isAuthenticated); - await click( - '.rose-header-utilities .header-dropdown-button-override button', - ); + await click(HEADER_DROPDOWN_BTN); - assert.strictEqual( - find('[data-test-sign-out]').textContent.trim(), - 'Sign Out', - ); + assert.dom(SIGNOUT_BTN).includesText('Sign Out'); - await click('[data-test-sign-out]'); + await click(SIGNOUT_BTN); assert.notOk(currentSession().isAuthenticated); }); @@ -235,4 +252,95 @@ module('Acceptance | authentication', function (hooks) { assert.dom('[data-test-no-auth-methods]').includesText('No Auth Methods'); assert.dom('.change-origin').exists(); }); + + test('signing out with running sessions renders signout modal', async function (assert) { + this.ipcStub.withArgs('hasRunningSessions').returns(true); + + await visit(urls.authenticate.methods.global); + + await authenticateSession({ username: 'test' }); + + assert.ok(currentSession().isAuthenticated); + + await click(HEADER_DROPDOWN_BTN); + + await click(SIGNOUT_BTN); + + assert.dom(MODAL_CLOSE_SESSIONS).isVisible(); + assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?'); + + await click(MODAL_CANCEL_BTN); + + assert.dom(MODAL_CLOSE_SESSIONS).isNotVisible(); + assert.ok(currentSession().isAuthenticated); + }); + + test('confirming signout via modal stops sessions and logs out user', async function (assert) { + const stopAllSessions = this.ipcStub.withArgs('stopAll'); + this.ipcStub.withArgs('hasRunningSessions').returns(true); + + await visit(urls.authenticate.methods.global); + + await authenticateSession({ username: 'test' }); + + assert.ok(currentSession().isAuthenticated); + + await click(HEADER_DROPDOWN_BTN); + + await click(SIGNOUT_BTN); + + assert.dom(MODAL_CLOSE_SESSIONS).isVisible(); + assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?'); + + await click(MODAL_CONFIRM_BTN); + + assert.dom(MODAL_CLOSE_SESSIONS).isNotVisible(); + assert.ok(stopAllSessions.calledOnce); + assert.notOk(currentSession().isAuthenticated); + }); + + test('attempting to quit app when signout modal is present triggers the close sessions modal', async function (assert) { + // We need to encapsulate the event listener inside a mocked window service to ensure + // the entire event is torn down (including the mocked window), since "window" exists + // globally across all tests, and we don't want tests impacting one another + const mockElectronEvent = class WindowElectronMock extends Service { + electron = { + onAppQuit: (callback) => { + window.addEventListener('onAppQuit', callback); + return () => { + window.removeEventListener('onAppQuit', callback); + }; + }, + }; + }; + + this.owner.register('service:browser/window', mockElectronEvent); + const stopAllSessions = this.ipcStub.withArgs('stopAll'); + const quitApp = this.ipcStub.withArgs('closeWindow'); + + this.ipcStub.withArgs('hasRunningSessions').returns(true); + + await visit(urls.authenticate.methods.global); + + await authenticateSession({ username: 'test' }); + + assert.ok(currentSession().isAuthenticated); + + await click(HEADER_DROPDOWN_BTN); + + await click(SIGNOUT_BTN); + + assert.dom(MODAL_CLOSE_SESSIONS).isVisible(); + assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?'); + + await triggerEvent(window, 'onAppQuit'); + + assert + .dom(MODAL_CLOSE_SESSIONS) + .includesText('Close sessions before quitting?'); + + await click(MODAL_CONFIRM_BTN); + assert.ok(stopAllSessions.calledOnce); + assert.ok(quitApp.calledOnce); + }); }); diff --git a/ui/desktop/tests/acceptance/projects/settings/index-test.js b/ui/desktop/tests/acceptance/projects/settings/index-test.js index f99257e2e..6e1eeb341 100644 --- a/ui/desktop/tests/acceptance/projects/settings/index-test.js +++ b/ui/desktop/tests/acceptance/projects/settings/index-test.js @@ -20,8 +20,6 @@ import { import WindowMockIPC from '../../../helpers/window-mock-ipc'; import setupStubs from 'api/test-support/handlers/cache-daemon-search'; -const SIGNOUT_SELECTOR = '[data-test-signout-button]'; - module('Acceptance | projects | settings | index', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); @@ -57,6 +55,10 @@ module('Acceptance | projects | settings | index', function (hooks) { settings: null, }; + const SIGNOUT_BTN = '[data-test-settings-signout-btn]'; + const MODAL_CLOSE_SESSIONS = '[data-test-close-sessions-modal]'; + const MODAL_CONFIRM_BTN = '.hds-modal__footer .hds-button--color-primary'; + const setDefaultClusterUrl = (test) => { const windowOrigin = window.location.origin; const clusterUrl = test.owner.lookup('service:clusterUrl'); @@ -65,17 +67,20 @@ module('Acceptance | projects | settings | index', function (hooks) { hooks.beforeEach(async function () { await authenticateSession(); + // Generate scopes instances.scopes.global = this.server.create('scope', { id: 'global', name: 'Global', }); const globalScope = { id: 'global', type: 'global' }; + instances.scopes.org = this.server.create('scope', { type: 'org', scope: globalScope, }); const orgScope = { id: instances.scopes.org.id, type: 'org' }; + instances.scopes.project = this.server.create('scope', { type: 'project', scope: orgScope, @@ -83,7 +88,6 @@ module('Acceptance | projects | settings | index', function (hooks) { urls.scopes.org = `/scopes/${instances.scopes.org.id}`; urls.scopes.global = `/scopes/${instances.scopes.global.id}`; urls.projects = `${urls.scopes.org}/projects`; - urls.settings = `${urls.projects}/settings`; this.owner.register('service:browser/window', WindowMockIPC); @@ -113,16 +117,19 @@ module('Acceptance | projects | settings | index', function (hooks) { assert.notOk(currentSession().get('data.theme')); assert.notOk(getRootElement().classList.contains('rose-theme-light')); assert.notOk(getRootElement().classList.contains('rose-theme-dark')); + // toggle light mode await select('[name="theme"]', 'light'); assert.strictEqual(currentSession().get('data.theme'), 'light'); assert.ok(getRootElement().classList.contains('rose-theme-light')); assert.notOk(getRootElement().classList.contains('rose-theme-dark')); + // toggle dark mode await select('[name="theme"]', 'dark'); assert.strictEqual(currentSession().get('data.theme'), 'dark'); assert.notOk(getRootElement().classList.contains('rose-theme-light')); assert.ok(getRootElement().classList.contains('rose-theme-dark')); + // toggle system default await select('[name="theme"]', 'system-default-theme'); assert.strictEqual( @@ -133,12 +140,37 @@ module('Acceptance | projects | settings | index', function (hooks) { assert.notOk(getRootElement().classList.contains('rose-theme-dark')); }); - test('clicking sign-out button logs out the user', async function (assert) { - await authenticateSession({ username: 'testuser' }); + test('clicking signout button logs out the user', async function (assert) { assert.expect(2); - await visit(urls.settings); + + await authenticateSession({ username: 'testuser' }); assert.ok(currentSession().isAuthenticated); - await click(SIGNOUT_SELECTOR); + + await visit(urls.settings); + + await click(SIGNOUT_BTN); + + assert.notOk(currentSession().isAuthenticated); + }); + + test('confirming signout with running sessions stops sessions and logs out user', async function (assert) { + const stopAllSessions = this.ipcStub.withArgs('stopAll'); + this.ipcStub.withArgs('hasRunningSessions').returns(true); + + await authenticateSession({ username: 'testuser' }); + assert.ok(currentSession().isAuthenticated); + + await visit(urls.settings); + + await click(SIGNOUT_BTN); + + assert.dom(MODAL_CLOSE_SESSIONS).isVisible(); + assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?'); + + await click(MODAL_CONFIRM_BTN); + + assert.dom(MODAL_CLOSE_SESSIONS).isNotVisible(); + assert.ok(stopAllSessions.calledOnce); assert.notOk(currentSession().isAuthenticated); }); }); diff --git a/ui/desktop/tests/helpers/window-mock-ipc.js b/ui/desktop/tests/helpers/window-mock-ipc.js index d48fef131..3e0ec24d7 100644 --- a/ui/desktop/tests/helpers/window-mock-ipc.js +++ b/ui/desktop/tests/helpers/window-mock-ipc.js @@ -55,6 +55,8 @@ class MockIPC { } pauseClientAgent() {} resumeClientAgent() {} + hasRunningSessions() {} + stopAll() {} } /** diff --git a/ui/desktop/tests/integration/components/settings-card/user-test.js b/ui/desktop/tests/integration/components/settings-card/user-test.js index 91bd82cd4..5df7779a9 100644 --- a/ui/desktop/tests/integration/components/settings-card/user-test.js +++ b/ui/desktop/tests/integration/components/settings-card/user-test.js @@ -25,6 +25,10 @@ module('Integration | Component | settings-card/user', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks, 'en-us'); + hooks.beforeEach(function () { + this.set('application', { showModalOrLogout: () => {} }); + }); + test('it renders password correctly', async function (assert) { this.owner.register( 'service:session', @@ -34,7 +38,9 @@ module('Integration | Component | settings-card/user', function (hooks) { }, ); - await render(hbs``); + await render( + hbs``, + ); assert.dom(AUTHENTICATION_BADGE).hasText('Authenticated'); assert.dom(AUTH_METHOD_TYPE).hasText('Password'); @@ -52,7 +58,9 @@ module('Integration | Component | settings-card/user', function (hooks) { }, ); - await render(hbs``); + await render( + hbs``, + ); assert.dom(AUTHENTICATION_BADGE).hasText('Authenticated'); assert.dom(AUTH_METHOD_TYPE).hasText('OIDC'); @@ -70,7 +78,9 @@ module('Integration | Component | settings-card/user', function (hooks) { }, ); - await render(hbs``); + await render( + hbs``, + ); assert.dom(AUTHENTICATION_BADGE).hasText('Authenticated'); assert.dom(AUTH_METHOD_TYPE).hasText('LDAP'); diff --git a/ui/desktop/tests/unit/controllers/application-test.js b/ui/desktop/tests/unit/controllers/application-test.js index 7f4b66360..90b985500 100644 --- a/ui/desktop/tests/unit/controllers/application-test.js +++ b/ui/desktop/tests/unit/controllers/application-test.js @@ -12,9 +12,9 @@ module('Unit | Controller | application', function (hooks) { setupTest(hooks); let controller; - let session; let clusterUrl; let mockIPC; + let session; const setupMockIpc = (test) => { test.owner.register('service:browser/window', WindowMockIPC); @@ -23,10 +23,10 @@ module('Unit | Controller | application', function (hooks) { hooks.beforeEach(async function () { await authenticateSession({}); + setupMockIpc(this); controller = this.owner.lookup('controller:application'); session = this.owner.lookup('service:session'); clusterUrl = this.owner.lookup('service:cluster-url'); - setupMockIpc(this); }); test('it exists', function (assert) { @@ -34,14 +34,8 @@ module('Unit | Controller | application', function (hooks) { assert.ok(controller.minimize); assert.ok(controller.toggleFullScreen); assert.ok(controller.close); - }); - - test('invalidateSession action de-authenticates a user', async function (assert) { - assert.true(session.isAuthenticated); - - await controller.invalidateSession(); - - assert.false(session.isAuthenticated); + assert.ok(controller.confirmCloseSessions); + assert.ok(controller.showModalOrLogout); }); test('toggleTheme action sets theme to specified value', function (assert) { 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 index 5650f86c9..c398245f5 100644 --- 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 @@ -132,7 +132,7 @@ module( assert.deepEqual(controller.availableSessions, selectedItems); }); - test('cancelSession action action updates session status', async function (assert) { + test('cancelSession action updates session status', async function (assert) { await visit(urls.sessions); const session = await store.findRecord('session', instances.session.id);