UI: pki configuration edit form (#20245)

* setup routing, move queries in ConfigurationIndex to parent resource route

* finish building out form, add model attrs build ttls

* add types

* update model attribute values, fix default ttl states

* remove defaults and use openApi, group with booleans

* add model to application route"

* add save functionality

* add error banner

* add transition after save

* use defaults from open api

* fix empty state language

* pass engine data

* change model attrs to ttl objects

* update types

* add invalid form alert to error block

* move data manipulation to serialize

* fix serializer, add comments

* add test for serializer

* edit configuration details view

* update details test

* change to updateRecord so POST request is made

* config/urls use POST instead of PUT

* add edit tests, update details

* add model hooks back to routes

* rearrange to remove dif

* remove createRecord for urls

* update comment

* wip sample ttl transform

* Revert "wip sample ttl transform"

This reverts commit 59fc179b5cd2994c4258e553e56667e29b3d6b72.

* revert changes, move model updates back to component

* simplify model fetches

* address comments;

* update pki/urls test

* update adapter test
This commit is contained in:
claire bontempo 2023-04-25 17:50:19 -04:00 committed by GitHub
parent ad18fc6398
commit 76f1971126
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 784 additions and 181 deletions

View File

@ -18,4 +18,9 @@ export default class PkiCrlAdapter extends ApplicationAdapter {
return resp.data;
});
}
updateRecord(store, type, snapshot) {
const data = snapshot.serialize();
return this.ajax(this._url(snapshot.record.id), 'POST', { data });
}
}

View File

@ -13,13 +13,12 @@ export default class PkiUrlsAdapter extends ApplicationAdapter {
return `${this.buildURL()}/${encodePath(backend)}/config/urls`;
}
urlForCreateRecord(modelName, snapshot) {
return this._url(snapshot.record.id);
updateRecord(store, type, snapshot) {
const data = snapshot.serialize();
return this.ajax(this._url(snapshot.record.id), 'POST', { data });
}
urlForFindRecord(id) {
return this._url(id);
}
urlForUpdateRecord(store, type, snapshot) {
return this._url(snapshot.record.id);
}
}

View File

