/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ /* eslint-disable qunit/require-expect */ import { currentURL, find, findAll, visit, click } from '@ember/test-helpers'; import { module, skip, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; import Tokens from 'nomad-ui/tests/pages/settings/tokens'; import Jobs from 'nomad-ui/tests/pages/jobs/list'; import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; import ClientDetail from 'nomad-ui/tests/pages/clients/detail'; import Layout from 'nomad-ui/tests/pages/layout'; import percySnapshot from '@percy/ember'; import faker from 'nomad-ui/mirage/faker'; import moment from 'moment'; import { run } from '@ember/runloop'; import { allScenarios } from '../../mirage/scenarios/default'; import { selectChoose, clickTrigger, } from 'ember-power-select/test-support/helpers'; let job; let node; let managementToken; let clientToken; module('Acceptance | tokens', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { window.localStorage.clear(); window.sessionStorage.clear(); faker.seed(1); server.create('agent'); server.create('node-pool'); node = server.create('node'); job = server.create('job'); managementToken = server.create('token'); clientToken = server.create('token'); }); test('it passes an accessibility audit', async function (assert) { assert.expect(1); await Tokens.visit(); await a11yAudit(assert); }); test('the token form sets the token in local storage', async function (assert) { const { secretId } = managementToken; await Tokens.visit(); assert.equal( window.localStorage.nomadTokenSecret, null, 'No token secret set' ); assert.ok(document.title.includes('Authorization')); await Tokens.secret(secretId).submit(); assert.equal( window.localStorage.nomadTokenSecret, secretId, 'Token secret was set' ); }); // TODO: unskip once store.unloadAll reliably waits for in-flight requests to settle skip('the x-nomad-token header gets sent with requests once it is set', async function (assert) { const { secretId } = managementToken; await JobDetail.visit({ id: job.id }); await ClientDetail.visit({ id: node.id }); assert.ok( server.pretender.handledRequests.length > 1, 'Requests have been made' ); server.pretender.handledRequests.forEach((req) => { assert.notOk(getHeader(req, 'x-nomad-token'), `No token for ${req.url}`); }); const requestPosition = server.pretender.handledRequests.length; await Tokens.visit(); await Tokens.secret(secretId).submit(); await JobDetail.visit({ id: job.id }); await ClientDetail.visit({ id: node.id }); const newRequests = server.pretender.handledRequests.slice(requestPosition); assert.ok(newRequests.length > 1, 'New requests have been made'); // Cross-origin requests can't have a token newRequests.forEach((req) => { assert.equal( getHeader(req, 'x-nomad-token'), secretId, `Token set for ${req.url}` ); }); }); test('an error message is shown when authenticating a token fails', async function (assert) { const { secretId } = managementToken; const bogusSecret = 'this-is-not-the-secret'; assert.notEqual( secretId, bogusSecret, 'bogus secret is not somehow coincidentally equal to the real secret' ); await Tokens.visit(); await Tokens.secret(bogusSecret).submit(); assert.equal( window.localStorage.nomadTokenSecret, null, 'Token secret is discarded on failure' ); assert.ok(Tokens.errorMessage, 'Token error message is shown'); assert.notOk(Tokens.successMessage, 'Token success message is not shown'); assert.equal(Tokens.policies.length, 0, 'No token policies are shown'); }); test('a success message and a special management token message are shown when authenticating succeeds', async function (assert) { const { secretId } = managementToken; await Tokens.visit(); await Tokens.secret(secretId).submit(); await percySnapshot(assert); assert.ok(Tokens.successMessage, 'Token success message is shown'); assert.notOk(Tokens.errorMessage, 'Token error message is not shown'); assert.ok(Tokens.managementMessage, 'Token management message is shown'); assert.equal(Tokens.policies.length, 0, 'No token policies are shown'); }); test('a success message and associated policies are shown when authenticating succeeds', async function (assert) { const { secretId } = clientToken; const policy = clientToken.policies.models[0]; policy.update('description', 'Make sure there is a description'); await Tokens.visit(); await Tokens.secret(secretId).submit(); assert.ok(Tokens.successMessage, 'Token success message is shown'); assert.notOk(Tokens.errorMessage, 'Token error message is not shown'); assert.notOk( Tokens.managementMessage, 'Token management message is not shown' ); assert.equal( Tokens.policies.length, clientToken.policies.length, 'Each policy associated with the token is listed' ); const policyElement = Tokens.policies.objectAt(0); assert.equal(policyElement.name, policy.name, 'Policy Name'); assert.equal( policyElement.description, policy.description, 'Policy Description' ); assert.equal(policyElement.rules, policy.rules, 'Policy Rules'); }); test('setting a token clears the store', async function (assert) { const { secretId } = clientToken; await Jobs.visit(); assert.ok(find('.job-row'), 'Jobs found'); await Tokens.visit(); await Tokens.secret(secretId).submit(); server.pretender.get('/v1/jobs', function () { return [200, {}, '[]']; }); await Jobs.visit(); // If jobs are lingering in the store, they would show up assert.notOk(find('[data-test-job-row]'), 'No jobs found'); }); test('it handles expiring tokens', async function (assert) { // Soon-expiring token const expiringToken = server.create('token', { name: "Time's a-tickin", expirationTime: moment().add(1, 'm').toDate(), }); await Tokens.visit(); // Token with no TTL await Tokens.secret(clientToken.secretId).submit(); assert .dom('[data-test-token-expiry]') .doesNotExist('No expiry shown for regular token'); await Tokens.clear(); // https://ember-concurrency.com/docs/testing-debugging/ setTimeout(() => run.cancelTimers(), 500); // Token with TTL await Tokens.secret(expiringToken.secretId).submit(); assert .dom('[data-test-token-expiry]') .exists('Expiry shown for TTL-having token'); // TTL Action await Jobs.visit(); assert .dom('.flash-message.alert-warning button') .exists('A global alert exists and has a clickable button'); await click('.flash-message.alert-warning button'); assert.equal( currentURL(), '/settings/tokens', 'Redirected to tokens page on notification action' ); }); test('it handles expired tokens', async function (assert) { const expiredToken = server.create('token', { name: 'Well past due', expirationTime: moment().add(-5, 'm').toDate(), }); // GC'd or non-existent token, from localStorage or otherwise window.localStorage.nomadTokenSecret = expiredToken.secretId; await Tokens.visit(); assert .dom('[data-test-token-expired]') .exists('Warning banner shown for expired token'); }); test('it forces redirect on an expired token', async function (assert) { const expiredToken = server.create('token', { name: 'Well past due', expirationTime: moment().add(-5, 'm').toDate(), }); window.localStorage.nomadTokenSecret = expiredToken.secretId; const expiredServerError = { errors: [ { detail: 'ACL token expired', }, ], }; server.pretender.get('/v1/jobs', function () { return [500, {}, JSON.stringify(expiredServerError)]; }); await Jobs.visit(); assert.equal( currentURL(), '/settings/tokens', 'Redirected to tokens page due to an expired token' ); }); test('it forces redirect on a not-found token', async function (assert) { const longDeadToken = server.create('token', { name: 'dead and gone', expirationTime: moment().add(-5, 'h').toDate(), }); window.localStorage.nomadTokenSecret = longDeadToken.secretId; const notFoundServerError = { errors: [ { detail: 'ACL token not found', }, ], }; server.pretender.get('/v1/jobs', function () { return [500, {}, JSON.stringify(notFoundServerError)]; }); await Jobs.visit(); assert.equal( currentURL(), '/settings/tokens', 'Redirected to tokens page due to a token not being found' ); }); test('it notifies you when your token has 10 minutes remaining', async function (assert) { let notificationRendered = assert.async(); let notificationNotRendered = assert.async(); window.localStorage.clear(); assert.equal( window.localStorage.nomadTokenSecret, null, 'No token secret set' ); assert.timeout(6000); const nearlyExpiringToken = server.create('token', { name: 'Not quite dead yet', expirationTime: moment().add(10, 'm').add(5, 's').toDate(), }); await Tokens.visit(); // Ember Concurrency makes testing iterations convoluted: https://ember-concurrency.com/docs/testing-debugging/ // Waiting for half a second to validate that there's no warning; // then a further 5 seconds to validate that there is a warning, and to explicitly cancelAllTimers(), // short-circuiting our Ember Concurrency loop. setTimeout(() => { assert .dom('.flash-message.alert-warning') .doesNotExist('No notification yet for a token with 10m5s left'); notificationNotRendered(); setTimeout(async () => { await percySnapshot(assert, { percyCSS: '[data-test-expiration-timestamp] { display: none; }', }); assert .dom('.flash-message.alert-warning') .exists('Notification is rendered at the 10m mark'); notificationRendered(); run.cancelTimers(); }, 5000); }, 500); await Tokens.secret(nearlyExpiringToken.secretId).submit(); }); test('when the ott query parameter is present upon application load it’s exchanged for a token', async function (assert) { const { oneTimeSecret, secretId } = managementToken; await JobDetail.visit({ id: job.id, ott: oneTimeSecret }); assert.notOk( currentURL().includes(oneTimeSecret), 'OTT is cleared from the URL after loading' ); await Tokens.visit(); assert.equal( window.localStorage.nomadTokenSecret, secretId, 'Token secret was set' ); }); test('SSO Sign-in flow: Manager', async function (assert) { server.create('auth-method', { name: 'vault' }); server.create('auth-method', { name: 'cognito' }); server.create('token', { name: 'Thelonious' }); await Tokens.visit(); assert.dom('[data-test-auth-method]').exists({ count: 2 }); await click('button[data-test-auth-method]'); assert.ok(currentURL().startsWith('/oidc-mock')); let managerButton = [...findAll('button')].filter((btn) => btn.textContent.includes('Sign In as Manager') )[0]; assert.dom(managerButton).exists(); await click(managerButton); await percySnapshot(assert); assert.ok(currentURL().startsWith('/settings/tokens')); assert.dom('[data-test-token-name]').includesText('Token: Manager'); }); test('SSO Sign-in flow: Regular User', async function (assert) { server.create('auth-method', { name: 'vault' }); server.create('token', { name: 'Thelonious' }); await Tokens.visit(); assert.dom('[data-test-auth-method]').exists({ count: 1 }); await click('button[data-test-auth-method]'); assert.ok(currentURL().startsWith('/oidc-mock')); let newTokenButton = [...findAll('button')].filter((btn) => btn.textContent.includes('Sign In as Thelonious') )[0]; assert.dom(newTokenButton).exists(); await click(newTokenButton); assert.ok(currentURL().startsWith('/settings/tokens')); assert.dom('[data-test-token-name]').includesText('Token: Thelonious'); }); test('It shows an error on failed SSO', async function (assert) { server.create('auth-method', { name: 'vault' }); await visit('/settings/tokens?state=failure'); assert.ok(Tokens.ssoErrorMessage); await Tokens.clearSSOError(); assert.equal(currentURL(), '/settings/tokens', 'State query param cleared'); assert.notOk(Tokens.ssoErrorMessage); await click('button[data-test-auth-method]'); assert.ok(currentURL().startsWith('/oidc-mock')); let failureButton = find('.button.error'); assert.dom(failureButton).exists(); await click(failureButton); assert.equal( currentURL(), '/settings/tokens?state=failure', 'Redirected with failure state' ); await percySnapshot(assert); assert.ok(Tokens.ssoErrorMessage); }); test('JWT Sign-in flow: OIDC methods only', async function (assert) { server.create('auth-method', { name: 'Vault', type: 'OIDC' }); server.create('auth-method', { name: 'Auth0', type: 'OIDC' }); await Tokens.visit(); assert .dom('[data-test-auth-method]') .exists({ count: 2 }, 'Both OIDC methods shown'); assert .dom('label[for="token-input"]') .hasText( 'Secret ID', 'Secret ID input shown without JWT info when no such method exists' ); }); test('JWT Sign-in flow: JWT method', async function (assert) { server.create('auth-method', { name: 'Vault', type: 'OIDC' }); server.create('auth-method', { name: 'Auth0', type: 'OIDC' }); server.create('auth-method', { name: 'JWT-Local', type: 'JWT' }); await Tokens.visit(); assert .dom('[data-test-auth-method]') .exists( { count: 2 }, 'The newly added JWT method does not add a 3rd Auth Method button' ); assert .dom('label[for="token-input"]') .hasText('Secret ID or JWT', 'Secret ID input now shows JWT info'); // Expect to be signed in as a manager await Tokens.secret( 'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.management' ).submit(); assert.ok(currentURL().startsWith('/settings/tokens')); assert.dom('[data-test-token-name]').includesText('Token: Manager'); await Tokens.clear(); // Expect to be signed in as a client await Tokens.secret( 'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.whateverlol' ).submit(); assert.ok(currentURL().startsWith('/settings/tokens')); assert.dom('[data-test-token-name]').includesText( `Token: ${ server.db.tokens.filter((token) => { return token.type === 'client'; })[0].name }` ); await Tokens.clear(); // Expect to an error on bad JWT await Tokens.secret( 'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bad' ).submit(); assert.ok(currentURL().startsWith('/settings/tokens')); assert.dom('[data-test-token-error]').exists(); }); test('JWT Sign-in flow: JWT Method Selector, Single JWT', async function (assert) { server.create('auth-method', { name: 'Vault', type: 'OIDC' }); server.create('auth-method', { name: 'Auth0', type: 'OIDC' }); server.create('auth-method', { name: 'JWT-Local', type: 'JWT' }); await Tokens.visit(); assert .dom('[data-test-token-submit]') .exists( { count: 1 }, 'Submit token/JWT button exists with only a single JWT ' ); assert .dom('[data-test-token-submit]') .hasText( 'Sign in with secret', 'Submit token/JWT button has correct text with only a single JWT ' ); await Tokens.secret('very-short-secret'); assert .dom('[data-test-token-submit]') .hasText( 'Sign in with secret', 'A short secret still shows the "secret" verbiage on the button' ); await Tokens.secret( 'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.whateverlol' ); assert .dom('[data-test-token-submit]') .hasText( 'Sign in with JWT', 'A JWT-shaped secret will change button text to reflect JWT sign-in' ); assert .dom('[data-test-select-jwt]') .doesNotExist('No JWT selector shown with only a single method'); }); test('JWT Sign-in flow: JWT Method Selector, Multiple JWT', async function (assert) { server.create('auth-method', { name: 'Vault', type: 'OIDC' }); server.create('auth-method', { name: 'Auth0', type: 'OIDC' }); server.create('auth-method', { name: 'JWT-Local', type: 'JWT', default: false, }); server.create('auth-method', { name: 'JWT-Regional', type: 'JWT', default: false, }); server.create('auth-method', { name: 'JWT-Global', type: 'JWT', default: true, }); await Tokens.visit(); assert .dom('[data-test-token-submit]') .exists( { count: 1 }, 'Submit token/JWT button exists with only a single JWT ' ); assert .dom('[data-test-select-jwt]') .doesNotExist('No JWT selector shown with an empty token/secret'); await Tokens.secret( 'aaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.whateverlol' ); assert .dom('[data-test-select-jwt]') .exists({ count: 1 }, 'JWT selector shown with multiple JWT methods'); assert.equal( currentURL(), '/settings/tokens?jwtAuthMethod=JWT-Global', 'Default JWT method is selected' ); await clickTrigger('[data-test-select-jwt]'); assert.dom('.dropdown-options').exists('Dropdown options are shown'); await selectChoose('[data-test-select-jwt]', 'JWT-Regional'); console.log(currentURL()); assert.equal( currentURL(), '/settings/tokens?jwtAuthMethod=JWT-Regional', 'Selected JWT method is shown' ); }); test('when the ott exchange fails an error is shown', async function (assert) { await visit('/?ott=fake'); assert.ok(Layout.error.isPresent); assert.equal(Layout.error.title, 'Token Exchange Error'); assert.equal( Layout.error.message, 'Failed to exchange the one-time token.' ); }); test('Tokens are shown on the policies index page', async function (assert) { allScenarios.policiesTestCluster(server); // Create an expired token server.create('token', { name: 'Expired Token', id: 'just-expired', policyIds: [server.db.policies[0].name], expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago }); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; await visit('/policies'); assert.dom('[data-test-policy-token-count]').exists(); const expectedFirstPolicyTokens = server.db.tokens.filter((token) => { return token.policyIds.includes(server.db.policies[0].name); }); assert .dom('[data-test-policy-total-tokens]') .hasText(expectedFirstPolicyTokens.length.toString()); assert.dom('[data-test-policy-expired-tokens]').hasText('(1 expired)'); window.localStorage.nomadTokenSecret = null; }); test('Tokens are shown on a policy page', async function (assert) { allScenarios.policiesTestCluster(server); // Create an expired token server.create('token', { name: 'Expired Token', id: 'just-expired', policyIds: [server.db.policies[0].name], expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago }); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; await visit('/policies'); await click('[data-test-policy-row]:first-child'); assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`); const expectedFirstPolicyTokens = server.db.tokens.filter((token) => { return token.policyIds.includes(server.db.policies[0].name); }); assert .dom('[data-test-policy-token-row]') .exists( { count: expectedFirstPolicyTokens.length }, 'Expected number of tokens are shown' ); assert.dom('[data-test-token-expiration-time]').hasText('10 minutes ago'); window.localStorage.nomadTokenSecret = null; }); test('Tokens Deletion', async function (assert) { allScenarios.policiesTestCluster(server); const testPolicy = server.db.policies[0]; const existingTokens = server.db.tokens.filter((t) => t.policyIds.includes(testPolicy.name) ); // Create an expired token server.create('token', { name: 'Doomed Token', id: 'enjoying-my-day-here', policyIds: [testPolicy.name], }); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; await visit('/policies'); await click('[data-test-policy-row]:first-child'); assert.equal(currentURL(), `/policies/${testPolicy.name}`); assert .dom('[data-test-policy-token-row]') .exists( { count: existingTokens.length + 1 }, 'Expected number of tokens are shown' ); const doomedTokenRow = [...findAll('[data-test-policy-token-row]')].find( (a) => a.textContent.includes('Doomed Token') ); assert.dom(doomedTokenRow).exists(); await click(doomedTokenRow.querySelector('button')); assert .dom(doomedTokenRow.querySelector('[data-test-confirm-button]')) .exists(); await click(doomedTokenRow.querySelector('[data-test-confirm-button]')); assert.dom('.flash-message.alert-success').exists(); assert .dom('[data-test-policy-token-row]') .exists( { count: existingTokens.length }, 'One fewer token after deletion' ); await percySnapshot(assert); window.localStorage.nomadTokenSecret = null; }); test('Test Token Creation', async function (assert) { allScenarios.policiesTestCluster(server); const testPolicy = server.db.policies[0]; const existingTokens = server.db.tokens.filter((t) => t.policyIds.includes(testPolicy.name) ); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; await visit('/policies'); await click('[data-test-policy-row]:first-child'); assert.equal(currentURL(), `/policies/${testPolicy.name}`); assert .dom('[data-test-policy-token-row]') .exists( { count: existingTokens.length }, 'Expected number of tokens are shown' ); await click('[data-test-create-test-token]'); assert.dom('.flash-message.alert-success').exists(); assert .dom('[data-test-policy-token-row]') .exists( { count: existingTokens.length + 1 }, 'One more token after test token creation' ); assert .dom('[data-test-policy-token-row]:last-child [data-test-token-name]') .hasText(`Example Token for ${testPolicy.name}`); await percySnapshot(assert); window.localStorage.nomadTokenSecret = null; }); function getHeader({ requestHeaders }, name) { // Headers are case-insensitive, but object property look up is not return ( requestHeaders[name] || requestHeaders[name.toLowerCase()] || requestHeaders[name.toUpperCase()] ); } });