[Desktop][Bug fix]: Stop target sessions before unauthenticating user session (#2891)
* add stopAll invocation to ipc handler * trigger modal on both signout btns * refactor invalidateSession fn * fix application controller tests * fix failing tests settings-card/user * add test to Acceptance | authentication * migrate electron dialog.showMessageBoxSync to Hds * rename channel and tracked prop, consolidate cancel fns * combine modals, add willDestroy, refactor * test signout and close sessions modal * address feedback * refactor window.electron in authentication-test * address feedback * refactor: 💡 Fix failing tests --------- Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
This commit is contained in:
1 parent
f776ba0617
commit
990e2d6b9d
20 files changed
+342
-84
No files matched your search
@@ -246,6 +246,8 @@ export default function initializeMockIPC(server, config) {
|
||||
}
|
||||
pauseClientAgent() {}
|
||||
resumeClientAgent() {}
|
||||
hasRunningSessions() {}
|
||||
stopAll() {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
|
||||
<:action>
|
||||
<Hds::Button
|
||||
data-test-signout-button
|
||||
data-test-settings-signout-btn
|
||||
@text={{t 'actions.deauthenticate'}}
|
||||
@color='critical'
|
||||
@icon='sign-out'
|
||||
@size='medium'
|
||||
{{on 'click' this.invalidateSession}}
|
||||
{{on 'click' @onSignout}}
|
||||
/>
|
||||
</:action>
|
||||
</SettingsCard>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
/>
|
||||
{{/if}}
|
||||
<dd.Interactive
|
||||
data-test-sign-out
|
||||
{{on 'click' this.invalidateSession}}
|
||||
data-test-nav-signout-btn
|
||||
{{on 'click' this.showModalOrLogout}}
|
||||
>
|
||||
{{t 'actions.deauthenticate'}}
|
||||
</dd.Interactive>
|
||||
@@ -155,4 +155,40 @@
|
||||
</M.Footer>
|
||||
</Hds::Modal>
|
||||
{{/if}}
|
||||
</PendingConfirmations>
|
||||
</PendingConfirmations>
|
||||
|
||||
{{#if (or this.isLoggingOut this.isAppQuitting)}}
|
||||
<Hds::Modal @onClose={{this.cancel}} data-test-close-sessions-modal as |M|>
|
||||
{{#if this.isAppQuitting}}
|
||||
<M.Header>
|
||||
{{t 'resources.session.messages.stop-sessions-before-quit.title'}}
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
{{t 'resources.session.messages.stop-sessions-before-quit.description'}}
|
||||
</M.Body>
|
||||
{{else}}
|
||||
{{! If not rendering the isAppQuitting modal, render isLoggingOut modal }}
|
||||
<M.Header>
|
||||
{{t 'resources.session.messages.stop-sessions-and-signout.title'}}
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
{{t 'resources.session.messages.stop-sessions-and-signout.description'}}
|
||||
</M.Body>
|
||||
{{/if}}
|
||||
<M.Footer>
|
||||
<Hds::ButtonSet>
|
||||
<Hds::Button
|
||||
{{on 'click' this.confirmCloseSessions}}
|
||||
type='button'
|
||||
@text={{t 'actions.ok'}}
|
||||
/>
|
||||
<Hds::Button
|
||||
{{on 'click' this.cancel}}
|
||||
type='button'
|
||||
@text={{t 'actions.cancel'}}
|
||||
@color='secondary'
|
||||
/>
|
||||
</Hds::ButtonSet>
|
||||
</M.Footer>
|
||||
</Hds::Modal>
|
||||
{{/if}}
|
||||
@@ -3,7 +3,7 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<SettingsCard::User />
|
||||
<SettingsCard::User @onSignout={{this.application.showModalOrLogout}} />
|
||||
<SettingsCard::Server @model={{@model}} />
|
||||
<SettingsCard::Application @model={{@model}} @toggle={{this.toggleTheme}} />
|
||||
<SettingsCard::ClientAgent @model={{@model}} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,8 @@ class MockIPC {
|
||||
}
|
||||
pauseClientAgent() {}
|
||||
resumeClientAgent() {}
|
||||
hasRunningSessions() {}
|
||||
stopAll() {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`<SettingsCard::User />`);
|
||||
await render(
|
||||
hbs`<SettingsCard::User @onSignout={{this.application.showModalOrLogout}} />`,
|
||||
);
|
||||
|
||||
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`<SettingsCard::User />`);
|
||||
await render(
|
||||
hbs`<SettingsCard::User @onSignout={{this.application.showModalOrLogout}} />`,
|
||||
);
|
||||
|
||||
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`<SettingsCard::User />`);
|
||||
await render(
|
||||
hbs`<SettingsCard::User @onSignout={{this.application.showModalOrLogout}} />`,
|
||||
);
|
||||
|
||||
assert.dom(AUTHENTICATION_BADGE).hasText('Authenticated');
|
||||
assert.dom(AUTH_METHOD_TYPE).hasText('LDAP');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user