github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/tests/acceptance/token-test.js (about) 1 /* eslint-disable qunit/require-expect */ 2 import { currentURL, find, findAll, visit, click } from '@ember/test-helpers'; 3 import { module, skip, test } from 'qunit'; 4 import { setupApplicationTest } from 'ember-qunit'; 5 import { setupMirage } from 'ember-cli-mirage/test-support'; 6 import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; 7 import Tokens from 'nomad-ui/tests/pages/settings/tokens'; 8 import Jobs from 'nomad-ui/tests/pages/jobs/list'; 9 import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; 10 import ClientDetail from 'nomad-ui/tests/pages/clients/detail'; 11 import Layout from 'nomad-ui/tests/pages/layout'; 12 import percySnapshot from '@percy/ember'; 13 import faker from 'nomad-ui/mirage/faker'; 14 import moment from 'moment'; 15 import { run } from '@ember/runloop'; 16 17 let job; 18 let node; 19 let managementToken; 20 let clientToken; 21 module('Acceptance | tokens', function (hooks) { 22 setupApplicationTest(hooks); 23 setupMirage(hooks); 24 25 hooks.beforeEach(function () { 26 window.localStorage.clear(); 27 window.sessionStorage.clear(); 28 faker.seed(1); 29 30 server.create('agent'); 31 node = server.create('node'); 32 job = server.create('job'); 33 managementToken = server.create('token'); 34 clientToken = server.create('token'); 35 }); 36 37 test('it passes an accessibility audit', async function (assert) { 38 assert.expect(1); 39 40 await Tokens.visit(); 41 await a11yAudit(assert); 42 }); 43 44 test('the token form sets the token in local storage', async function (assert) { 45 const { secretId } = managementToken; 46 47 await Tokens.visit(); 48 assert.equal( 49 window.localStorage.nomadTokenSecret, 50 null, 51 'No token secret set' 52 ); 53 assert.equal(document.title, 'Authorization - Nomad'); 54 55 await Tokens.secret(secretId).submit(); 56 assert.equal( 57 window.localStorage.nomadTokenSecret, 58 secretId, 59 'Token secret was set' 60 ); 61 }); 62 63 // TODO: unskip once store.unloadAll reliably waits for in-flight requests to settle 64 skip('the x-nomad-token header gets sent with requests once it is set', async function (assert) { 65 const { secretId } = managementToken; 66 67 await JobDetail.visit({ id: job.id }); 68 await ClientDetail.visit({ id: node.id }); 69 70 assert.ok( 71 server.pretender.handledRequests.length > 1, 72 'Requests have been made' 73 ); 74 75 server.pretender.handledRequests.forEach((req) => { 76 assert.notOk(getHeader(req, 'x-nomad-token'), `No token for ${req.url}`); 77 }); 78 79 const requestPosition = server.pretender.handledRequests.length; 80 81 await Tokens.visit(); 82 await Tokens.secret(secretId).submit(); 83 84 await JobDetail.visit({ id: job.id }); 85 await ClientDetail.visit({ id: node.id }); 86 87 const newRequests = server.pretender.handledRequests.slice(requestPosition); 88 assert.ok(newRequests.length > 1, 'New requests have been made'); 89 90 // Cross-origin requests can't have a token 91 newRequests.forEach((req) => { 92 assert.equal( 93 getHeader(req, 'x-nomad-token'), 94 secretId, 95 `Token set for ${req.url}` 96 ); 97 }); 98 }); 99 100 test('an error message is shown when authenticating a token fails', async function (assert) { 101 const { secretId } = managementToken; 102 const bogusSecret = 'this-is-not-the-secret'; 103 assert.notEqual( 104 secretId, 105 bogusSecret, 106 'bogus secret is not somehow coincidentally equal to the real secret' 107 ); 108 109 await Tokens.visit(); 110 await Tokens.secret(bogusSecret).submit(); 111 112 assert.equal( 113 window.localStorage.nomadTokenSecret, 114 null, 115 'Token secret is discarded on failure' 116 ); 117 assert.ok(Tokens.errorMessage, 'Token error message is shown'); 118 assert.notOk(Tokens.successMessage, 'Token success message is not shown'); 119 assert.equal(Tokens.policies.length, 0, 'No token policies are shown'); 120 }); 121 122 test('a success message and a special management token message are shown when authenticating succeeds', async function (assert) { 123 const { secretId } = managementToken; 124 125 await Tokens.visit(); 126 await Tokens.secret(secretId).submit(); 127 128 await percySnapshot(assert); 129 130 assert.ok(Tokens.successMessage, 'Token success message is shown'); 131 assert.notOk(Tokens.errorMessage, 'Token error message is not shown'); 132 assert.ok(Tokens.managementMessage, 'Token management message is shown'); 133 assert.equal(Tokens.policies.length, 0, 'No token policies are shown'); 134 }); 135 136 test('a success message and associated policies are shown when authenticating succeeds', async function (assert) { 137 const { secretId } = clientToken; 138 const policy = clientToken.policies.models[0]; 139 policy.update('description', 'Make sure there is a description'); 140 141 await Tokens.visit(); 142 await Tokens.secret(secretId).submit(); 143 144 assert.ok(Tokens.successMessage, 'Token success message is shown'); 145 assert.notOk(Tokens.errorMessage, 'Token error message is not shown'); 146 assert.notOk( 147 Tokens.managementMessage, 148 'Token management message is not shown' 149 ); 150 assert.equal( 151 Tokens.policies.length, 152 clientToken.policies.length, 153 'Each policy associated with the token is listed' 154 ); 155 156 const policyElement = Tokens.policies.objectAt(0); 157 158 assert.equal(policyElement.name, policy.name, 'Policy Name'); 159 assert.equal( 160 policyElement.description, 161 policy.description, 162 'Policy Description' 163 ); 164 assert.equal(policyElement.rules, policy.rules, 'Policy Rules'); 165 }); 166 167 test('setting a token clears the store', async function (assert) { 168 const { secretId } = clientToken; 169 170 await Jobs.visit(); 171 assert.ok(find('.job-row'), 'Jobs found'); 172 173 await Tokens.visit(); 174 await Tokens.secret(secretId).submit(); 175 176 server.pretender.get('/v1/jobs', function () { 177 return [200, {}, '[]']; 178 }); 179 180 await Jobs.visit(); 181 182 // If jobs are lingering in the store, they would show up 183 assert.notOk(find('[data-test-job-row]'), 'No jobs found'); 184 }); 185 186 test('it handles expiring tokens', async function (assert) { 187 // Soon-expiring token 188 const expiringToken = server.create('token', { 189 name: "Time's a-tickin", 190 expirationTime: moment().add(1, 'm').toDate(), 191 }); 192 193 await Tokens.visit(); 194 195 // Token with no TTL 196 await Tokens.secret(clientToken.secretId).submit(); 197 assert 198 .dom('[data-test-token-expiry]') 199 .doesNotExist('No expiry shown for regular token'); 200 201 await Tokens.clear(); 202 203 // https://ember-concurrency.com/docs/testing-debugging/ 204 setTimeout(() => run.cancelTimers(), 500); 205 206 // Token with TTL 207 await Tokens.secret(expiringToken.secretId).submit(); 208 assert 209 .dom('[data-test-token-expiry]') 210 .exists('Expiry shown for TTL-having token'); 211 212 // TTL Action 213 await Jobs.visit(); 214 assert 215 .dom('.flash-message.alert-error button') 216 .exists('A global alert exists and has a clickable button'); 217 218 await click('.flash-message.alert-error button'); 219 assert.equal( 220 currentURL(), 221 '/settings/tokens', 222 'Redirected to tokens page on notification action' 223 ); 224 }); 225 226 test('it handles expired tokens', async function (assert) { 227 const expiredToken = server.create('token', { 228 name: 'Well past due', 229 expirationTime: moment().add(-5, 'm').toDate(), 230 }); 231 232 // GC'd or non-existent token, from localStorage or otherwise 233 window.localStorage.nomadTokenSecret = expiredToken.secretId; 234 await Tokens.visit(); 235 assert 236 .dom('[data-test-token-expired]') 237 .exists('Warning banner shown for expired token'); 238 }); 239 240 test('it forces redirect on an expired token', async function (assert) { 241 const expiredToken = server.create('token', { 242 name: 'Well past due', 243 expirationTime: moment().add(-5, 'm').toDate(), 244 }); 245 246 window.localStorage.nomadTokenSecret = expiredToken.secretId; 247 const expiredServerError = { 248 errors: [ 249 { 250 detail: 'ACL token expired', 251 }, 252 ], 253 }; 254 server.pretender.get('/v1/jobs', function () { 255 return [500, {}, JSON.stringify(expiredServerError)]; 256 }); 257 258 await Jobs.visit(); 259 assert.equal( 260 currentURL(), 261 '/settings/tokens', 262 'Redirected to tokens page due to an expired token' 263 ); 264 }); 265 266 test('it forces redirect on a not-found token', async function (assert) { 267 const longDeadToken = server.create('token', { 268 name: 'dead and gone', 269 expirationTime: moment().add(-5, 'h').toDate(), 270 }); 271 272 window.localStorage.nomadTokenSecret = longDeadToken.secretId; 273 const notFoundServerError = { 274 errors: [ 275 { 276 detail: 'ACL token not found', 277 }, 278 ], 279 }; 280 server.pretender.get('/v1/jobs', function () { 281 return [500, {}, JSON.stringify(notFoundServerError)]; 282 }); 283 284 await Jobs.visit(); 285 assert.equal( 286 currentURL(), 287 '/settings/tokens', 288 'Redirected to tokens page due to a token not being found' 289 ); 290 }); 291 292 test('it notifies you when your token has 10 minutes remaining', async function (assert) { 293 let notificationRendered = assert.async(); 294 let notificationNotRendered = assert.async(); 295 window.localStorage.clear(); 296 assert.equal( 297 window.localStorage.nomadTokenSecret, 298 null, 299 'No token secret set' 300 ); 301 assert.timeout(6000); 302 const nearlyExpiringToken = server.create('token', { 303 name: 'Not quite dead yet', 304 expirationTime: moment().add(10, 'm').add(5, 's').toDate(), 305 }); 306 307 await Tokens.visit(); 308 309 // Ember Concurrency makes testing iterations convoluted: https://ember-concurrency.com/docs/testing-debugging/ 310 // Waiting for half a second to validate that there's no warning; 311 // then a further 5 seconds to validate that there is a warning, and to explicitly cancelAllTimers(), 312 // short-circuiting our Ember Concurrency loop. 313 setTimeout(() => { 314 assert 315 .dom('.flash-message.alert-error') 316 .doesNotExist('No notification yet for a token with 10m5s left'); 317 notificationNotRendered(); 318 setTimeout(async () => { 319 await percySnapshot(assert, { 320 percyCSS: '[data-test-expiration-timestamp] { display: none; }', 321 }); 322 323 assert 324 .dom('.flash-message.alert-error') 325 .exists('Notification is rendered at the 10m mark'); 326 notificationRendered(); 327 run.cancelTimers(); 328 }, 5000); 329 }, 500); 330 await Tokens.secret(nearlyExpiringToken.secretId).submit(); 331 }); 332 333 test('when the ott query parameter is present upon application load it’s exchanged for a token', async function (assert) { 334 const { oneTimeSecret, secretId } = managementToken; 335 336 await JobDetail.visit({ id: job.id, ott: oneTimeSecret }); 337 338 assert.notOk( 339 currentURL().includes(oneTimeSecret), 340 'OTT is cleared from the URL after loading' 341 ); 342 343 await Tokens.visit(); 344 345 assert.equal( 346 window.localStorage.nomadTokenSecret, 347 secretId, 348 'Token secret was set' 349 ); 350 }); 351 352 test('SSO Sign-in flow: Manager', async function (assert) { 353 server.create('auth-method', { name: 'vault' }); 354 server.create('auth-method', { name: 'cognito' }); 355 server.create('token', { name: 'Thelonious' }); 356 357 await Tokens.visit(); 358 assert.dom('[data-test-auth-method]').exists({ count: 2 }); 359 await click('button[data-test-auth-method]'); 360 assert.ok(currentURL().startsWith('/oidc-mock')); 361 let managerButton = [...findAll('button')].filter((btn) => 362 btn.textContent.includes('Sign In as Manager') 363 )[0]; 364 365 assert.dom(managerButton).exists(); 366 await click(managerButton); 367 368 await percySnapshot(assert); 369 370 assert.ok(currentURL().startsWith('/settings/tokens')); 371 assert.dom('[data-test-token-name]').includesText('Token: Manager'); 372 }); 373 374 test('SSO Sign-in flow: Regular User', async function (assert) { 375 server.create('auth-method', { name: 'vault' }); 376 server.create('token', { name: 'Thelonious' }); 377 378 await Tokens.visit(); 379 assert.dom('[data-test-auth-method]').exists({ count: 1 }); 380 await click('button[data-test-auth-method]'); 381 assert.ok(currentURL().startsWith('/oidc-mock')); 382 let newTokenButton = [...findAll('button')].filter((btn) => 383 btn.textContent.includes('Sign In as Thelonious') 384 )[0]; 385 assert.dom(newTokenButton).exists(); 386 await click(newTokenButton); 387 388 assert.ok(currentURL().startsWith('/settings/tokens')); 389 assert.dom('[data-test-token-name]').includesText('Token: Thelonious'); 390 }); 391 392 test('It shows an error on failed SSO', async function (assert) { 393 server.create('auth-method', { name: 'vault' }); 394 await visit('/settings/tokens?state=failure'); 395 assert.ok(Tokens.ssoErrorMessage); 396 await Tokens.clearSSOError(); 397 assert.equal(currentURL(), '/settings/tokens', 'State query param cleared'); 398 assert.notOk(Tokens.ssoErrorMessage); 399 400 await click('button[data-test-auth-method]'); 401 assert.ok(currentURL().startsWith('/oidc-mock')); 402 403 let failureButton = find('.button.error'); 404 assert.dom(failureButton).exists(); 405 await click(failureButton); 406 assert.equal( 407 currentURL(), 408 '/settings/tokens?state=failure', 409 'Redirected with failure state' 410 ); 411 412 await percySnapshot(assert); 413 assert.ok(Tokens.ssoErrorMessage); 414 }); 415 416 test('when the ott exchange fails an error is shown', async function (assert) { 417 await visit('/?ott=fake'); 418 419 assert.ok(Layout.error.isPresent); 420 assert.equal(Layout.error.title, 'Token Exchange Error'); 421 assert.equal( 422 Layout.error.message, 423 'Failed to exchange the one-time token.' 424 ); 425 }); 426 427 function getHeader({ requestHeaders }, name) { 428 // Headers are case-insensitive, but object property look up is not 429 return ( 430 requestHeaders[name] || 431 requestHeaders[name.toLowerCase()] || 432 requestHeaders[name.toUpperCase()] 433 ); 434 } 435 });