UI: fix flaky form-related integration tests (#27537)

* tests: await settled after calling cancelTimers to fix flakiness

* chore: don't use assert.ok

* tests: fix flaky mfa-test
This commit is contained in:
Noelle Daley 2024-06-21 16:49:54 -07:00 committed by GitHub
parent 89e9e0f2cd
commit 4e02a7a673
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 89 additions and 48 deletions

View File

@ -2,14 +2,13 @@
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Ember from 'ember';
import { service } from '@ember/service';
// ARG NOTE: Once you remove outer-html after glimmerizing you can remove the outer-html component
import Component from './outer-html';
import { task, timeout, waitForEvent } from 'ember-concurrency';
import { debounce } from '@ember/runloop';
const WAIT_TIME = 500;
const ERROR_WINDOW_CLOSED =
'The provider window was closed before authentication was complete. Your web browser may have blocked or closed a pop-up window. Please check your settings and click Sign In to try again.';
const ERROR_MISSING_PARAMS =
@ -109,6 +108,8 @@ export default Component.extend({
watchPopup: task(function* (oidcWindow) {
while (true) {
const WAIT_TIME = Ember.testing ? 50 : 500;
yield timeout(WAIT_TIME);
if (!oidcWindow || oidcWindow.closed) {
return this.handleOIDCError(ERROR_WINDOW_CLOSED);

View File

@ -3,6 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Ember from 'ember';
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
@ -29,7 +30,7 @@ export const TOTP_VALIDATION_ERROR =
export default class MfaForm extends Component {
@service auth;
@tracked countdown;
@tracked countdown = 0;
@tracked error;
@tracked codeDelayMessage;
@ -104,7 +105,10 @@ export default class MfaForm extends Component {
@task *newCodeDelay(message) {
// parse validity period from error string to initialize countdown
this.countdown = parseInt(message.match(/(\d\w seconds)/)[0].split(' ')[0]);
while (this.countdown) {
if (Ember.testing) return;
while (this.countdown > 0) {
yield timeout(1000);
this.countdown--;
}

View File

@ -43,7 +43,7 @@
placeholder={{if (gt constraint.methods.length 1) "Enter passcode"}}
spellcheck="false"
autofocus="true"
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
disabled={{or this.validate.isRunning this.countdown}}
@value={{constraint.passcode}}
data-test-mfa-passcode={{index}}
/>
@ -56,7 +56,7 @@
{{/if}}
{{/each}}
</div>
{{#if this.newCodeDelay.isRunning}}
{{#if this.countdown}}
<div>
<AlertInline @type="danger" @message={{this.codeDelayMessage}} />
</div>
@ -66,10 +66,10 @@
@icon={{if this.validate.isRunning "loading"}}
id="validate"
type="submit"
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
disabled={{or this.validate.isRunning this.countdown}}
data-test-mfa-validate
/>
{{#if this.newCodeDelay.isRunning}}
{{#if this.countdown}}
<Icon @name="delay" class="has-text-grey" />
<span class="has-text-grey is-v-centered" data-test-mfa-countdown>{{this.countdown}}</span>
{{/if}}

View File

@ -70,6 +70,7 @@ module('Acceptance | oidc auth method', function (hooks) {
window.postMessage(buildMessage().data, window.origin);
cancelTimers();
}, 100);
await click('[data-test-auth-submit]');
});
@ -98,6 +99,7 @@ module('Acceptance | oidc auth method', function (hooks) {
window.postMessage(buildMessage().data, window.origin);
cancelTimers();
}, 50);
await click('[data-test-auth-submit]');
});
@ -109,6 +111,7 @@ module('Acceptance | oidc auth method', function (hooks) {
window.postMessage(buildMessage().data, window.origin);
cancelTimers();
}, 50);
await click('[data-test-auth-submit]');
await waitUntil(() => find('[data-test-user-menu-trigger]'));
await click('[data-test-user-menu-trigger]');

View File

@ -147,7 +147,6 @@ module('Integration | Component | auth form', function (hooks) {
await this.renderComponent();
later(() => cancelTimers(), 50);
await settled();
assert.dom(GENERAL.messageError).hasText('Error Token unwrap failed: There was an error unwrapping!');
});

View File

@ -166,7 +166,10 @@ module('Integration | Component | auth jwt', function (hooks) {
await waitUntil(() => {
return this.openSpy.calledOnce;
});
cancelTimers();
await settled();
const call = this.openSpy.getCall(0);
assert.deepEqual(
call.args,
@ -201,6 +204,8 @@ module('Integration | Component | auth jwt', function (hooks) {
buildMessage({ data: { source: 'oidc-callback', state: 'state', foo: 'bar' } })
);
cancelTimers();
await settled();
assert.strictEqual(this.error, ERROR_MISSING_PARAMS, 'calls onError with params missing error');
});
@ -226,9 +231,11 @@ module('Integration | Component | auth jwt', function (hooks) {
return this.openSpy.calledOnce;
});
this.window.trigger('message', buildMessage({ origin: 'http://hackerz.com' }));
cancelTimers();
await settled();
assert.notOk(this.handler.called, 'should not call the submit handler');
assert.false(this.handler.called, 'should not call the submit handler');
});
test('oidc: fails silently when event is not trusted', async function (assert) {
@ -242,7 +249,8 @@ module('Integration | Component | auth jwt', function (hooks) {
this.window.trigger('message', buildMessage({ isTrusted: false }));
cancelTimers();
await settled();
assert.notOk(this.handler.called, 'should not call the submit handler');
assert.false(this.handler.called, 'should not call the submit handler');
});
test('oidc: it should trigger error callback when role is not found', async function (assert) {

View File

@ -71,25 +71,29 @@ module('Integration | Component | control group success', function (hooks) {
this.set('model', MODEL);
this.set('response', response);
await render(hbs`<ControlGroupSuccess @model={{this.model}} @controlGroupResponse={{this.response}} />`);
assert.ok(component.showsNavigateMessage, 'shows unwrap message');
assert.true(component.showsNavigateMessage, 'shows unwrap message');
await component.navigate();
later(() => cancelTimers(), 50);
return settled().then(() => {
assert.ok(this.controlGroup.markTokenForUnwrap.calledOnce, 'marks token for unwrap');
assert.ok(this.router.transitionTo.calledOnce, 'calls router transition');
});
await settled();
assert.true(this.controlGroup.markTokenForUnwrap.calledOnce, 'marks token for unwrap');
assert.true(this.router.transitionTo.calledOnce, 'calls router transition');
});
test('render without token', async function (assert) {
assert.expect(2);
this.set('model', MODEL);
await render(hbs`<ControlGroupSuccess @model={{this.model}} />`);
assert.ok(component.showsUnwrapForm, 'shows unwrap form');
assert.true(component.showsUnwrapForm, 'shows unwrap form');
await component.token('token');
component.unwrap();
later(() => cancelTimers(), 50);
return settled().then(() => {
assert.ok(component.showsJsonViewer, 'shows unwrapped data');
});
await settled();
assert.true(component.showsJsonViewer, 'shows unwrapped data');
});
});

View File

@ -224,15 +224,15 @@ module('Integration | Component | edit form kmip role', function (hooks) {
click('[data-test-edit-form-submit]');
later(() => cancelTimers(), 50);
return settled().then(() => {
for (const afterStateKey of Object.keys(stateAfterSave)) {
assert.strictEqual(
model.get(afterStateKey),
stateAfterSave[afterStateKey],
`sets ${afterStateKey} on save`
);
}
});
await settled();
for (const afterStateKey of Object.keys(stateAfterSave)) {
assert.strictEqual(
model.get(afterStateKey),
stateAfterSave[afterStateKey],
`sets ${afterStateKey} on save`
);
}
});
}
});

View File

@ -68,12 +68,12 @@ module('Integration | Component | edit form', function (hooks) {
component.submit();
later(() => cancelTimers(), 50);
return settled().then(() => {
assert.ok(saveSpy.calledOnce, 'calls passed onSave');
assert.strictEqual(saveSpy.getCall(0).args[0].saveType, 'save');
assert.deepEqual(saveSpy.getCall(0).args[0].model, this.model, 'passes model to onSave');
const flash = this.owner.lookup('service:flash-messages');
assert.strictEqual(flash.success.callCount, 1, 'calls flash message success');
});
await settled();
assert.true(saveSpy.calledOnce, 'calls passed onSave');
assert.strictEqual(saveSpy.getCall(0).args[0].saveType, 'save');
assert.deepEqual(saveSpy.getCall(0).args[0].model, this.model, 'passes model to onSave');
const flash = this.owner.lookup('service:flash-messages');
assert.strictEqual(flash.success.callCount, 1, 'calls flash message success');
});
});

View File

@ -5,10 +5,9 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { render, settled, fillIn, click, waitUntil, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { fillIn, click, waitUntil } from '@ember/test-helpers';
import { _cancelTimers as cancelTimers, later } from '@ember/runloop';
import { TOTP_VALIDATION_ERROR } from 'vault/components/mfa/mfa-form';
@ -84,6 +83,12 @@ module('Integration | Component | mfa-form', function (hooks) {
);
});
test('it should render a submit button', async function (assert) {
await render(hbs`<Mfa::MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
assert.dom('[data-test-mfa-validate]').isNotDisabled('Button is not disabled by default');
});
test('it should render method selects and passcode inputs', async function (assert) {
assert.expect(2);
const duoConstraint = this.server.create('mfa-method', { type: 'duo', uses_passcode: true });
@ -170,7 +175,6 @@ module('Integration | Component | mfa-form', function (hooks) {
await click('[data-test-mfa-validate]');
});
// TODO JLR: It doesn't appear that cancelTimers is working and tests wait for the full countdown
test('it should show countdown on passcode already used and rate limit errors', async function (assert) {
const messages = {
used: 'code already used; new code is available in 45 seconds',
@ -184,12 +188,16 @@ module('Integration | Component | mfa-form', function (hooks) {
throw { errors: [messages[code]] };
},
});
const expectedTime = code === 'used' ? 45 : 15;
await render(hbs`<Mfa::MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
await fillIn('[data-test-mfa-passcode]', code);
later(() => cancelTimers(), 50);
await click('[data-test-mfa-validate]');
const expectedTime = code === 'used' ? '45' : '15';
await waitFor('[data-test-mfa-countdown]');
assert
.dom('[data-test-mfa-countdown]')
.includesText(expectedTime, 'countdown renders with correct initial value from error response');
@ -209,6 +217,8 @@ module('Integration | Component | mfa-form', function (hooks) {
await fillIn('[data-test-mfa-passcode]', 'test-code');
later(() => cancelTimers(), 50);
await settled();
await click('[data-test-mfa-validate]');
assert
.dom('[data-test-message-error]')

View File

@ -116,8 +116,8 @@ module('Integration | Component | mount backend form', function (hooks) {
later(() => cancelTimers(), 50);
await settled();
assert.ok(spy.calledOnce, 'calls the passed success method');
assert.ok(
assert.true(spy.calledOnce, 'calls the passed success method');
assert.true(
this.flashSuccessSpy.calledWith('Successfully mounted the approle auth method at foo.'),
'Renders correct flash message'
);
@ -184,8 +184,8 @@ module('Integration | Component | mount backend form', function (hooks) {
later(() => cancelTimers(), 50);
await settled();
assert.ok(spy.calledOnce, 'calls the passed success method');
assert.ok(
assert.true(spy.calledOnce, 'calls the passed success method');
assert.true(
this.flashSuccessSpy.calledWith('Successfully mounted the ssh secrets engine at foo.'),
'Renders correct flash message'
);

View File

@ -5,6 +5,7 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { settled } from '@ember/test-helpers';
import EmberObject from '@ember/object';
import Evented from '@ember/object/evented';
import sinon from 'sinon';
@ -29,9 +30,14 @@ module('Unit | Component | auth-jwt', function (hooks) {
this.component.prepareForOIDC.perform(mockWindow.create());
this.component.window.trigger('message', { origin: 'http://anotherdomain.com', isTrusted: true });
assert.ok(this.errorSpy.notCalled, 'Error handler not triggered while waiting for oidc callback message');
assert.true(
this.errorSpy.notCalled,
'Error handler not triggered while waiting for oidc callback message'
);
assert.strictEqual(this.component.exchangeOIDC.performCount, 0, 'exchangeOIDC method not fired');
cancelTimers();
await settled();
});
test('it should ignore untrusted messages while waiting for oidc callback', async function (assert) {
@ -40,10 +46,11 @@ module('Unit | Component | auth-jwt', function (hooks) {
this.component.window.trigger('message', { origin: 'http://localhost:4200', isTrusted: false });
assert.ok(this.errorSpy.notCalled, 'Error handler not triggered while waiting for oidc callback message');
assert.strictEqual(this.component.exchangeOIDC.performCount, 0, 'exchangeOIDC method not fired');
cancelTimers();
await settled();
});
// TODO: Flaky
// test case for https://github.com/hashicorp/vault/issues/12436
test('it should ignore messages sent from outside the app while waiting for oidc callback', async function (assert) {
assert.expect(2);
@ -65,12 +72,17 @@ module('Unit | Component | auth-jwt', function (hooks) {
message.data.source = 'oidc-callback';
this.component.window.trigger('message', message);
assert.ok(this.errorSpy.notCalled, 'Error handler not triggered while waiting for oidc callback message');
assert.true(
this.errorSpy.notCalled,
'Error handler not triggered while waiting for oidc callback message'
);
assert.strictEqual(
this.component.exchangeOIDC.performCount,
1,
'exchangeOIDC method fires when oidc callback message is received'
);
cancelTimers();
await settled();
});
});