@ -4,15 +4,67 @@
*/
import Model, { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
@withFormFields(['expiry', 'autoRebuildGracePeriod', 'deltaRebuildInterval', 'ocspExpiry'])
export default class PkiCrlModel extends Model {
// This model uses the backend value as the model ID
get useOpenAPI() {
return true;
}
@attr('string') expiry;
@attr('boolean') autoRebuild;
@attr('string') ocspExpiry;
@attr('string', {
label: 'Auto-rebuild on',
labelDisabled: 'Auto-rebuild off',
mapToBoolean: 'autoRebuild',
isOppositeValue: false,
helperTextEnabled: 'Vault will rebuild the CRL in the below grace period before expiration',
helperTextDisabled: 'Vault will not automatically rebuild the CRL',
})
autoRebuildGracePeriod;
@attr('boolean') enableDelta;
@attr('string', {
label: 'Delta CRL building on',
labelDisabled: 'Delta CRL building off',
mapToBoolean: 'enableDelta',
isOppositeValue: false,
helperTextEnabled: 'Vault will rebuild the delta CRL at the interval below:',
helperTextDisabled: 'Vault will not rebuild the delta CRL at an interval',
})
deltaRebuildInterval;
@attr('boolean') disable;
@attr('string', {
label: 'Expiry',
labelDisabled: 'No expiry',
mapToBoolean: 'disable',
isOppositeValue: true,
helperTextDisabled: 'The CRL will not be built.',
helperTextEnabled: 'The CRL will expire after:',
})
expiry;
@attr('boolean') ocspDisable;
@attr('string', {
label: 'OCSP responder APIs enabled',
labelDisabled: 'OCSP responder APIs disabled',
mapToBoolean: 'ocspDisable',
isOppositeValue: true,
helperTextEnabled: "Requests about a certificate's status will be valid for:",
helperTextDisabled: 'Requests cannot be made to check if an individual certificate is valid.',
})
ocspExpiry;
// TODO follow-on ticket to add enterprise only attributes:
/*
@attr('boolean') crossClusterRevocation;
@attr('boolean') unifiedCrl;
@attr('boolean') unifiedCrlOnExistingPaths;
*/
@lazyCapabilities(apiPath`${'id'}/config/crl`, 'id') crlPath;
get canSet() {
return this.crlPath.get('canCreate') !== false;
}
}

View File

@ -18,6 +18,7 @@
* @param onChange {Function} - This function will be passed a TTL object, which includes enabled{bool}, seconds{number}, timeString{string}, goSafeTimeString{string}.
* @param initialEnabled=false {Boolean} - Set this value if you want the toggle on when component is mounted
* @param label="Time to live (TTL)" {String} - Label is the main label that lives next to the toggle. Yielded values will replace the label
* @param labelDisabled=Label to display when TTL is toggled off
* @param helperTextEnabled="" {String} - This helper text is shown under the label when the toggle is switched on
* @param helperTextDisabled="" {String} - This helper text is shown under the label when the toggle is switched off
* @param initialValue=null {string} - InitialValue is the duration value which will be shown when the component is loaded. If it can't be parsed, will default to 0.
@ -52,6 +53,9 @@ export default class TtlPickerComponent extends Component {
elementId = 'ttl-' + guidFor(this);
get label() {
if (this.args.label && this.args.labelDisabled) {
return this.enableTTL ? this.args.label : this.args.labelDisabled;
}
return this.args.label || 'Time to live (TTL)';
}
get helperText() {

View File

@ -38,14 +38,37 @@
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Certificate Revocation List (CRL)
</h2>
<InfoTableRow @label="Expiry" @value={{@crl.expiry}} />
<InfoTableRow @label="Auto-rebuild" @value={{if @crl.autoRebuild "On" "Off"}} />
<InfoTableRow @label="CRL building" @value={{if @crl.disable "Disabled" "Enabled"}} />
{{#unless @crl.disable}}
<InfoTableRow @label="Expiry" @value={{@crl.expiry}} />
<InfoTableRow @label="Auto-rebuild">
<Icon
class={{if @crl.autoRebuild "icon-true" "icon-false"}}
@name={{if @crl.autoRebuild "check-circle" "x-square"}}
/>
{{if @crl.autoRebuild "On" "Off"}}
</InfoTableRow>
{{#if @crl.autoRebuild}}
<InfoTableRow @label="Auto-rebuild grace period" @value={{@crl.autoRebuildGracePeriod}} />
{{/if}}
<InfoTableRow @label="Delta CRL building">
<Icon
class={{if @crl.enableDelta "icon-true" "icon-false"}}
@name={{if @crl.enableDelta "check-circle" "x-square"}}
/>
{{if @crl.enableDelta "On" "Off"}}
</InfoTableRow>
{{#if @crl.enableDelta}}
<InfoTableRow @label="Delta rebuild interval" @value={{@crl.deltaRebuildInterval}} />
{{/if}}
{{/unless}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Online Certificate Status Protocol (OCSP)
</h2>
<InfoTableRow @label="Responder APIs" @value={{if @crl.ocspDisable "Disabled" "Enabled"}} />
<InfoTableRow @label="Interval" @value={{@crl.ocspExpiry}} />
{{#unless @crl.ocspDisable}}
<InfoTableRow @label="Interval" @value={{@crl.ocspExpiry}} />
{{/unless}}
{{/if}}
{{else}}
<Toolbar>

View File

@ -0,0 +1,99 @@
<div class="box is-sideless is-fullwidth is-marginless">
{{#if this.errorBanner}}
<AlertBanner @type="danger" @message={{this.errorBanner}} data-test-error-banner />
{{/if}}
<form {{on "submit" (perform this.save)}}>
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-urls-edit-section>
<h2 class="title is-size-5 has-border-bottom-light page-header">
Global URLs
</h2>
{{#if @urls.canSet}}
{{#each @urls.allFields as |attr|}}
<FormField @attr={{attr}} @model={{@urls}} @showHelpText={{false}} />
{{/each}}
{{else}}
<EmptyState
class="is-box-shadowless"
@title="You do not have permission to set URLs"
@message="Ask your administrator if you think you should have access to:"
>
<code>POST /{{@backend}}/config/urls</code>
</EmptyState>
{{/if}}
</fieldset>
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-crl-edit-section>
<h2 class="title is-size-5 has-border-bottom-light page-header">
Certificate Revocation List (CRL)
</h2>
{{#if @crl.canSet}}
{{#each @crl.formFields as |attr|}}
{{#if (eq attr.name "ocspExpiry")}}
<h2 class="title is-size-5 has-border-bottom-light page-header">
Online Certificate Status Protocol (OCSP)
</h2>
{{/if}}
{{#if (or (includes attr.name this.alwaysRender) (not @crl.disable))}}
{{#let (get @crl attr.options.mapToBoolean) as |booleanValue|}}
<div class="field">
<TtlPicker
data-test-input={{attr.name}}
@onChange={{fn this.handleTtl attr}}
@label={{attr.options.label}}
@labelDisabled={{attr.options.labelDisabled}}
@helperTextDisabled={{attr.options.helperTextDisabled}}
@helperTextEnabled={{attr.options.helperTextEnabled}}
@initialEnabled={{if attr.options.isOppositeValue (not booleanValue) booleanValue}}
@initialValue={{get @crl attr.name}}
/>
</div>
{{/let}}
{{/if}}
{{/each}}
{{else}}
<EmptyState
class="is-box-shadowless"
@title="You do not have permission to set revocation configuration"
@message="Ask your administrator if you think you should have access to:"
>
<code>POST /{{@backend}}/config/crl</code>
</EmptyState>
{{/if}}
</fieldset>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
{{#if (or @urls.canSet @crl.canSet)}}
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-configuration-edit-save
>
Save
</button>
{{/if}}
<button
{{on "click" this.cancel}}
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
data-test-configuration-edit-cancel
>
Cancel
</button>
</div>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline
@type="danger"
@paddingTop={{true}}
@message={{this.invalidFormAlert}}
@mimicRefresh={{true}}
data-test-configuration-edit-validation-alert
/>
</div>
{{/if}}
</div>
</form>
</div>

View File

@ -0,0 +1,77 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import RouterService from '@ember/routing/router-service';
import FlashMessageService from 'vault/services/flash-messages';
import { FormField, TtlEvent } from 'vault/app-types';
import PkiCrlModel from 'vault/models/pki/crl';
import PkiUrlsModel from 'vault/models/pki/urls';
import errorMessage from 'vault/utils/error-message';
interface Args {
crl: PkiCrlModel;
urls: PkiUrlsModel;
}
interface PkiCrlTtls {
autoRebuildGracePeriod: string;
expiry: string;
deltaRebuildInterval: string;
ocspExpiry: string;
}
interface PkiCrlBooleans {
autoRebuild: boolean;
enableDelta: boolean;
disable: boolean;
ocspDisable: boolean;
}
export default class PkiConfigurationEditComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@tracked invalidFormAlert = '';
@tracked errorBanner = '';
get alwaysRender() {
return ['expiry', 'ocspExpiry'];
}
@task
@waitFor
*save(event: Event) {
event.preventDefault();
try {
yield this.args.urls.save();
yield this.args.crl.save();
this.flashMessages.success('Successfully updated configuration');
this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index');
} catch (error) {
this.invalidFormAlert = 'There was an error submitting this form.';
this.errorBanner = errorMessage(error);
}
}
@action
cancel() {
this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index');
}
@action
handleTtl(attr: FormField, e: TtlEvent) {
const { enabled, goSafeTimeString } = e;
const ttlAttr = attr.name;
this.args.crl[ttlAttr as keyof PkiCrlTtls] = goSafeTimeString;
// expiry and ocspExpiry both correspond to 'disable' booleans
// so when ttl is enabled, the booleans are set to false
this.args.crl[attr.options.mapToBoolean as keyof PkiCrlBooleans] = attr.options.isOppositeValue
? !enabled
: enabled;
}
}

View File

@ -4,6 +4,7 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
/**
* the overview, roles, issuers, certificates, and key routes all need to be aware of the whether there is a config for the engine
@ -21,6 +22,7 @@ export function withConfig() {
return SuperClass;
}
return class CheckConfig extends SuperClass {
@service secretMountPath;
shouldPromptConfig = false;
async beforeModel() {

View File

@ -4,5 +4,18 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default class PkiConfigurationRoute extends Route {}
export default class PkiConfigurationRoute extends Route {
@service store;
model() {
const engine = this.modelFor('application');
return hash({
engine,
urls: this.store.findRecord('pki/urls', engine.id).catch((e) => e.httpStatus),
crl: this.store.findRecord('pki/crl', engine.id).catch((e) => e.httpStatus),
});
}
}

View File

@ -16,7 +16,7 @@ export default class PkiConfigurationCreateRoute extends Route {
model() {
return hash({
config: this.store.createRecord('pki/action'),
urls: this.getOrCreateUrls(this.secretMountPath.currentPath),
urls: this.modelFor('configuration').urls,
});
}
@ -28,12 +28,4 @@ export default class PkiConfigurationCreateRoute extends Route {
{ label: 'configure' },
];
}
async getOrCreateUrls(backend) {
try {
return this.store.findRecord('pki/urls', backend);
} catch (e) {
return this.store.createRecord('pki/urls', { id: backend });
}
}
}

View File

@ -4,5 +4,29 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
export default class PkiConfigurationEditRoute extends Route {}
@withConfirmLeave('model.config', ['model.urls', 'model.crl'])
export default class PkiConfigurationEditRoute extends Route {
@service secretMountPath;
model() {
const { urls, crl, engine } = this.modelFor('configuration');
return {
engineId: engine.id,
urls,
crl,
};
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'overview' },
{ label: 'configuration', route: 'configuration.index' },
{ label: 'edit' },
];
}
}

View File

@ -11,46 +11,23 @@ import { hash } from 'rsvp';
@withConfig()
export default class ConfigurationIndexRoute extends Route {
@service store;
@service secretMountPath;
async fetchUrls(backend) {
try {
return await this.store.findRecord('pki/urls', backend);
} catch (e) {
return e.httpStatus;
}
}
async fetchCrl(backend) {
try {
return await this.store.findRecord('pki/crl', backend);
} catch (e) {
return e.httpStatus;
}
}
async fetchMountConfig(path) {
const mountConfig = await this.store.query('secret-engine', { path });
async fetchMountConfig(backend) {
const mountConfig = await this.store.query('secret-engine', { path: backend });
if (mountConfig) {
return mountConfig.get('firstObject');
}
}
async model() {
const backend = this.secretMountPath.currentPath;
model() {
const { urls, crl, engine } = this.modelFor('configuration');
return hash({
hasConfig: this.shouldPromptConfig,
engine: this.modelFor('application'),
urls: this.fetchUrls(backend),
crl: this.fetchCrl(backend),
mountConfig: this.fetchMountConfig(backend),
engine,
urls,
crl,
mountConfig: this.fetchMountConfig(engine.id),
issuerModel: this.store.createRecord('pki/issuer'),
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
}
}

View File

@ -1 +1,13 @@
configuration.edit
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
<Icon @name="file-text" @size="24" class="has-text-grey-light" />
Edit PKI configuration
</h1>
</p.levelLeft>
</PageHeader>
<Page::PkiConfigurationEdit @urls={{this.model.urls}} @crl={{this.model.crl}} @backend={{this.model.engineId}} />

View File

@ -299,9 +299,10 @@ module('Acceptance | pki engine route cleanup test', function (hooks) {
await click(SELECTORS.configuration.generateRootSave);
// Go to list view so we fetch all the issuers
await visit(`/vault/secrets/${this.mountPath}/pki/issuers`);
issuers = this.store.peekAll('pki/issuer');
const issuerId = issuers.objectAt(0).id;
assert.strictEqual(issuers.length, 1, 'Issuer exists on model');
assert.strictEqual(issuers.length, 1, 'Issuer exists on model in list');
await visit(`/vault/secrets/${this.mountPath}/pki/issuers/${issuerId}/details`);
await click(SELECTORS.issuerDetails.configure);
issuer = this.store.peekRecord('pki/issuer', issuerId);
@ -310,7 +311,7 @@ module('Acceptance | pki engine route cleanup test', function (hooks) {
assert.true(issuer.hasDirtyAttributes, 'Model is dirty');
await click(SELECTORS.overviewBreadcrumb);
issuers = this.store.peekAll('pki/issuer');
assert.strictEqual(issuers.length, 1, 'Issuer exists on model');
assert.strictEqual(issuers.length, 1, 'Issuer exists on model in overview');
issuer = this.store.peekRecord('pki/issuer', issuerId);
assert.false(issuer.hasDirtyAttributes, 'Dirty attrs were rolled back');
});

View File

@ -1,36 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export const SELECTORS = {
// global urls
issuingCertificatesLabel: '[data-test-row-label="Issuing certificates"]',
issuingCertificatesRowVal: '[data-test-row-value="Issuing certificates"]',
crlDistributionPointsLabel: '[data-test-row-label="CRL distribution points"]',
crlDistributionPointsRowVal: '[data-test-row-value="CRL distribution points"]',
// crl
expiryLabel: '[data-test-row-label="Expiry"]',
expiryRowVal: '[data-test-row-value="Expiry"]',
rebuildLabel: '[data-test-row-label="Auto-rebuild"]',
rebuildRowVal: '[data-test-row-value="Auto-rebuild"]',
responderApiLabel: '[data-test-row-label="Responder APIs"]',
responderApiRowVal: '[data-test-row-value="Responder APIs"]',
intervalLabel: '[data-test-row-label="Interval"]',
intervalRowVal: '[data-test-row-value="Interval"]',
// mount configuration
engineTypeLabel: '[data-test-row-label="Secret engine type"]',
engineTypeRowVal: '[data-test-row-value="Secret engine type"]',
pathLabel: '[data-test-row-label="Path"]',
pathRowVal: '[data-test-row-value="Path"]',
accessorLabel: '[data-test-row-label="Accessor"]',
accessorRowVal: '[data-test-row-value="Accessor"]',
localLabel: '[data-test-row-label="Local"]',
localRowVal: '[data-test-value-div="Local"]',
sealWrapLabel: '[data-test-row-label="Seal wrap"]',
sealWrapRowVal: '[data-test-value-div="Seal wrap"]',
maxLeaseTtlLabel: '[data-test-row-label="Max lease TTL"]',
maxLeaseTtlRowVal: '[data-test-row-value="Max lease TTL"]',
allowedManagedKeysLabel: '[data-test-row-label="Allowed managed keys"]',
allowedManagedKeysRowVal: '[data-test-value-div="Allowed managed keys"]',
};

View File

@ -0,0 +1,19 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export const SELECTORS = {
errorBanner: '[data-test-error-banner]',
urlsEditSection: '[data-test-urls-edit-section]',
urlFieldInput: (attr) => `[data-test-input="${attr}"] textarea`,
urlFieldLabel: (attr) => `[data-test-input="${attr}"] label`,
crlEditSection: '[data-test-crl-edit-section]',
crlToggleInput: (attr) => `[data-test-input="${attr}"] input`,
crlTtlInput: (attr) => `[data-test-ttl-value="${attr}"]`,
crlFieldLabel: (attr) => `[data-test-input="${attr}"] label`,
saveButton: '[data-test-configuration-edit-save]',
cancelButton: '[data-test-configuration-edit-cancel]',
validationAlert: '[data-test-configuration-edit-validation-alert]',
deleteButton: (attr) => `[data-test-input="${attr}"] [data-test-string-list-button="delete"]`,
};

View File

@ -8,7 +8,12 @@ import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-configuration-details';
const SELECTORS = {
rowLabel: (attr) => `[data-test-row-label="${attr}"]`,
rowValue: (attr) => `[data-test-value-div="${attr}"]`,
rowIcon: (attr, icon) => `[data-test-row-value="${attr}"] [data-test-icon="${icon}"]`,
};
module('Integration | Component | Page::PkiConfigurationDetails', function (hooks) {
setupRenderingTest(hooks);
@ -23,9 +28,13 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
this.crl = this.store.createRecord('pki/crl', {
id: 'pki-test',
expiry: '20h',
autoRebuild: false,
disable: false,
autoRebuild: true,
autoRebuildGracePeriod: '13h',
enableDelta: true,
deltaRebuildInterval: '15m',
ocspExpiry: '77h',
oscpDisable: true,
ocspDisable: false,
});
this.mountConfig = {
id: 'pki-test',
@ -49,10 +58,10 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
);
assert
.dom(SELECTORS.issuingCertificatesLabel)
.dom(SELECTORS.rowLabel('Issuing certificates'))
.hasText('Issuing certificates', 'issuing certificate row label renders');
assert
.dom(SELECTORS.issuingCertificatesRowVal)
.dom(SELECTORS.rowValue('Issuing certificates'))
.hasText('example.com', 'issuing certificate value renders');
this.urls.issuingCertificates = null;
await render(
@ -60,13 +69,13 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
{ owner: this.engine }
);
assert
.dom(SELECTORS.issuingCertificatesRowVal)
.dom(SELECTORS.rowValue('Issuing certificates'))
.hasText('None', 'issuing certificate value renders None if none is configured');
assert
.dom(SELECTORS.crlDistributionPointsLabel)
.dom(SELECTORS.rowLabel('CRL distribution points'))
.hasText('CRL distribution points', 'crl distribution points row label renders');
assert
.dom(SELECTORS.crlDistributionPointsRowVal)
.dom(SELECTORS.rowValue('CRL distribution points'))
.hasText('None', 'crl distribution points value renders None if none is configured');
});
@ -76,26 +85,61 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
{ owner: this.engine }
);
assert.dom(SELECTORS.expiryLabel).hasText('Expiry', 'crl expiry row label renders');
assert.dom(SELECTORS.expiryRowVal).hasText('20h', 'expiry value renders');
assert.dom(SELECTORS.rebuildLabel).hasText('Auto-rebuild', 'auto rebuild label renders');
assert.dom(SELECTORS.rowLabel('CRL building')).hasText('CRL building', 'crl expiry row label renders');
assert.dom(SELECTORS.rowValue('CRL building')).hasText('Enabled', 'enabled renders');
assert.dom(SELECTORS.rowValue('Expiry')).hasText('20h', 'expiry value renders');
assert.dom(SELECTORS.rowLabel('Auto-rebuild')).hasText('Auto-rebuild', 'auto rebuild label renders');
assert.dom(SELECTORS.rowValue('Auto-rebuild')).hasText('On', 'it renders truthy auto build');
assert.dom(SELECTORS.rowIcon('Auto-rebuild', 'check-circle'));
assert
.dom(SELECTORS.rebuildRowVal)
.hasText('Off', 'auto-rebuild value renders off if auto rebuild is false');
this.crl.autoRebuild = true;
.dom(SELECTORS.rowValue('Auto-rebuild grace period'))
.hasText('13h', 'it renders auto build grace period');
assert.dom(SELECTORS.rowValue('Delta CRL building')).hasText('On', 'it renders truthy delta crl build');
assert.dom(SELECTORS.rowIcon('Delta CRL building', 'check-circle'));
assert
.dom(SELECTORS.rowValue('Delta rebuild interval'))
.hasText('15m', 'it renders delta build duration');
assert
.dom(SELECTORS.rowValue('Responder APIs'))
.hasText('Enabled', 'responder apis value renders Enabled if oscp_disable=false');
assert.dom(SELECTORS.rowValue('Interval')).hasText('77h', 'interval value renders');
// check falsy aut_rebuild and _enable_delta hides duration values
this.crl.autoRebuild = false;
this.crl.enableDelta = false;
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @mountConfig={{this.mountConfig}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.rowValue('Auto-rebuild')).hasText('Off', 'it renders falsy auto build');
assert.dom(SELECTORS.rowIcon('Auto-rebuild', 'x-square'));
assert
.dom(SELECTORS.rebuildRowVal)
.hasText('On', 'auto-rebuild value renders on if auto rebuild is true');
assert.dom(SELECTORS.responderApiLabel).hasText('Responder APIs', 'responder apis row label renders');
.dom(SELECTORS.rowValue('Auto-rebuild grace period'))
.doesNotExist('does not render auto-rebuild grace period');
assert.dom(SELECTORS.rowValue('Delta CRL building')).hasText('Off', 'it renders falsy delta cr build');
assert.dom(SELECTORS.rowIcon('Delta CRL building', 'x-square'));
assert
.dom(SELECTORS.responderApiRowVal)
.hasText('Enabled', 'responder apis value renders Enabled if oscpDisable is true');
assert.dom(SELECTORS.intervalLabel).hasText('Interval', 'interval row label renders');
assert.dom(SELECTORS.intervalRowVal).hasText('77h', 'interval value renders');
.dom(SELECTORS.rowValue('Delta rebuild interval'))
.doesNotExist('does not render delta rebuild duration');
// check falsy disable and ocsp_disable hides duration values and other params
this.crl.autoRebuild = true;
this.crl.enableDelta = true;
this.crl.disable = true;
this.crl.ocspDisable = true;
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @mountConfig={{this.mountConfig}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.rowValue('CRL building')).hasText('Disabled', 'disabled renders');
assert.dom(SELECTORS.rowValue('Expiry')).doesNotExist();
assert
.dom(SELECTORS.rowValue('Responder APIs'))
.hasText('Disabled', 'responder apis value renders Disabled');
assert.dom(SELECTORS.rowValue('Interval')).doesNotExist();
assert.dom(SELECTORS.rowValue('Auto-rebuild')).doesNotExist();
assert.dom(SELECTORS.rowValue('Auto-rebuild grace period')).doesNotExist();
assert.dom(SELECTORS.rowValue('Delta CRL building')).doesNotExist();
assert.dom(SELECTORS.rowValue('Delta rebuild interval')).doesNotExist();
});
test('shows the correct information on mount configuration section', async function (assert) {
@ -104,24 +148,28 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
{ owner: this.engine }
);
assert.dom(SELECTORS.engineTypeLabel).hasText('Secret engine type', 'engine type row label renders');
assert.dom(SELECTORS.engineTypeRowVal).hasText('pki', 'engine type row value renders');
assert.dom(SELECTORS.pathLabel).hasText('Path', 'path row label renders');
assert.dom(SELECTORS.pathRowVal).hasText('/pki-test', 'path row value renders');
assert.dom(SELECTORS.accessorLabel).hasText('Accessor', 'accessor row label renders');
assert.dom(SELECTORS.accessorRowVal).hasText('pki_33345b0d', 'accessor row value renders');
assert.dom(SELECTORS.localLabel).hasText('Local', 'local row label renders');
assert.dom(SELECTORS.localRowVal).hasText('No', 'local row value renders');
assert.dom(SELECTORS.sealWrapLabel).hasText('Seal wrap', 'seal wrap row label renders');
assert
.dom(SELECTORS.sealWrapRowVal)
.dom(SELECTORS.rowLabel('Secret engine type'))
.hasText('Secret engine type', 'engine type row label renders');
assert.dom(SELECTORS.rowValue('Secret engine type')).hasText('pki', 'engine type row value renders');
assert.dom(SELECTORS.rowLabel('Path')).hasText('Path', 'path row label renders');
assert.dom(SELECTORS.rowValue('Path')).hasText('/pki-test', 'path row value renders');
assert.dom(SELECTORS.rowLabel('Accessor')).hasText('Accessor', 'accessor row label renders');
assert.dom(SELECTORS.rowValue('Accessor')).hasText('pki_33345b0d', 'accessor row value renders');
assert.dom(SELECTORS.rowLabel('Local')).hasText('Local', 'local row label renders');
assert.dom(SELECTORS.rowValue('Local')).hasText('No', 'local row value renders');
assert.dom(SELECTORS.rowLabel('Seal wrap')).hasText('Seal wrap', 'seal wrap row label renders');
assert
.dom(SELECTORS.rowValue('Seal wrap'))
.hasText('Yes', 'seal wrap row value renders Yes if sealWrap is true');
assert.dom(SELECTORS.maxLeaseTtlLabel).hasText('Max lease TTL', 'max lease label renders');
assert.dom(SELECTORS.maxLeaseTtlRowVal).hasText('400h', 'max lease value renders');
assert.dom(SELECTORS.rowLabel('Max lease TTL')).hasText('Max lease TTL', 'max lease label renders');
assert.dom(SELECTORS.rowValue('Max lease TTL')).hasText('400h', 'max lease value renders');
assert
.dom(SELECTORS.allowedManagedKeysLabel)
.dom(SELECTORS.rowLabel('Allowed managed keys'))
.hasText('Allowed managed keys', 'allowed managed keys label renders');
assert.dom(SELECTORS.allowedManagedKeysRowVal).hasText('Yes', 'allowed managed keys value renders');
assert
.dom(SELECTORS.rowValue('Allowed managed keys'))
.hasText('Yes', 'allowed managed keys value renders');
});
test('shows mount configuration when hasConfig is false', async function (assert) {
@ -133,23 +181,27 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
{ owner: this.engine }
);
assert.dom(SELECTORS.engineTypeLabel).hasText('Secret engine type', 'engine type row label renders');
assert.dom(SELECTORS.engineTypeRowVal).hasText('pki', 'engine type row value renders');
assert.dom(SELECTORS.pathLabel).hasText('Path', 'path row label renders');
assert.dom(SELECTORS.pathRowVal).hasText('/pki-test', 'path row value renders');
assert.dom(SELECTORS.accessorLabel).hasText('Accessor', 'accessor row label renders');
assert.dom(SELECTORS.accessorRowVal).hasText('pki_33345b0d', 'accessor row value renders');
assert.dom(SELECTORS.localLabel).hasText('Local', 'local row label renders');
assert.dom(SELECTORS.localRowVal).hasText('No', 'local row value renders');
assert.dom(SELECTORS.sealWrapLabel).hasText('Seal wrap', 'seal wrap row label renders');
assert
.dom(SELECTORS.sealWrapRowVal)
.dom(SELECTORS.rowLabel('Secret engine type'))
.hasText('Secret engine type', 'engine type row label renders');
assert.dom(SELECTORS.rowValue('Secret engine type')).hasText('pki', 'engine type row value renders');
assert.dom(SELECTORS.rowLabel('Path')).hasText('Path', 'path row label renders');
assert.dom(SELECTORS.rowValue('Path')).hasText('/pki-test', 'path row value renders');
assert.dom(SELECTORS.rowLabel('Accessor')).hasText('Accessor', 'accessor row label renders');
assert.dom(SELECTORS.rowValue('Accessor')).hasText('pki_33345b0d', 'accessor row value renders');
assert.dom(SELECTORS.rowLabel('Local')).hasText('Local', 'local row label renders');
assert.dom(SELECTORS.rowValue('Local')).hasText('No', 'local row value renders');
assert.dom(SELECTORS.rowLabel('Seal wrap')).hasText('Seal wrap', 'seal wrap row label renders');
assert
.dom(SELECTORS.rowValue('Seal wrap'))
.hasText('Yes', 'seal wrap row value renders Yes if sealWrap is true');
assert.dom(SELECTORS.maxLeaseTtlLabel).hasText('Max lease TTL', 'max lease label renders');
assert.dom(SELECTORS.maxLeaseTtlRowVal).hasText('400h', 'max lease value renders');
assert.dom(SELECTORS.rowLabel('Max lease TTL')).hasText('Max lease TTL', 'max lease label renders');
assert.dom(SELECTORS.rowValue('Max lease TTL')).hasText('400h', 'max lease value renders');
assert
.dom(SELECTORS.allowedManagedKeysLabel)
.dom(SELECTORS.rowLabel('Allowed managed keys'))
.hasText('Allowed managed keys', 'allowed managed keys label renders');
assert.dom(SELECTORS.allowedManagedKeysRowVal).hasText('Yes', 'allowed managed keys value renders');
assert
.dom(SELECTORS.rowValue('Allowed managed keys'))
.hasText('Yes', 'allowed managed keys value renders');
});
});

View File

@ -0,0 +1,211 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { click, fillIn, render } from '@ember/test-helpers';
import { setupEngine } from 'ember-engines/test-support';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-configuration-edit';
import sinon from 'sinon';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
module('Integration | Component | page/pki-configuration-edit', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(async function () {
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.context = { owner: this.engine }; // this.engine set by setupEngine
this.store = this.owner.lookup('service:store');
this.cancelSpy = sinon.spy();
this.backend = 'pki-engine';
// both models only use findRecord. API parameters for pki/crl
// are set by default backend values when the engine is mounted
this.store.pushPayload('pki/crl', {
modelName: 'pki/crl',
id: this.backend,
auto_rebuild: false,
auto_rebuild_grace_period: '12h',
delta_rebuild_interval: '15m',
disable: false,
enable_delta: false,
expiry: '72h',
ocsp_disable: false,
ocsp_expiry: '12h',
});
this.store.pushPayload('pki/urls', {
modelName: 'pki/urls',
id: this.backend,
issuing_certificates: ['hashicorp.com'],
crl_distribution_points: ['some-crl-distribution.com'],
ocsp_servers: ['ocsp-stuff.com'],
});
this.urls = this.store.peekRecord('pki/urls', this.backend);
this.crl = this.store.peekRecord('pki/crl', this.backend);
});
test('it renders with config data and updates config', async function (assert) {
assert.expect(27);
this.server.post(`/${this.backend}/config/crl`, (schema, req) => {
assert.ok(true, 'request made to save crl config');
assert.propEqual(
JSON.parse(req.requestBody),
{
auto_rebuild: true,
auto_rebuild_grace_period: '24h',
delta_rebuild_interval: '45m',
disable: false,
enable_delta: true,
expiry: '1152h',
ocsp_disable: false,
ocsp_expiry: '24h',
},
'it updates crl model attributes'
);
});
this.server.post(`/${this.backend}/config/urls`, (schema, req) => {
assert.ok(true, 'request made to save urls config');
assert.propEqual(
JSON.parse(req.requestBody),
{
crl_distribution_points: ['test-crl.com'],
issuing_certificates: ['update-hashicorp.com'],
ocsp_servers: ['ocsp.com'],
},
'it updates url model attributes'
);
});
await render(
hbs`
<Page::PkiConfigurationEdit
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
assert.dom(SELECTORS.urlsEditSection).exists('renders urls section');
assert.dom(SELECTORS.crlEditSection).exists('renders crl section');
assert.dom(SELECTORS.cancelButton).exists();
this.urls.eachAttribute((name) => {
assert.dom(SELECTORS.urlFieldInput(name)).exists(`renders ${name} input`);
});
assert.dom(SELECTORS.urlFieldInput('issuingCertificates')).hasValue('hashicorp.com');
assert.dom(SELECTORS.urlFieldInput('crlDistributionPoints')).hasValue('some-crl-distribution.com');
assert.dom(SELECTORS.urlFieldInput('ocspServers')).hasValue('ocsp-stuff.com');
await fillIn(SELECTORS.urlFieldInput('issuingCertificates'), 'update-hashicorp.com');
await fillIn(SELECTORS.urlFieldInput('crlDistributionPoints'), 'test-crl.com');
await fillIn(SELECTORS.urlFieldInput('ocspServers'), 'ocsp.com');
// confirm default toggle state and text
this.crl.eachAttribute((name, { options }) => {
if (['expiry', 'ocspExpiry'].includes(name)) {
assert.dom(SELECTORS.crlToggleInput(name)).isChecked(`${name} defaults to toggled on`);
assert.dom(SELECTORS.crlFieldLabel(name)).hasTextContaining(options.label);
assert.dom(SELECTORS.crlFieldLabel(name)).hasTextContaining(options.helperTextEnabled);
}
if (['autoRebuildGracePeriod', 'deltaRebuildInterval'].includes(name)) {
assert.dom(SELECTORS.crlToggleInput(name)).isNotChecked(`${name} defaults off`);
assert.dom(SELECTORS.crlFieldLabel(name)).hasTextContaining(options.labelDisabled);
assert.dom(SELECTORS.crlFieldLabel(name)).hasTextContaining(options.helperTextDisabled);
}
});
// toggle everything on
await click(SELECTORS.crlToggleInput('autoRebuildGracePeriod'));
assert
.dom(SELECTORS.crlFieldLabel('autoRebuildGracePeriod'))
.hasTextContaining(
'Auto-rebuild on Vault will rebuild the CRL in the below grace period before expiration',
'it renders auto rebuild toggled on text'
);
await click(SELECTORS.crlToggleInput('deltaRebuildInterval'));
assert
.dom(SELECTORS.crlFieldLabel('deltaRebuildInterval'))
.hasTextContaining(
'Delta CRL building on Vault will rebuild the delta CRL at the interval below:',
'it renders delta crl build toggled on text'
);
// assert ttl values update model attributes
await fillIn(SELECTORS.crlTtlInput('Expiry'), '48');
await fillIn(SELECTORS.crlTtlInput('Auto-rebuild on'), '24');
await fillIn(SELECTORS.crlTtlInput('Delta CRL building on'), '45');
await fillIn(SELECTORS.crlTtlInput('OCSP responder APIs enabled'), '24');
await click(SELECTORS.saveButton);
});
test('it removes urls and sends false crl values', async function (assert) {
assert.expect(8);
this.server.post(`/${this.backend}/config/crl`, (schema, req) => {
assert.ok(true, 'request made to save crl config');
assert.propEqual(
JSON.parse(req.requestBody),
{
auto_rebuild: false,
auto_rebuild_grace_period: '12h',
delta_rebuild_interval: '15m',
disable: true,
enable_delta: false,
expiry: '72h',
ocsp_disable: true,
ocsp_expiry: '12h',
},
'crl payload has correct data'
);
});
this.server.post(`/${this.backend}/config/urls`, (schema, req) => {
assert.ok(true, 'request made to save urls config');
assert.propEqual(
JSON.parse(req.requestBody),
{
crl_distribution_points: [],
issuing_certificates: [],
ocsp_servers: [],
},
'url payload has empty arrays'
);
});
await render(
hbs`
<Page::PkiConfigurationEdit
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
await click(SELECTORS.deleteButton('issuingCertificates'));
await click(SELECTORS.deleteButton('crlDistributionPoints'));
await click(SELECTORS.deleteButton('ocspServers'));
// toggle everything off
await click(SELECTORS.crlToggleInput('expiry'));
assert.dom(SELECTORS.crlFieldLabel('expiry')).hasText('No expiry The CRL will not be built.');
assert
.dom(SELECTORS.crlToggleInput('autoRebuildGracePeriod'))
.doesNotExist('expiry off hides the auto rebuild toggle');
assert
.dom(SELECTORS.crlToggleInput('deltaRebuildInterval'))
.doesNotExist('expiry off hides delta crl toggle');
await click(SELECTORS.crlToggleInput('ocspExpiry'));
assert
.dom(SELECTORS.crlFieldLabel('ocspExpiry'))
.hasTextContaining(
'OCSP responder APIs disabled Requests cannot be made to check if an individual certificate is valid.',
'it renders correct toggled off text'
);
await click(SELECTORS.saveButton);
});
});

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Unit | Adapter | pki/crl', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.backend = 'pki-engine';
});
test('it should make request to correct endpoint on update', async function (assert) {
assert.expect(1);
this.server.post(`/${this.backend}/config/crl`, () => {
assert.ok(true, 'request made to correct endpoint on update');
});
this.store.pushPayload('pki/crl', {
modelName: 'pki/crl',
id: this.backend,
});
const model = this.store.peekRecord('pki/crl', this.backend);
await model.save();
});
test('it should make request to correct endpoint on find', async function (assert) {
assert.expect(1);
this.server.get(`/${this.backend}/config/crl`, () => {
assert.ok(true, 'request is made to correct endpoint on find');
return { data: { id: this.backend } };
});
this.store.findRecord('pki/crl', this.backend);
});
});

View File

@ -4,50 +4,42 @@
*/
import { module, test } from 'qunit';
import { resolve } from 'rsvp';
import { setupTest } from 'vault/tests/helpers';
const storeStub = {
pushPayload() {},
serializerFor() {
return {
serializeIntoHash() {},
};
},
};
const makeSnapshot = (obj) => {
const snapshot = {
id: obj.id,
record: {
...obj,
},
};
snapshot.attr = (attr) => snapshot[attr];
return snapshot;
};
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Unit | Adapter | pki/urls', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
test('pki url endpoints', function (assert) {
let url, method;
const adapter = this.owner.factoryFor('adapter:pki/urls').create({
ajax: (...args) => {
[url, method] = args;
return resolve({});
},
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.backend = 'pki-engine';
});
test('it should make request to correct endpoint on update', async function (assert) {
assert.expect(1);
this.server.post(`/${this.backend}/config/urls`, () => {
assert.ok(true, 'request made to correct endpoint on update');
});
adapter.createRecord(storeStub, 'pki/urls', makeSnapshot({ id: 'pki-create' }));
assert.strictEqual(url, '/v1/pki-create/config/urls', 'create url OK');
assert.strictEqual(method, 'POST', 'create method OK');
this.store.pushPayload('pki/urls', {
modelName: 'pki/urls',
id: this.backend,
});
adapter.updateRecord(storeStub, 'pki/urls', makeSnapshot({ id: 'pki-update' }));
assert.strictEqual(url, '/v1/pki-update/config/urls', 'update url OK');
assert.strictEqual(method, 'PUT', 'update method OK');
const model = this.store.peekRecord('pki/urls', this.backend);
await model.save();
});
adapter.findRecord(null, 'capabilities', 'pki-find');
assert.strictEqual(url, '/v1/pki-find/config/urls', 'find url OK');
assert.strictEqual(method, 'GET', 'find method OK');
test('it should make request to correct endpoint on find', async function (assert) {
assert.expect(1);
this.server.get(`/${this.backend}/config/urls`, () => {
assert.ok(true, 'request is made to correct endpoint on find');
return { data: { id: this.backend } };
});
this.store.findRecord('pki/urls', this.backend);
});
});

View File

@ -7,7 +7,13 @@
export interface FormField {
name: string;
type: string;
options: unknown;
options: AttributeOptions;
}
interface AttributeOptions {
label: string;
mapToBoolean: string;
isOppositeValue: boolean;
}
export interface FormFieldGroups {
@ -40,3 +46,10 @@ export interface Breadcrumb {
route?: string;
linkExternal?: boolean;
}
export interface TtlEvent {
enabled: boolean;
seconds: number;
timeString: string;
goSafeTimeString: string;
}

16
ui/types/vault/models/pki/crl.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import Model from '@ember-data/model';
import { FormField } from 'vault/app-types';
export default class PkiCrlModel extends Model {
autoRebuild: boolean;
autoRebuildGracePeriod: string;
enableDelta: boolean;
expiry: string;
deltaRebuildInterval: string;
disable: boolean;
ocspExpiry: string;
ocspDisable: boolean;
crlPath: string;
formFields: FormField[];
get canSet(): boolean;
}

11
ui/types/vault/models/pki/urls.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
import Model from '@ember-data/model';
export default class PkiUrlsModel extends Model {
get useOpenAPI(): boolean;
getHelpUrl(backendPath: string): string;
issuingCertificates: array;
crlDistributionPoints: array;
ocspServers: array;
urlsPath: string;
get canSet(): boolean;
}