Pki Generate Intermediate CSR (#18807)

* adds pki generate csr component

* adds keyParamsByType helper to pki-generate-toggle-groups component

* removes unused router service from pki-generate-csr component

* updates common pki generate form fields

* addresses feedback and adds tests
This commit is contained in:
Jordan Reimer 2023-01-24 12:32:17 -07:00 committed by GitHub
parent bf3e266929
commit e873d27e83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 522 additions and 143 deletions

View File

@ -1,5 +1,6 @@
import Model, { attr } from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withFormFields } from 'vault/decorators/model-form-fields'; import { withFormFields } from 'vault/decorators/model-form-fields';
import { withModelValidations } from 'vault/decorators/model-validations'; import { withModelValidations } from 'vault/decorators/model-validations';
@ -10,7 +11,7 @@ const validations = {
issuerName: [ issuerName: [
{ {
validator(model) { validator(model) {
if (model.issuerName === 'default') return false; if (model.actionType === 'generate-root' && model.issuerName === 'default') return false;
return true; return true;
}, },
message: 'Issuer name must be unique across all issuers and not be the reserved value default.', message: 'Issuer name must be unique across all issuers and not be the reserved value default.',
@ -23,6 +24,8 @@ const validations = {
export default class PkiActionModel extends Model { export default class PkiActionModel extends Model {
@service secretMountPath; @service secretMountPath;
@tracked actionType; // used to toggle between different form fields when creating configuration
/* actionType import */ /* actionType import */
@attr('string') pemBundle; @attr('string') pemBundle;
@ -33,7 +36,7 @@ export default class PkiActionModel extends Model {
}) })
type; type;
@attr('string') issuerName; // REQUIRED, cannot be "default" @attr('string') issuerName; // REQUIRED for generate-root actionType, cannot be "default"
@attr('string') keyName; // cannot be "default" @attr('string') keyName; // cannot be "default"
@ -123,6 +126,11 @@ export default class PkiActionModel extends Model {
}) })
serialNumber; serialNumber;
@attr('boolean', {
subText: 'Whether to add a Basic Constraints extension with CA: true.',
})
addBasicConstraints;
@attr({ @attr({
label: 'Backdate validity', label: 'Backdate validity',
detailsLabel: 'Issued certificate backdating', detailsLabel: 'Issued certificate backdating',

View File

@ -25,35 +25,41 @@ export default class PkiActionSerializer extends ApplicationSerializer {
_allowedParamsByType(actionType, type) { _allowedParamsByType(actionType, type) {
const keyFields = keyParamsByType(type).map((attrName) => underscore(attrName).toLowerCase()); const keyFields = keyParamsByType(type).map((attrName) => underscore(attrName).toLowerCase());
switch (actionType) { const commonProps = [
case 'import':
return ['pem_bundle'];
case 'generate-root':
return [
'alt_names', 'alt_names',
'common_name', 'common_name',
'country', 'country',
'exclude_cn_from_sans', 'exclude_cn_from_sans',
'format', 'format',
'ip_sans', 'ip_sans',
'issuer_name',
'locality', 'locality',
'max_path_length',
'not_after',
'not_before_duration',
'organization', 'organization',
'other_sans', 'other_sans',
'ou', 'ou',
'permitted_dns_domains',
'postal_code', 'postal_code',
'private_key_format',
'province', 'province',
'serial_number', 'serial_number',
'street_address', 'street_address',
'ttl',
'type', 'type',
'uri_sans',
...keyFields, ...keyFields,
]; ];
switch (actionType) {
case 'import':
return ['pem_bundle'];
case 'generate-root':
return [
...commonProps,
'issuer_name',
'max_path_length',
'not_after',
'not_before_duration',
'permitted_dns_domains',
'private_key_format',
'ttl',
];
case 'generate-csr':
return [...commonProps, 'add_basic_constraints'];
default: default:
// if type doesn't match, serialize all // if type doesn't match, serialize all
return null; return null;

View File

@ -304,7 +304,7 @@
{{on "input" this.onChangeWithEvent}} {{on "input" this.onChangeWithEvent}}
{{on "change" this.onChangeWithEvent}} {{on "change" this.onChangeWithEvent}}
{{on "keyup" this.handleKeyUp}} {{on "keyup" this.handleKeyUp}}
class="input {{if this.validationError 'has-error-border'}} has-bottom-margin-m" class="input {{if this.validationError 'has-error-border' 'has-bottom-margin-m'}}"
maxLength={{@attr.options.characterLimit}} maxLength={{@attr.options.characterLimit}}
/> />
{{#if @attr.options.validationAttr}} {{#if @attr.options.validationAttr}}

View File

@ -2,7 +2,7 @@
<div class="columns"> <div class="columns">
{{#each this.configTypes as |option|}} {{#each this.configTypes as |option|}}
<div class="column is-flex"> <div class="column is-flex">
<label for={{option.key}} class="box-label is-column {{if (eq this.actionType option.key) 'is-selected'}}"> <label for={{option.key}} class="box-label is-column {{if (eq @config.actionType option.key) 'is-selected'}}">
<div> <div>
<h3 class="box-label-header title is-6"> <h3 class="box-label-header title is-6">
<Icon @size="24" @name={{option.icon}} /> <Icon @size="24" @name={{option.icon}} />
@ -17,8 +17,8 @@
id={{option.key}} id={{option.key}}
name="pki-config-type" name="pki-config-type"
@value={{option.key}} @value={{option.key}}
@groupValue={{this.actionType}} @groupValue={{@config.actionType}}
@onChange={{fn (mut this.actionType) option.key}} @onChange={{fn (mut @config.actionType) option.key}}
data-test-pki-config-option={{option.key}} data-test-pki-config-option={{option.key}}
/> />
<label for={{option.key}}></label> <label for={{option.key}}></label>
@ -27,22 +27,26 @@
</div> </div>
{{/each}} {{/each}}
</div> </div>
{{#if (eq this.actionType "import")}} {{#if (eq @config.actionType "import")}}
<PkiCaCertificateImport <PkiCaCertificateImport
@model={{@config}} @model={{@config}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.overview"}} @onCancel={{transition-to "vault.cluster.secrets.backend.pki.overview"}}
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers"}} @onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
@adapterOptions={{hash actionType=this.actionType useIssuer=@config.canImportBundle}} @adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canImportBundle}}
/> />
{{else if (eq this.actionType "generate-root")}} {{else if (eq @config.actionType "generate-root")}}
<PkiGenerateRoot <PkiGenerateRoot
@model={{@config}} @model={{@config}}
@urls={{@urls}} @urls={{@urls}}
@onCancel={{this.cancel}} @onCancel={{this.cancel}}
@adapterOptions={{hash actionType="generate-root" useIssuer=@config.canGenerateIssuerRoot}} @adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canGenerateIssuerRoot}}
/>
{{else if (eq @config.actionType "generate-csr")}}
<PkiGenerateCsr
@model={{@config}}
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
@onCancel={{this.cancel}}
/> />
{{else if (eq this.actionType "generate-csr")}}
<code>POST /intermediate/generate/:type ~or~ /issuers/generate/intermediate/:type</code>
{{else}} {{else}}
<EmptyState <EmptyState
@title="Choose an option" @title="Choose an option"

View File

@ -4,7 +4,6 @@ import { inject as service } from '@ember/service';
import Store from '@ember-data/store'; import Store from '@ember-data/store';
import Router from '@ember/routing/router'; import Router from '@ember/routing/router';
import FlashMessageService from 'vault/services/flash-messages'; import FlashMessageService from 'vault/services/flash-messages';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
import PkiActionModel from 'vault/models/pki/action'; import PkiActionModel from 'vault/models/pki/action';
@ -23,7 +22,6 @@ export default class PkiConfigureForm extends Component<Args> {
@service declare readonly store: Store; @service declare readonly store: Store;
@service declare readonly router: Router; @service declare readonly router: Router;
@service declare readonly flashMessages: FlashMessageService; @service declare readonly flashMessages: FlashMessageService;
@tracked actionType = '';
get configTypes() { get configTypes() {
return [ return [
@ -51,24 +49,6 @@ export default class PkiConfigureForm extends Component<Args> {
]; ];
} }
shouldUseIssuerEndpoint() {
const { config } = this.args;
// To determine which endpoint the config adapter should use,
// we want to check capabilities on the newer endpoints (those
// prefixed with "issuers") and use the old path as fallback
// if user does not have permissions.
switch (this.actionType) {
case 'import':
return config.canImportBundle;
case 'generate-root':
return config.canGenerateIssuerRoot;
case 'generate-csr':
return config.canGenerateIssuerIntermediate;
default:
return false;
}
}
@action cancel() { @action cancel() {
this.args.config.rollbackAttributes(); this.args.config.rollbackAttributes();
this.router.transitionTo('vault.cluster.secrets.backend.pki.overview'); this.router.transitionTo('vault.cluster.secrets.backend.pki.overview');

View File

@ -0,0 +1,28 @@
<form {{on "submit" (perform this.save)}}>
<MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
<h2 class="title is-size-5 has-border-bottom-light page-header">
CSR parameters
</h2>
{{#each this.formFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
<PkiGenerateToggleGroups @model={{@model}} />
<div class="field is-grouped box is-fullwidth is-bottomless has-top-margin-l">
<div class="control">
<button type="submit" class="button is-primary" data-test-save>
Done
</button>
<button {{on "click" this.cancel}} type="button" class="button has-left-margin-s" data-test-cancel>
Cancel
</button>
</div>
{{#if this.alert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.alert}} @mimicRefresh={{true}} data-test-alert />
</div>
{{/if}}
</div>
</form>

View File

@ -0,0 +1,76 @@
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 { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import FlashMessageService from 'vault/services/flash-messages';
import PkiActionModel from 'vault/models/pki/action';
import errorMessage from 'vault/utils/error-message';
interface Args {
model: PkiActionModel;
useIssuer: boolean;
onSave: CallableFunction;
onCancel: CallableFunction;
}
export default class PkiGenerateIntermediateComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@tracked modelValidations = null;
@tracked error: string | null = null;
@tracked alert: string | null = null;
formFields;
constructor(owner: unknown, args: Args) {
super(owner, args);
this.formFields = expandAttributeMeta(this.args.model, [
'type',
'commonName',
'excludeCnFromSans',
'format',
'serialNumber',
'addBasicConstraints',
]);
}
@action
cancel() {
this.args.model.unloadRecord();
this.args.onCancel();
}
async getCapability(): Promise<boolean> {
try {
const issuerCapabilities = await this.args.model.generateIssuerCsrPath;
return issuerCapabilities.get('canCreate') === true;
} catch (error) {
return false;
}
}
@task
@waitFor
*save(event: Event): Generator<Promise<boolean | PkiActionModel>> {
event.preventDefault();
try {
const { model, onSave } = this.args;
const { isValid, state, invalidFormMessage } = model.validate();
if (isValid) {
const useIssuer = yield this.getCapability();
yield model.save({ adapterOptions: { actionType: 'generate-csr', useIssuer } });
this.flashMessages.success('Successfully generated CSR.');
onSave();
} else {
this.modelValidations = state;
this.alert = invalidFormMessage;
}
} catch (e) {
this.error = errorMessage(e);
this.alert = 'There was a problem generating the CSR.';
}
}
}

View File

@ -20,58 +20,7 @@
{{/let}} {{/let}}
{{/each}} {{/each}}
{{! togglable groups }} <PkiGenerateToggleGroups @model={{@model}} />
{{#each-in this.groups as |group fields|}}
<ToggleButton
@isOpen={{eq this.showGroup group}}
@openLabel={{concat "Hide " group}}
@closedLabel={{group}}
@onClick={{fn this.toggleGroup group}}
class="is-block"
data-test-toggle-group={{group}}
/>
{{#if (eq this.showGroup group)}}
<div class="box is-marginless" data-test-group={{group}}>
{{#if (eq group "Key parameters")}}
<p class="has-bottom-margin-m" data-test-toggle-group-description>
{{#if (eq @model.type "internal")}}
This certificate type is internal. This means that the private key will not be returned and cannot be retrieved
later. Below, you will name the key and define its type and key bits.
{{else if (eq @model.type "kms")}}
This certificate type is kms, meaning managed keys will be used. Below, you will name the key and tell Vault
where to find it in your KMS or HSM.
<DocLink @path="/vault/docs/enterprise/managed-keys">Learn more about managed keys.</DocLink>
{{else if (eq @model.type "exported")}}
This certificate type is exported. This means the private key will be returned in the response. Below, you will
name the key and define its type and key bits.
{{else if (eq @model.type "existing")}}
You chose to use an existing key. This means that well use the key reference to create the CSR or root. Please
provide the reference to the key.
{{else}}
Please choose a type to see key parameter options.
{{/if}}
</p>
{{#if this.keyParamFields}}
<PkiKeyParameters @model={{@model}} @fields={{this.keyParamFields}} />
{{/if}}
{{else}}
<p class="has-bottom-margin-m" data-test-toggle-group-description>
{{#if (eq group "Subject Alternative Name (SAN) Options")}}
SAN fields are an extension that allow you specify additional host names (sites, IP addresses, common names,
etc.) to be protected by a single certificate.
{{else if (eq group "Additional subject fields")}}
These fields provide more information about the client to which the certificate belongs.
{{/if}}
</p>
{{#each fields as |fieldName|}}
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
<FormField data-test-field @attr={{attr}} @mode="create" @model={{@model}} />
{{/let}}
{{/each}}
{{/if}}
</div>
{{/if}}
{{/each-in}}
{{#if @urls}} {{#if @urls}}
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-urls-section> <fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-urls-section>

View File

@ -4,7 +4,6 @@ import { waitFor } from '@ember/test-waiters';
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import { keyParamsByType } from 'pki/utils/action-params';
import errorMessage from 'vault/utils/error-message'; import errorMessage from 'vault/utils/error-message';
/** /**
@ -32,11 +31,6 @@ export default class PkiGenerateRootComponent extends Component {
@tracked errorBanner = ''; @tracked errorBanner = '';
@tracked invalidFormAlert = ''; @tracked invalidFormAlert = '';
@action
toggleGroup(group, isOpen) {
this.showGroup = isOpen ? group : null;
}
get defaultFields() { get defaultFields() {
return [ return [
'type', 'type',
@ -49,14 +43,6 @@ export default class PkiGenerateRootComponent extends Component {
'maxPathLength', 'maxPathLength',
]; ];
} }
get keyParamFields() {
const { type } = this.args.model;
if (!type) return null;
const fields = keyParamsByType(type);
return fields.map((fieldName) => {
return this.args.model.allFields.find((attr) => attr.name === fieldName);
});
}
@action cancel() { @action cancel() {
// Generate root form will always have a new model // Generate root form will always have a new model
@ -64,29 +50,6 @@ export default class PkiGenerateRootComponent extends Component {
this.args.onCancel(); this.args.onCancel();
} }
get groups() {
return {
'Key parameters': this.keyParamFields,
'Subject Alternative Name (SAN) Options': [
'excludeCnFromSans',
'serialNumber',
'altNames',
'ipSans',
'uriSans',
'otherSans',
],
'Additional subject fields': [
'ou',
'organization',
'country',
'locality',
'province',
'streetAddress',
'postalCode',
],
};
}
@action @action
checkFormValidity() { checkFormValidity() {
if (this.args.model.validate) { if (this.args.model.validate) {

View File

@ -0,0 +1,51 @@
{{#each-in this.groups as |group fields|}}
<ToggleButton
@isOpen={{eq this.showGroup group}}
@openLabel={{concat "Hide " group}}
@closedLabel={{group}}
@onClick={{fn this.toggleGroup group}}
class="is-block"
data-test-toggle-group={{group}}
/>
{{#if (eq this.showGroup group)}}
<div class="box is-marginless" data-test-group={{group}}>
{{#if (eq group "Key parameters")}}
<p class="has-bottom-margin-m" data-test-toggle-group-description>
{{#if (eq @model.type "internal")}}
This certificate type is internal. This means that the private key will not be returned and cannot be retrieved
later. Below, you will name the key and define its type and key bits.
{{else if (eq @model.type "kms")}}
This certificate type is kms, meaning managed keys will be used. Below, you will name the key and tell Vault
where to find it in your KMS or HSM.
<DocLink @path="/vault/docs/enterprise/managed-keys">Learn more about managed keys.</DocLink>
{{else if (eq @model.type "exported")}}
This certificate type is exported. This means the private key will be returned in the response. Below, you will
name the key and define its type and key bits.
{{else if (eq @model.type "existing")}}
You chose to use an existing key. This means that well use the key reference to create the CSR or root. Please
provide the reference to the key.
{{else}}
Please choose a type to see key parameter options.
{{/if}}
</p>
{{#if this.keyParamFields}}
<PkiKeyParameters @model={{@model}} @fields={{this.keyParamFields}} />
{{/if}}
{{else}}
<p class="has-bottom-margin-m" data-test-toggle-group-description>
{{#if (eq group "Subject Alternative Name (SAN) Options")}}
SAN fields are an extension that allow you specify additional host names (sites, IP addresses, common names,
etc.) to be protected by a single certificate.
{{else if (eq group "Additional subject fields")}}
These fields provide more information about the client to which the certificate belongs.
{{/if}}
</p>
{{#each fields as |fieldName|}}
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
<FormField data-test-field @attr={{attr}} @mode="create" @model={{@model}} />
{{/let}}
{{/each}}
{{/if}}
</div>
{{/if}}
{{/each-in}}

View File

@ -0,0 +1,48 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { keyParamsByType } from 'pki/utils/action-params';
import PkiActionModel from 'vault/models/pki/action';
interface Args {
model: PkiActionModel;
}
export default class PkiGenerateToggleGroupsComponent extends Component<Args> {
@tracked showGroup: string | null = null;
get keyParamFields() {
const { type } = this.args.model;
if (!type) return null;
const fields = keyParamsByType(type);
return fields.map((fieldName) => {
return this.args.model.allFields.find((attr) => attr.name === fieldName);
});
}
get groups() {
const groups = {
'Key parameters': this.keyParamFields,
'Subject Alternative Name (SAN) Options': ['altNames', 'ipSans', 'uriSans', 'otherSans'],
'Additional subject fields': [
'ou',
'organization',
'country',
'locality',
'province',
'streetAddress',
'postalCode',
],
};
// excludeCnFromSans and serialNumber are present in default fields for generate-csr -- only include for other types
if (this.args.model.actionType !== 'generate-csr') {
groups['Subject Alternative Name (SAN) Options'].unshift('excludeCnFromSans', 'serialNumber');
}
return groups;
}
@action
toggleGroup(group: string, isOpen: boolean) {
this.showGroup = isOpen ? group : null;
}
}

View File

@ -1,3 +1,12 @@
import Route from '@ember/routing/route'; import PkiIssuersIndexRoute from '.';
export default class PkiIssuersGenerateIntermediateRoute extends Route {} export default class PkiIssuersGenerateIntermediateRoute extends PkiIssuersIndexRoute {
model() {
return this.store.createRecord('pki/action', { actionType: 'generate-csr' });
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs.push({ label: 'generate CSR' });
}
}

View File

@ -1 +1,16 @@
route: issuers.generate-intermediate <PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
Generate intermediate CSR
</h1>
</p.levelLeft>
</PageHeader>
<PkiGenerateCsr
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
/>

View File

@ -127,6 +127,21 @@ module('Acceptance | pki workflow', function (hooks) {
assert.dom(SELECTORS.issuerDetails.valueByName('Common name')).hasText('my-common-name'); assert.dom(SELECTORS.issuerDetails.valueByName('Common name')).hasText('my-common-name');
assert.dom(SELECTORS.issuerDetails.valueByName('Issuer name')).hasText('my-first-issuer'); assert.dom(SELECTORS.issuerDetails.valueByName('Issuer name')).hasText('my-first-issuer');
}); });
test('it should generate intermediate csr', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
await click(SELECTORS.emptyStateLink);
await click(SELECTORS.configuration.optionByKey('generate-csr'));
await fillIn(SELECTORS.configuration.typeField, 'exported');
await fillIn(SELECTORS.configuration.inputByName('commonName'), 'my-common-name');
await click('[data-test-save]');
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/issuers`,
'Transitions to issuers on save success'
);
});
}); });
module('roles', function (hooks) { module('roles', function (hooks) {

View File

@ -0,0 +1,81 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { click, fillIn, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Integration | Component | PkiGenerateCsr', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(async function () {
this.owner.lookup('service:secretMountPath').update('pki-test');
this.model = this.owner
.lookup('service:store')
.createRecord('pki/action', { actionType: 'generate-csr' });
this.server.post('/sys/capabilities-self', () => ({
data: {
capabilities: ['root'],
'pki-test/issuers/generate/intermediate/exported': ['root'],
},
}));
});
test('it should render fields and save', async function (assert) {
assert.expect(9);
this.server.post('/pki-test/issuers/generate/intermediate/exported', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.strictEqual(payload.common_name, 'foo', 'Request made to correct endpoint on save');
});
this.onSave = () => assert.ok(true, 'onSave action fires');
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onSave={{this.onSave}} />`, {
owner: this.engine,
});
const fields = [
'type',
'commonName',
'excludeCnFromSans',
'format',
'serialNumber',
'addBasicConstraints',
];
fields.forEach((key) => {
assert.dom(`[data-test-input="${key}"]`).exists(`${key} form field renders`);
});
assert.dom('[data-test-toggle-group]').exists({ count: 3 }, 'Toggle groups render');
await fillIn('[data-test-input="type"]', 'exported');
await fillIn('[data-test-input="commonName"]', 'foo');
await click('[data-test-save]');
});
test('it should display validation errors', async function (assert) {
assert.expect(4);
this.onCancel = () => assert.ok(true, 'onCancel action fires');
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onCancel={{this.onCancel}} />`, {
owner: this.engine,
});
await click('[data-test-save]');
assert
.dom('[data-test-field-validation="type"]')
.hasText('Type is required.', 'Type validation error renders');
assert
.dom('[data-test-field="commonName"] [data-test-inline-alert]')
.hasText('Common name is required.', 'Common name validation error renders');
assert.dom('[data-test-alert]').hasText('There are 2 errors with this form.', 'Alert renders');
await click('[data-test-cancel]');
});
});

View File

@ -0,0 +1,97 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { click, render, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
const selectors = {
keys: '[data-test-toggle-group="Key parameters"]',
sanOptions: '[data-test-toggle-group="Subject Alternative Name (SAN) Options"]',
subjectFields: '[data-test-toggle-group="Additional subject fields"]',
};
module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
hooks.beforeEach(async function () {
this.model = this.owner
.lookup('service:store')
.createRecord('pki/action', { actionType: 'generate-root' });
});
test('it should render key parameters', async function (assert) {
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
assert.dom(selectors.keys).hasText('Key parameters', 'Key parameters group renders');
await click(selectors.keys);
assert
.dom('[data-test-toggle-group-description]')
.hasText(
'Please choose a type to see key parameter options.',
'Placeholder renders for key params when type is not selected'
);
const fields = {
exported: ['keyName', 'keyType', 'keyBits'],
internal: ['keyName', 'keyType', 'keyBits'],
existing: ['keyRef'],
kms: ['keyName', 'managedKeyName', 'managedKeyId'],
};
for (const type in fields) {
this.model.type = type;
await settled();
assert
.dom('[data-test-field]')
.exists({ count: fields[type].length }, `Correct number of fields render for ${type} type`);
fields[type].forEach((key) => {
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders for ${type} type`);
});
}
});
test('it should render SAN options', async function (assert) {
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
assert
.dom(selectors.sanOptions)
.hasText('Subject Alternative Name (SAN) Options', 'SAN options group renders');
await click(selectors.sanOptions);
const fields = ['excludeCnFromSans', 'serialNumber', 'altNames', 'ipSans', 'uriSans', 'otherSans'];
assert.dom('[data-test-field]').exists({ count: 6 }, `Correct number of fields render`);
fields.forEach((key) => {
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders for generate-root actionType`);
});
this.model.actionType = 'generate-csr';
await settled();
assert
.dom('[data-test-field]')
.exists({ count: 4 }, 'Correct number of fields render for generate-csr actionType');
assert
.dom('[data-test-input="excludeCnFromSans"]')
.doesNotExist('excludeCnFromSans field hidden for generate-csr actionType');
assert
.dom('[data-test-input="serialNumber"]')
.doesNotExist('serialNumber field hidden for generate-csr actionType');
});
test('it should render additional subject fields', async function (assert) {
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
assert.dom(selectors.subjectFields).hasText('Additional subject fields', 'SAN options group renders');
await click(selectors.subjectFields);
const fields = ['ou', 'organization', 'country', 'locality', 'province', 'streetAddress', 'postalCode'];
assert.dom('[data-test-field]').exists({ count: fields.length }, 'Correct number of fields render');
fields.forEach((key) => {
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders`);
});
});
});

View File

@ -20,6 +20,8 @@
"vault/tests/*": ["tests/*"], "vault/tests/*": ["tests/*"],
"vault/mirage/*": ["mirage/*"], "vault/mirage/*": ["mirage/*"],
"vault/*": [ "vault/*": [
"types/*",
"types/vault/*",
"app/*", "app/*",
"lib/core/app/*", "lib/core/app/*",
"lib/css/app/*", "lib/css/app/*",

View File

@ -8,3 +8,18 @@ export interface FormField {
export interface FormFieldGroups { export interface FormFieldGroups {
[key: string]: Array<FormField>; [key: string]: Array<FormField>;
} }
export interface FormFieldGroupOptions {
[key: string]: Array<string>;
}
export interface ModelValidation {
isValid: boolean;
state: {
[key: string]: {
isValid: boolean;
errors: Array<string>;
};
};
invalidFormMessage: string;
}

21
ui/types/vault/models/capabilities.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
import ComputedProperty from '@ember/object/computed';
import Model from '@ember-data/model';
interface CapabilitiesModel extends Model {
path: string;
capabilities: Array<string>;
canSudo: ComputedProperty<boolean | undefined>;
canRead: ComputedProperty<boolean | undefined>;
canCreate: ComputedProperty<boolean | undefined>;
canUpdate: ComputedProperty<boolean | undefined>;
canDelete: ComputedProperty<boolean | undefined>;
canList: ComputedProperty<boolean | undefined>;
// these don't seem to be used anywhere
// inferring type from key name
allowedParameters: Array<string>;
deniedParameters: Array<string>;
}
export default CapabilitiesModel;
export const SUDO_PATHS: string[];
export const SUDO_PATH_PREFIXES: string[];

View File

@ -1,15 +1,20 @@
import Model from '@ember-data/model'; import Model from '@ember-data/model';
import { FormField, ModelValidations } from 'vault/app-types';
import CapabilitiesModel from '../capabilities';
export default class PkiActionModel extends Model { export default class PkiActionModel extends Model {
secretMountPath: unknown; secretMountPath: unknown;
pemBundle: string; pemBundle: string;
type: string; type: string;
actionType: string | null;
get backend(): string; get backend(): string;
// apiPaths for capabilities // apiPaths for capabilities
importBundlePath: string; importBundlePath: Promise<CapabilitiesModel>;
generateIssuerRootPath: string; generateIssuerRootPath: Promise<CapabilitiesModel>;
generateIssuerCsrPath: string; generateIssuerCsrPath: Promise<CapabilitiesModel>;
crossSignPath: string; crossSignPath: string;
allFields: Array<FormField>;
validate(): ModelValidations;
// Capabilities // Capabilities
get canImportBundle(): boolean; get canImportBundle(): boolean;
get canGenerateIssuerRoot(): boolean; get canGenerateIssuerRoot(): boolean;

View File

@ -0,0 +1,6 @@
import Model from '@ember-data/model';
import { FormField, FormFieldGroups, FormFieldGroupOptions } from 'vault/app-types';
export default function _default(modelClass: Model, fieldGroups: FormFieldGroupOptions): FormFieldGroups;
export function expandAttributeMeta(modelClass: Model, attributeNames: Array<string>): Array<FormField>;