[UI] Ember Data Migration - PKI Configuration (#10328) (#10523)

* [UI] Ember Data Migration - PKI Config Setup (#10320)

* adds api and capabilities services to pki engine

* updates eslintrc to ignore rest siblings for no-unused-vars rule

* adds ember-template-lint to pki engine

* updates check-issuers decorator to use api service

* adds constants for pki capabilities paths

* updates pki configuration route to use api service and fetch capabilities

* [UI] Ember Data Migration - PKI Config Generate Form (#10322)

* updates form class data object to tracked

* adds isNot validator

* updates tsconfig to resolve json modules

* updates open-api form class to use the spec file rather than help response for form field/group generation

* adds pki config generate form

* [UI] Ember Data Migration - PKI Config Create (#10331)

* updates pki configure create route and component

* updates pki generate csr component to use api service and form class

* updates pki generate root component to use api service and form class

* updates pki import bundle component to use api service

* [UI] Ember Data Migration - PKI Config Generate Sub Components (#10332)

* updates pki generate toggle groups component to support form class

* updates pki key parameters component to support form class

* updates pki generate immediate component based on csr component changes

* updates pki generate root component based on root component changes

* more pki config sub component updates

* updates pki issuer rotate root component to use api serivce and form

* updates pki acceptance tests (#10341)

* [UI] Ember Data Migration - PKI Configuration Edit (#10339)

* adds forms for pki config acme, cluster, crl and urls

* updates pki config edit worflow to use api service and forms

* updates pki config details workflow to use api service (#10340)

* updates auth configure section route to pass schema key to OpenApiForm constructor

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
Vault Automation 2025-11-18 11:42:22 -05:00 committed by GitHub
parent 4509269103
commit 71dee6b2e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 1972 additions and 1735 deletions

View File

@ -39,6 +39,7 @@ module.exports = {
'ember/no-actions-hash': 'off',
'ember/require-tagless-components': 'off',
'ember/no-component-lifecycle-hooks': 'off',
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
},
overrides: [
// node files

View File

@ -120,7 +120,9 @@ export default class App extends Application {
pki: {
dependencies: {
services: [
'api',
'auth',
'capabilities',
'download',
'flash-messages',
'namespace',

View File

@ -4,6 +4,7 @@
*/
import { validate } from 'vault/utils/forms/validate';
import { set } from '@ember/object';
import { TrackedObject } from 'tracked-built-ins';
import type { Validations } from 'vault/app-types';
import type FormField from 'vault/utils/forms/field';
@ -24,7 +25,7 @@ export default class Form<T extends object> {
fieldGroupProps = ['formFieldGroups'];
constructor(data: Partial<T> = {}, options: FormOptions = {}, validations?: Validations) {
this.data = { ...data } as T;
this.data = new TrackedObject(data) as T;
this.isNew = options.isNew || false;
// typically this would be defined on the subclass
// if validations are conditional, it may be preferable to define them during instantiation

View File

@ -6,52 +6,75 @@
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import FormFieldGroup from 'vault/utils/forms/field-group';
import { propsForSchema } from 'vault/utils/openapi-helpers';
import { expandOpenApiProps } from 'vault/utils/openapi-helpers';
import openApiSpec from '@hashicorp/vault-client-typescript/openapi.json';
import type { OpenApiHelpResponse } from 'vault/utils/openapi-helpers';
import type { OpenApiProps } from 'vault/utils/openapi-helpers';
export default class OpenApiForm<T extends object> extends Form<T> {
declare formFieldGroups: FormFieldGroup[];
formFieldGroups: FormFieldGroup[] = [];
formFields: FormField[] = [];
constructor(helpResponse: OpenApiHelpResponse, ...formArgs: ConstructorParameters<typeof Form>) {
super(...formArgs);
// create formFieldGroups from the OpenAPI properties
const props = propsForSchema(helpResponse);
const groups: { [groupName: string]: FormField[] } = {};
// iterate over the properties and organize them into groups
for (const [name, prop] of Object.entries(props)) {
// disabling lint rule since we need to ignore certain options returned from expandOpenApiProps util
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { fieldGroup, fieldValue, type, defaultValue, ...options } = prop;
// groupName from groupsMap takes precedence over fieldGroup from the property
const group = fieldGroup || 'default';
// organize the form fields so we can create formFieldGroups later
if (!(group in groups)) {
groups[group] = [];
}
// create a new FormField for the property and associate it with the appropriate fieldGroup
// props marked as `identifier` are primary fields that should be rendered first in the form
const arrMethod = options.identifier ? 'unshift' : 'push';
groups[group]?.[arrMethod](new FormField(name, type, options));
// set the default value on the data object
if (defaultValue && this.data[name as keyof typeof this.data] === undefined) {
this.data = { ...this.data, [name]: defaultValue };
constructor(schemaKey: string, ...formArgs: ConstructorParameters<typeof Form>) {
const [data = {}, ...restArgs] = formArgs;
const defaultValues = {} as Partial<T>;
const formFields: FormField[] = [];
let formFieldGroups: FormFieldGroup[] = [];
// find the schema in the OpenAPI spec that contains the properties for dynamic form generation
const schema = openApiSpec.components.schemas[schemaKey as keyof typeof openApiSpec.components.schemas];
// there could be an instance where the schema isn't found but an inherited class has defined static fields
// in that case we will simply bypass dynamic form generation in favor of throwing an error
if (schema) {
// create formFieldGroups from the OpenAPI properties
const props = expandOpenApiProps(schema.properties as OpenApiProps, 'form');
const groups: { [groupName: string]: FormField[] } = {};
// iterate over the properties and organize them into groups
for (const [name, prop] of Object.entries(props)) {
// disabling lint rule since we need to ignore certain options returned from expandOpenApiProps util
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { fieldGroup, fieldValue, type, defaultValue, ...options } = prop;
// groupName from groupsMap takes precedence over fieldGroup from the property
const group = fieldGroup || 'default';
// organize the form fields so we can create formFieldGroups later
if (!(group in groups)) {
groups[group] = [];
}
// create a new FormField for the property and associate it with the appropriate fieldGroup
// props marked as `identifier` are primary fields that should be rendered first in the form
const arrMethod = options.identifier ? 'unshift' : 'push';
const field = new FormField(name, type, options);
groups[group]?.[arrMethod](field);
// push all fields to formFields array for convenience access and flexibility
formFields.push(field);
// set the default value from the schema if it is not already set in the data
if (defaultValue !== undefined && data[name as keyof typeof data] === undefined) {
defaultValues[name as keyof T] = defaultValue as T[keyof T];
}
}
// create formFieldGroups from the expanded groups
formFieldGroups = Object.entries(groups).reduce<FormFieldGroup[]>(
(formFieldGroups, [groupName, fields]) => {
const group = new FormFieldGroup(groupName, fields);
// ensure the default group is the first group to render
if (groupName === 'default') {
return [group, ...formFieldGroups];
}
return [...formFieldGroups, group];
},
[]
);
} else {
// to aide in development log out an error to the console if the schema is not found
// eslint-disable-next-line no-console
console.error(`OpenApiForm: Schema '${schemaKey}' not found in OpenAPI spec.`);
}
// ensure default group is the first item in the formFieldGroups
// create formFieldGroups from the expanded groups
this.formFieldGroups = Object.entries(groups).reduce<FormFieldGroup[]>(
(formFieldGroups, [groupName, fields]) => {
const group = new FormFieldGroup(groupName, fields);
// ensure the default group is the first group to render
if (groupName === 'default') {
return [group, ...formFieldGroups];
}
return [...formFieldGroups, group];
},
[]
);
// call the super constructor with the merged default values and data
super({ ...defaultValues, ...data }, ...restArgs);
// add the generated form fields and groups to the instance
// this allows for a base class to define static form fields/groups and have them merged with the dynamic ones if necessary
this.formFields.push(...formFields);
this.formFieldGroups.push(...formFieldGroups);
}
}

View File

@ -0,0 +1,53 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import type { PkiConfigureAcmeRequest } from '@hashicorp/vault-client-typescript';
export default class PkiConfigAcmeForm extends Form<PkiConfigureAcmeRequest> {
formFields = [
new FormField('enabled', 'boolean', {
label: 'ACME enabled',
subText: 'When ACME is disabled, all requests to ACME directory URLs will return 404.',
}),
new FormField('default_directory_policy', 'string', {
subText:
"Specifies the behavior of the default ACME directory. Can be 'forbid', 'sign-verbatim' or a role given by 'role:<role_name>'. If a role is used, it must be present in 'allowed_roles'.",
}),
new FormField('allowed_roles', 'string', {
editType: 'stringArray',
subText:
"The default value '*' allows every role within the mount to be used. If the default_directory_policy specifies a role, it must be allowed under this configuration.",
}),
new FormField('allowed_role_ext_key_usage', 'boolean', {
label: 'Allow role ExtKeyUsage',
subText:
"When enabled, respect the role's ExtKeyUsage flags. Otherwise, ACME certificates are forced to ServerAuth.",
}),
new FormField('allowed_issuers', 'string', {
editType: 'stringArray',
subText:
"Specifies a list of issuers allowed to issue certificates via explicit ACME paths. If an allowed role specifies an issuer outside this list, it will be allowed. The default value '*' allows every issuer within the mount.",
}),
new FormField('eab_policy', 'string', {
label: 'EAB policy',
possibleValues: ['not-required', 'new-account-required', 'always-required'],
}),
new FormField('dns_resolver', 'string', {
label: 'DNS resolver',
subText:
'An optional overriding DNS resolver to use for challenge verification lookups. When not specified, the default system resolver will be used. This allows domains on peered networks with an accessible DNS resolver to be validated.',
}),
new FormField('max_ttl', 'string', {
label: 'Max TTL',
editType: 'ttl',
hideToggle: true,
helperTextEnabled:
'Specify the maximum TTL for ACME certificates. Role TTL values will be limited to this value.',
}),
];
}

View File

@ -0,0 +1,24 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import type { PkiConfigureClusterRequest } from '@hashicorp/vault-client-typescript';
export default class PkiConfigClusterForm extends Form<PkiConfigureClusterRequest> {
formFields = [
new FormField('path', 'string', {
label: "Mount's API path",
subText:
"Specifies the path to this performance replication cluster's API mount path, including any namespaces as path components. This address is used for the ACME directories, which must be served over a TLS-enabled listener.",
}),
new FormField('aia_path', 'string', {
label: 'AIA path',
subText:
"Specifies the path to this performance replication cluster's AIA distribution point; may refer to an external, non-Vault responder.",
}),
];
}

View File

@ -0,0 +1,88 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import FormFieldGroup from 'vault/utils/forms/field-group';
import type { PkiConfigureCrlRequest } from '@hashicorp/vault-client-typescript';
export default class PkiConfigCrlForm extends Form<PkiConfigureCrlRequest> {
crlFields = [
new FormField('expiry', 'string', {
label: 'Expiry',
labelDisabled: 'No expiry',
mapToBoolean: 'disable',
isOppositeValue: true,
editType: 'ttl',
helperTextDisabled: 'The CRL will not be built.',
helperTextEnabled: 'The CRL will expire after:',
}),
new FormField('auto_rebuild_grace_period', 'string', {
label: 'Auto-rebuild on',
labelDisabled: 'Auto-rebuild off',
mapToBoolean: 'auto_rebuild',
isOppositeValue: false,
editType: 'ttl',
helperTextEnabled: 'Vault will rebuild the CRL in the below grace period before expiration',
helperTextDisabled: 'Vault will not automatically rebuild the CRL',
}),
new FormField('delta_rebuild_interval', 'string', {
label: 'Delta CRL building on',
labelDisabled: 'Delta CRL building off',
mapToBoolean: 'enable_delta',
isOppositeValue: false,
editType: 'ttl',
helperTextEnabled: 'Vault will rebuild the delta CRL at the interval below:',
helperTextDisabled: 'Vault will not rebuild the delta CRL at an interval',
}),
];
ocspFields = [
new FormField('ocsp_expiry', 'string', {
label: 'OCSP responder APIs enabled',
labelDisabled: 'OCSP responder APIs disabled',
mapToBoolean: 'ocsp_disable',
isOppositeValue: true,
editType: 'ttl',
helperTextEnabled: "Requests about a certificate's status will be valid for:",
helperTextDisabled: 'Requests cannot be made to check if an individual certificate is valid.',
}),
];
revocationFields = [
new FormField('cross_cluster_revocation', 'boolean', {
label: 'Cross-cluster revocation',
helpText:
'Enables cross-cluster revocation request queues. When a serial not issued on this local cluster is passed to the /revoke endpoint, it is replicated across clusters and revoked by the issuing cluster if it is online.',
}),
new FormField('unified_crl', 'boolean', {
label: 'Unified CRL',
helpText:
'Enables unified CRL and OCSP building. This synchronizes all revocations between clusters; a single, unified CRL will be built on the active node of the primary performance replication (PR) cluster.',
}),
new FormField('unified_crl_on_existing_paths', 'boolean', {
label: 'Unified CRL on existing paths',
helpText:
'If enabled, existing CRL and OCSP paths will return the unified CRL instead of a response based on cluster-local data.',
}),
];
formFields = [
new FormField('auto_rebuild', 'boolean'),
new FormField('enable_delta', 'boolean'),
new FormField('disable', 'boolean'),
new FormField('ocsp_disable', 'boolean'),
...this.crlFields,
...this.ocspFields,
...this.revocationFields,
];
formFieldGroups = [
new FormFieldGroup('Certificate Revocation List (CRL)', this.crlFields),
new FormFieldGroup('Online Certificate Status Protocol (OCSP)', this.ocspFields),
new FormFieldGroup('Unified Revocation', this.revocationFields),
];
}

View File

@ -0,0 +1,77 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import OpenApiForm from 'vault/forms/open-api';
import FormField from 'vault/utils/forms/field';
import type {
PkiGenerateRootRequest,
PkiIssuersGenerateRootRequest,
PkiGenerateIntermediateRequest,
PkiIssuersGenerateIntermediateRequest,
} from '@hashicorp/vault-client-typescript';
import type { Validations } from 'vault/app-types';
type PkiGenerateRequest =
| PkiGenerateRootRequest
| PkiIssuersGenerateRootRequest
| PkiGenerateIntermediateRequest
| PkiIssuersGenerateIntermediateRequest;
type PkiConfigGenerateFormData = PkiGenerateRequest & {
type?: string;
customTtl?: string;
subject_serial_number?: string;
};
export default class PkiConfigGenerateForm extends OpenApiForm<PkiConfigGenerateFormData> {
constructor(...args: ConstructorParameters<typeof OpenApiForm>) {
super(...args);
// type and customTtl are UI only fields used to determine which fields to show and which validations to apply
// add them manually to the formFields since they are not included in the helpResponse
this.formFields.push(
new FormField('type', 'string', {
possibleValues: ['exported', 'internal', 'existing', 'kms'],
noDefault: true,
}),
new FormField('customTtl', 'string', {
label: 'Not valid after',
subText:
'The time after which this certificate will no longer be valid. This can be a TTL (a range of time from now) or a specific date.',
editType: 'yield',
}),
// this property is not documented but seems to be supported?
new FormField('subject_serial_number', 'string', {
subText:
"Specifies the requested Subject's named Serial Number value. This has no impact on the Certificate's serial number randomly generated by Vault.",
})
);
}
validations: Validations = {
type: [{ type: 'presence', message: 'Type is required.' }],
common_name: [{ type: 'presence', message: 'Common name is required.' }],
issuer_name: [
{
type: 'isNot',
options: { value: 'default' },
message: `Issuer name must be unique across all issuers and not be the reserved value 'default'.`,
},
],
key_name: [
{
type: 'isNot',
options: { value: 'default' },
message: `Key name cannot be the reserved value 'default'`,
},
],
};
toJSON() {
const { data, ...rest } = super.toJSON(this.data);
// remove type and customTtl from payload since they are UI only props
const { type, customTtl, ...payload } = data;
return { data: payload, ...rest };
}
}

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import OpenApiForm from 'vault/forms/open-api';
import type { PkiConfigureUrlsRequest } from '@hashicorp/vault-client-typescript';
import type Form from 'vault/forms/form';
export default class PkiConfigUrlsForm extends OpenApiForm<PkiConfigureUrlsRequest> {
constructor(...args: ConstructorParameters<typeof Form>) {
super('PkiConfigureUrlsRequest', ...args);
}
}

View File

@ -13,8 +13,6 @@ import type PathHelpService from 'vault/services/path-help';
import type Store from '@ember-data/store';
import type { ClusterSettingsAuthConfigureRouteModel } from '../configure';
import type { MountConfig } from 'vault/mount';
import type { HTTPRequestInit, RequestOpts } from '@hashicorp/vault-client-typescript';
import type { OpenApiHelpResponse } from 'vault/utils/openapi-helpers';
export default class ClusterSettingsAuthConfigureRoute extends Route {
@service declare readonly api: ApiService;
@ -56,46 +54,66 @@ export default class ClusterSettingsAuthConfigureRoute extends Route {
}[method.methodType];
}
fetchConfig(type: string, section: string, path: string, help = false) {
const initOverride = help
? (context: { init: HTTPRequestInit; context: RequestOpts }) =>
this.api.addQueryParams(context, { help: 1 })
: undefined;
fetchConfig(type: string, section: string, path: string) {
switch (type) {
case 'aws': {
switch (section) {
case 'client':
return this.api.auth.awsReadClientConfiguration(path, initOverride);
return this.api.auth.awsReadClientConfiguration(path);
case 'identity-accesslist':
return this.api.auth.awsReadIdentityAccessListTidySettings(path, initOverride);
return this.api.auth.awsReadIdentityAccessListTidySettings(path);
case 'roletag-denylist':
return this.api.auth.awsReadRoleTagDenyListTidySettings(path, initOverride);
return this.api.auth.awsReadRoleTagDenyListTidySettings(path);
}
break;
}
case 'azure':
return this.api.auth.azureReadAuthConfiguration(path, initOverride);
return this.api.auth.azureReadAuthConfiguration(path);
case 'github':
return this.api.auth.githubReadConfiguration(path, initOverride);
return this.api.auth.githubReadConfiguration(path);
case 'gcp':
return this.api.auth.googleCloudReadAuthConfiguration(path, initOverride);
return this.api.auth.googleCloudReadAuthConfiguration(path);
case 'jwt':
case 'oidc':
return this.api.auth.jwtReadConfiguration(path, initOverride);
return this.api.auth.jwtReadConfiguration(path);
case 'kubernetes':
return this.api.auth.kubernetesReadAuthConfiguration(path, initOverride);
return this.api.auth.kubernetesReadAuthConfiguration(path);
case 'ldap':
return this.api.auth.ldapReadAuthConfiguration(path, initOverride);
return this.api.auth.ldapReadAuthConfiguration(path);
case 'okta':
return this.api.auth.oktaReadConfiguration(path, initOverride);
return this.api.auth.oktaReadConfiguration(path);
case 'radius':
return this.api.auth.radiusReadConfiguration(path, initOverride);
return this.api.auth.radiusReadConfiguration(path);
}
throw { httpStatus: 404 };
}
schemaForType(type: string, section?: string) {
if (type === 'aws' && section) {
return (
{
client: 'AwsConfigureClientRequest',
'identity-accesslist': 'AwsConfigureIdentityAccessListTidyOperationRequest',
'roletag-denylist': 'AwsConfigureRoleTagDenyListTidyOperationRequest',
}[section] || ''
);
}
return (
{
azure: 'AzureConfigureAuthRequest',
github: 'GithubConfigureRequest',
gcp: 'GoogleCloudConfigureAuthRequest',
jwt: 'JwtConfigureRequest',
oidc: 'JwtConfigureRequest',
kubernetes: 'KubernetesConfigureRequest',
ldap: 'LdapConfigureAuthRequest',
okta: 'OktaConfigureRequest',
radius: 'RadiusConfigureRequest',
}[type] || ''
);
}
async modelForConfiguration(section: string) {
const { path, methodType } = this.configRouteModel.method;
@ -113,14 +131,7 @@ export default class ClusterSettingsAuthConfigureRoute extends Route {
throw { message, httpsStatus: status };
}
}
// make request to fetch OpenAPI properties with help query param
const helpResponse = (await this.fetchConfig(
methodType,
section,
path,
true
)) as unknown as OpenApiHelpResponse;
const form = new OpenApiForm(helpResponse, formData, formOptions);
const form = new OpenApiForm(this.schemaForType(methodType, section), formData, formOptions);
// for jwt and oidc types, the jwks_pairs field is not deprecated but we do not render it in the UI
// remove the field from the group before rendering the form
if (['jwt', 'oidc'].includes(methodType)) {

View File

@ -27,4 +27,27 @@ export const PATH_MAP = {
authMethodConfig: apiPath`auth/${'path'}/config`,
authMethodConfigAws: apiPath`auth/${'path'}/config/client`,
authMethodDelete: apiPath`sys/auth/${'path'}`,
pkiRevoke: apiPath`${'backend'}/revoke`,
pkiConfigAcme: apiPath`${'backend'}/config/acme`,
pkiConfigCluster: apiPath`${'backend'}/config/cluster`,
pkiConfigCrl: apiPath`${'backend'}/config/crl`,
pkiConfigUrls: apiPath`${'backend'}/config/urls`,
pkiIssuersImportBundle: apiPath`${'backend'}/issuers/import/bundle`,
pkiIssuersGenerateRoot: apiPath`${'backend'}/issuers/generate/root/${'type'}`,
pkiIssuersGenerateIntermediate: apiPath`${'backend'}/issuers/generate/intermediate/${'type'}`,
pkiIssuersCrossSign: apiPath`${'backend'}/issuers/cross-sign`,
pkiIssuer: apiPath`${'backend'}/issuer/${'issuerId'}`,
pkiIssuerSignIntermediate: apiPath`${'backend'}/issuer/${'issuerId'}/sign-intermediate`,
pkiRoot: apiPath`${'backend'}/root`,
pkiRootRotateExported: apiPath`${'backend'}/root/rotate/exported`,
pkiRootRotateInternal: apiPath`${'backend'}/root/rotate/internal`,
pkiRootRotateExisting: apiPath`${'backend'}/root/rotate/existing`,
pkiIntermediateCrossSign: apiPath`${'backend'}/intermediate/cross-sign`,
pkiKey: apiPath`${'backend'}/key/${'keyId'}`,
pkiKeysGenerate: apiPath`${'backend'}/keys/generate`,
pkiKeysImport: apiPath`${'backend'}/keys/import`,
pkiRole: apiPath`${'backend'}/roles/${'id'}`,
pkiIssue: apiPath`${'backend'}/issue/${'id'}`,
pkiSign: apiPath`${'backend'}/sign/${'id'}`,
pkiSignVerbatim: apiPath`${'backend'}/sign-verbatim/${'id'}`,
};

View File

@ -24,6 +24,10 @@ export interface FieldOptions {
placeholder?: string;
noDefault?: boolean;
isSectionHeader?: boolean;
hideToggle?: boolean;
labelDisabled?: string;
mapToBoolean?: string;
isOppositeValue?: boolean;
}
export default class FormField {

View File

@ -63,6 +63,9 @@ export const isNonString = (value) => {
}
};
// returns true if the value is NOT equal to the comparison value
export const isNot = (value, { value: comparisonValue }) => value !== comparisonValue;
export const WHITESPACE_WARNING = (item) =>
`${capitalize(
item
@ -79,6 +82,7 @@ export default {
endsInSlash,
isNonString,
hasWhitespace,
isNot,
WHITESPACE_WARNING,
NON_STRING_WARNING,
};

View File

@ -3,105 +3,97 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if @hasConfig}}
<Toolbar aria-label="PKI configuration">
<ToolbarActions aria-label="actions for PKI configuration">
{{#if @canDeleteAllIssuers}}
<Hds::Button
@text="Delete all issuers"
@color="secondary"
class="toolbar-button"
{{on "click" (fn (mut this.showDeleteAllIssuers) true)}}
data-test-delete-all-issuers-link
/>
<div class="toolbar-separator"></div>
{{/if}}
<ToolbarLink @route="configuration.edit" @model={{@backend}}>
Edit configuration
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{#if (not-eq @cluster 403)}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Cluster Config
</h2>
{{#each @cluster.allFields as |attr|}}
<InfoTableRow
@label={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{or (get @cluster attr.name) "None"}}
<Toolbar aria-label="PKI configuration">
<ToolbarActions aria-label="actions for PKI configuration">
{{#if @canDeleteAllIssuers}}
<Hds::Button
@text="Delete all issuers"
@color="secondary"
class="toolbar-button"
{{on "click" (fn (mut this.showDeleteAllIssuers) true)}}
data-test-delete-all-issuers-link
/>
{{/each}}
{{/if}}
{{#if (not-eq @acme 403)}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
ACME Config
</h2>
{{#each @acme.allFields as |attr|}}
<InfoTableRow
@label={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{or (get @acme attr.name) "None"}}
@formatTtl={{eq attr.options.editType "ttl"}}
/>
{{/each}}
{{/if}}
{{#if (not-eq @urls 403)}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Global URLs
</h2>
<InfoTableRow @label="Issuing certificates" @value={{or @urls.issuingCertificates "None"}} />
<InfoTableRow
@label="CRL distribution points"
@value={{if @urls.crlDistributionPoints @urls.crlDistributionPoints "None"}}
/>
{{/if}}
{{#if (not-eq @crl 403)}}
<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="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"}} />
{{#unless @crl.ocspDisable}}
<InfoTableRow @label="Interval" @value={{@crl.ocspExpiry}} />
{{/unless}}
{{#if this.isEnterprise}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Unified Revocation
</h2>
<InfoTableRow @label="Cross-cluster revocation" @value={{@crl.crossClusterRevocation}} />
<InfoTableRow @label="Unified CRL" @value={{@crl.unifiedCrl}} />
<InfoTableRow @label="Unified CRL on existing paths" @value={{@crl.unifiedCrlOnExistingPaths}} />
<div class="toolbar-separator"></div>
{{/if}}
<ToolbarLink @route="configuration.edit" @model={{@backend}}>
Edit configuration
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{#if (not-eq @cluster 403)}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Cluster Config
</h2>
<InfoTableRow @label="Mount's API path" @value={{or @cluster.path "None"}} />
<InfoTableRow @label="AIA path" @value={{or @cluster.aia_path "None"}} />
{{/if}}
{{#if (not-eq @acme 403)}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
ACME Config
</h2>
<InfoTableRow @label="ACME enabled" @value={{or @acme.enabled "None"}} />
<InfoTableRow @label="Default directory policy" @value={{or @acme.default_directory_policy "None"}} />
<InfoTableRow @label="Allowed roles" @value={{or @acme.allowed_roles "None"}} />
<InfoTableRow @label="Allow role ExtKeyUsage" @value={{or @acme.allow_role_ext_key_usage "None"}} />
<InfoTableRow @label="Allowed issuers" @value={{or @acme.allowed_issuers "None"}} />
<InfoTableRow @label="EAB policy" @value={{or @acme.eab_policy "None"}} />
<InfoTableRow @label="DNS resolver" @value={{or @acme.dns_resolver "None"}} />
<InfoTableRow @label="Max TTL" @value={{or @acme.max_ttl "None"}} @formatTtl={{true}} />
{{/if}}
{{#if (not-eq @urls 403)}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Global URLs
</h2>
<InfoTableRow @label="Issuing certificates" @value={{or @urls.issuing_certificates "None"}} />
<InfoTableRow @label="CRL distribution points" @value={{or @urls.crl_distribution_points "None"}} />
{{/if}}
{{#if (not-eq @crl 403)}}
<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="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.auto_rebuild "icon-true" "icon-false"}}
@name={{if @crl.auto_rebuild "check-circle" "x-square"}}
/>
{{if @crl.auto_rebuild "On" "Off"}}
</InfoTableRow>
{{#if @crl.auto_rebuild}}
<InfoTableRow @label="Auto-rebuild grace period" @value={{@crl.auto_rebuild_grace_period}} />
{{/if}}
<InfoTableRow @label="Delta CRL building">
<Icon
class={{if @crl.enable_delta "icon-true" "icon-false"}}
@name={{if @crl.enable_delta "check-circle" "x-square"}}
/>
{{if @crl.enable_delta "On" "Off"}}
</InfoTableRow>
{{#if @crl.enable_delta}}
<InfoTableRow @label="Delta rebuild interval" @value={{@crl.delta_rebuild_interval}} />
{{/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.ocsp_disable "Disabled" "Enabled"}} />
{{#unless @crl.ocsp_disable}}
<InfoTableRow @label="Interval" @value={{@crl.ocsp_expiry}} />
{{/unless}}
{{#if this.isEnterprise}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Unified Revocation
</h2>
<InfoTableRow @label="Cross-cluster revocation" @value={{@crl.cross_cluster_revocation}} />
<InfoTableRow @label="Unified CRL" @value={{@crl.unified_crl}} />
<InfoTableRow @label="Unified CRL on existing paths" @value={{@crl.unified_crl_on_existing_paths}} />
{{/if}}
{{/if}}

View File

@ -7,10 +7,10 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import errorMessage from 'vault/utils/error-message';
import type RouterService from '@ember/routing/router-service';
import type FlashMessageService from 'vault/services/flash-messages';
import type Store from '@ember-data/store';
import type ApiService from 'vault/services/api';
import type VersionService from 'vault/services/version';
interface Args {
@ -18,10 +18,11 @@ interface Args {
}
export default class PkiConfigurationDetails extends Component<Args> {
@service declare readonly store: Store;
@service declare readonly api: ApiService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly version: VersionService;
@tracked showDeleteAllIssuers = false;
get isEnterprise() {
@ -31,14 +32,14 @@ export default class PkiConfigurationDetails extends Component<Args> {
@action
async deleteAllIssuers() {
try {
const issuerAdapter = this.store.adapterFor('pki/issuer');
await issuerAdapter.deleteAllIssuers(this.args.backend);
await this.api.secrets.pkiDeleteRoot(this.args.backend);
this.flashMessages.success('Successfully deleted all issuers and keys');
this.showDeleteAllIssuers = false;
this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index');
} catch (error) {
this.showDeleteAllIssuers = false;
this.flashMessages.danger(errorMessage(error));
const { message } = await this.api.parseError(error);
this.flashMessages.danger(message);
}
}
}

View File

@ -11,7 +11,7 @@
<ul class={{if (gt this.errors.length 1) "bullet"}}>
{{#each this.errors as |error|}}
<li>
<code>POST config/{{error.modelName}}</code>:
<code>POST config/{{error.type}}</code>:
{{error.message}}
</li>
{{/each}}
@ -24,9 +24,9 @@
<h2 class="title is-size-5 has-border-bottom-light page-header">
Cluster Config
</h2>
{{#if @cluster.canSet}}
{{#each @cluster.allFields as |attr|}}
<FormField @attr={{attr}} @model={{@cluster}} @showHelpText={{false}} />
{{#if @capabilities.canSetCluster}}
{{#each @clusterForm.formFields as |field|}}
<FormField @attr={{field}} @model={{@clusterForm}} @showHelpText={{false}} />
{{/each}}
{{else}}
<EmptyState
@ -43,9 +43,9 @@
<h2 class="title is-size-5 has-border-bottom-light page-header">
ACME Config
</h2>
{{#if @acme.canSet}}
{{#each @acme.allFields as |attr|}}
<FormField @attr={{attr}} @model={{@acme}} @showHelpText={{false}} @backend={{@backend}} />
{{#if @capabilities.canSetAcme}}
{{#each @acmeForm.formFields as |field|}}
<FormField @attr={{field}} @model={{@acmeForm}} @showHelpText={{false}} />
{{/each}}
{{else}}
<EmptyState
@ -62,9 +62,9 @@
<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}} />
{{#if @capabilities.canSetUrls}}
{{#each @urlsForm.formFields as |field|}}
<FormField @attr={{field}} @model={{@urlsForm}} @showHelpText={{false}} />
{{/each}}
{{else}}
<EmptyState
@ -78,36 +78,36 @@
</fieldset>
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-crl-edit-section>
{{#if @crl.canSet}}
{{#each @crl.formFieldGroups as |fieldGroup|}}
{{#if @capabilities.canSetCrl}}
{{#each @crlForm.formFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{#if (or (not-eq group "Unified Revocation") this.isEnterprise)}}
<h2 class="title is-size-5 has-border-bottom-light page-header" data-test-crl-header={{group}}>
{{group}}
</h2>
{{/if}}
{{#each fields as |attr|}}
{{#if (eq attr.options.editType "ttl")}}
{{#if (or (includes attr.name (array "expiry" "ocspExpiry")) (not @crl.disable))}}
{{#let (get @crl attr.options.mapToBoolean) as |enabled|}}
{{#each fields as |field|}}
{{#if (eq field.options.editType "ttl")}}
{{#if (or (includes field.name (array "expiry" "ocsp_expiry")) (not @crlForm.disable))}}
{{#let (get @crlForm field.options.mapToBoolean) as |enabled|}}
{{! 'enabled' is the pki/crl model's boolean attr that corresponds to the duration set by the ttl }}
<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 enabled) enabled}}
@initialValue={{get @crl attr.name}}
data-test-input={{field.name}}
@onChange={{fn this.handleTtl field}}
@label={{field.options.label}}
@labelDisabled={{field.options.labelDisabled}}
@helperTextDisabled={{field.options.helperTextDisabled}}
@helperTextEnabled={{field.options.helperTextEnabled}}
@initialEnabled={{if field.options.isOppositeValue (not enabled) enabled}}
@initialValue={{get @crlForm field.name}}
/>
</div>
{{/let}}
{{/if}}
{{else}}
{{#if this.isEnterprise}}
<FormField @attr={{attr}} @model={{@crl}} />
<FormField @attr={{field}} @model={{@crlForm}} />
{{/if}}
{{/if}}
{{/each}}
@ -125,7 +125,7 @@
<hr class="has-background-gray-100" />
<Hds::ButtonSet>
{{#if (or @urls.canSet @crl.canSet)}}
{{#if (or @capabilities.canSetUrls @capabilities.canSetCrl)}}
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}

View File

@ -9,44 +9,49 @@ import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import errorMessage from 'vault/utils/error-message';
import { addToArray } from 'vault/helpers/add-to-array';
import { capitalize } from '@ember/string';
import type RouterService from '@ember/routing/router-service';
import type FlashMessageService from 'vault/services/flash-messages';
import type VersionService from 'vault/services/version';
import type PkiConfigAcmeModel from 'vault/models/pki/config/acme';
import type PkiConfigClusterModel from 'vault/models/pki/config/cluster';
import type PkiConfigCrlModel from 'vault/models/pki/config/crl';
import type PkiConfigUrlsModel from 'vault/models/pki/config/urls';
import type PkiConfigAcmeForm from 'vault/forms/secrets/pki/config/acme';
import type PkiConfigClusterForm from 'vault/forms/secrets/pki/config/cluster';
import type PkiConfigCrlForm from 'vault/forms/secrets/pki/config/crl';
import type PkiConfigUrlsForm from 'vault/forms/secrets/pki/config/urls';
import type { FormField, TtlEvent } from 'vault/app-types';
import { addToArray } from 'vault/helpers/add-to-array';
import type ApiService from 'vault/services/api';
interface Args {
acme: PkiConfigAcmeModel;
cluster: PkiConfigClusterModel;
crl: PkiConfigCrlModel;
urls: PkiConfigUrlsModel;
acmeForm: PkiConfigAcmeForm;
clusterForm: PkiConfigClusterForm;
crlForm: PkiConfigCrlForm;
urlsForm: PkiConfigUrlsForm;
backend: string;
capabilities: Record<string, boolean>;
}
interface PkiConfigCrlTtls {
autoRebuildGracePeriod: string;
auto_rebuild_grace_period: string;
expiry: string;
deltaRebuildInterval: string;
ocspExpiry: string;
delta_rebuild_interval: string;
ocsp_expiry: string;
}
interface PkiConfigCrlBooleans {
autoRebuild: boolean;
enableDelta: boolean;
auto_rebuild: boolean;
enable_delta: boolean;
disable: boolean;
ocspDisable: boolean;
ocsp_disable: boolean;
}
interface ErrorObject {
modelName: string;
type: string;
message: string;
}
export default class PkiConfigurationEditComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly version: VersionService;
@service declare readonly api: ApiService;
@tracked invalidFormAlert = '';
@tracked errors: Array<ErrorObject> = [];
@ -55,38 +60,41 @@ export default class PkiConfigurationEditComponent extends Component<Args> {
return this.version.isEnterprise;
}
@task
@waitFor
*save(event: Event) {
event.preventDefault();
// first clear errors and sticky flash messages
this.errors = [];
this.flashMessages.clearMessages();
save = task(
waitFor(async (event: Event) => {
event.preventDefault();
// first clear errors and sticky flash messages
this.errors = [];
this.flashMessages.clearMessages();
// modelName is also the API endpoint (i.e. pki/config/cluster)
for (const modelName of ['cluster', 'acme', 'urls', 'crl']) {
const model = this.args[modelName as keyof Args];
// skip saving and continue to next iteration if user does not have permission
if (!model.canSet) continue;
try {
yield model.save();
this.flashMessages.success(`Successfully updated config/${modelName}`);
} catch (error) {
const errorObject: ErrorObject = {
modelName,
message: errorMessage(error),
};
this.flashMessages.danger(`Error updating config/${modelName}`, { sticky: true });
this.errors = addToArray(this.errors, errorObject);
for (const type of ['cluster', 'acme', 'urls', 'crl']) {
// skip saving and continue to next iteration if user does not have permission
if (!this.args.capabilities[`canSet${capitalize(type)}`]) continue;
try {
const formKey = `${type}Form` as 'clusterForm' | 'acmeForm' | 'urlsForm' | 'crlForm';
const { data } = this.args[formKey].toJSON();
const apiKey = `pkiConfigure${capitalize(type)}` as
| 'pkiConfigureAcme'
| 'pkiConfigureCluster'
| 'pkiConfigureUrls'
| 'pkiConfigureCrl';
await this.api.secrets[apiKey](this.args.backend, data);
this.flashMessages.success(`Successfully updated config/${type}`);
} catch (error) {
const { message } = await this.api.parseError(error);
this.errors = addToArray(this.errors, { type, message });
this.flashMessages.danger(`Error updating config/${type}`, { sticky: true });
}
}
}
if (this.errors.length) {
this.invalidFormAlert = 'There was an error submitting this form.';
} else {
this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index');
}
}
if (this.errors.length) {
this.invalidFormAlert = 'There was an error submitting this form.';
} else {
this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index');
}
})
);
@action
cancel() {
@ -94,14 +102,17 @@ export default class PkiConfigurationEditComponent extends Component<Args> {
}
@action
handleTtl(attr: FormField, e: TtlEvent) {
handleTtl(field: FormField, e: TtlEvent) {
const { enabled, goSafeTimeString } = e;
const ttlAttr = attr.name;
this.args.crl[ttlAttr as keyof PkiConfigCrlTtls] = goSafeTimeString;
const {
name,
options: { mapToBoolean, isOppositeValue },
} = field;
const { data } = this.args.crlForm;
data[name as keyof PkiConfigCrlTtls] = 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 PkiConfigCrlBooleans] = attr.options.isOppositeValue
? !enabled
: enabled;
data[mapToBoolean as keyof PkiConfigCrlBooleans] = isOppositeValue ? !enabled : enabled;
}
}

View File

@ -14,14 +14,12 @@
</p.levelLeft>
</PageHeader>
{{#if @config.id}}
<Toolbar />
{{else}}
{{#if this.showActionTypes}}
<div class="box is-bottomless is-fullwidth is-marginless">
<div class="columns">
{{#each this.configTypes as |option|}}
<div class="column is-flex">
<label for={{option.key}} class="box-label is-column {{if (eq @config.actionType option.key) 'is-selected'}}">
<label for={{option.key}} class="box-label is-column {{if (eq this.actionType option.key) 'is-selected'}}">
<div>
<h3 class="box-label-header title is-6">
<Icon @size="24" @name={{option.icon}} />
@ -36,8 +34,8 @@
id={{option.key}}
name="pki-config-type"
@value={{option.key}}
@groupValue={{@config.actionType}}
@onChange={{fn (mut @config.actionType) option.key}}
@groupValue={{this.actionType}}
@onChange={{fn (mut this.actionType) option.key}}
data-test-pki-config-option={{option.key}}
/>
<label for={{option.key}}></label>
@ -47,41 +45,29 @@
{{/each}}
</div>
</div>
{{else}}
<Toolbar />
{{/if}}
{{#if (eq @config.actionType "import")}}
{{#if (eq this.actionType "import")}}
<PkiImportPemBundle
@model={{@config}}
@useIssuer={{@capabilities.canImportBundle}}
@onCancel={{@onCancel}}
@onSave={{fn (mut this.title) "View imported items"}}
@onSave={{this.onSave "View imported items"}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.overview"}}
@adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canImportBundle}}
/>
{{else if (eq @config.actionType "generate-root")}}
{{#if @config.privateKey}}
<div class="has-top-margin-m">
<Hds::Alert data-test-config-next-steps @type="inline" @color="highlight" class="has-bottom-margin-s" as |A|>
<A.Title>Next steps</A.Title>
<A.Description>
The
<code>private_key</code>
is only available once. Make sure you copy and save it now.
</A.Description>
</Hds::Alert>
</div>
{{/if}}
{{else if (eq this.actionType "generate-root")}}
<PkiGenerateRoot
@model={{@config}}
@urls={{@urls}}
@withUrls={{true}}
@canSetUrls={{@capabilities.canSetUrls}}
@onCancel={{@onCancel}}
@adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canGenerateIssuerRoot}}
@onSave={{fn (mut this.title) "View Root Certificate"}}
@onSave={{this.onSave "View Root Certificate"}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.overview"}}
/>
{{else if (eq @config.actionType "generate-csr")}}
{{else if (eq this.actionType "generate-csr")}}
<PkiGenerateCsr
@model={{@config}}
@onCancel={{@onCancel}}
@onSave={{fn (mut this.title) "View Generated CSR"}}
@onSave={{this.onSave "View Generated CSR"}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.overview"}}
/>
{{else}}

View File

@ -6,15 +6,15 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import type Store from '@ember-data/store';
import type RouterService from '@ember/routing/router';
import type FlashMessageService from 'vault/services/flash-messages';
import type PkiActionModel from 'vault/models/pki/action';
import type { Breadcrumb } from 'vault/vault/app-types';
import type { Breadcrumb, CapabilitiesMap } from 'vault/vault/app-types';
interface Args {
config: PkiActionModel;
capabilities: CapabilitiesMap;
onCancel: CallableFunction;
breadcrumbs: Breadcrumb;
}
@ -32,6 +32,8 @@ export default class PkiConfigureCreate extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@tracked title = 'Configure PKI';
@tracked showActionTypes = true;
@tracked actionType = '';
get configTypes() {
return [
@ -58,4 +60,10 @@ export default class PkiConfigureCreate extends Component<Args> {
},
];
}
@action
onSave(title: string) {
this.title = title;
this.showActionTypes = false;
}
}

View File

@ -14,11 +14,11 @@
</p.levelLeft>
</PageHeader>
{{#if @model.id}}
{{#if (eq this.title "View Generated CSR")}}
<Toolbar />
{{/if}}
<PkiGenerateCsr
@model={{@model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onSave={{fn (mut this.title) "View Generated CSR"}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}

View File

@ -5,10 +5,11 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type PkiActionModel from 'vault/vault/models/pki/action';
import type { Breadcrumb } from 'vault/app-types';
interface Args {
model: PkiActionModel;
breadcrumbs: Breadcrumb[];
}
export default class PagePkiIssuerGenerateIntermediateComponent extends Component<Args> {

View File

@ -14,25 +14,12 @@
</p.levelLeft>
</PageHeader>
{{#if @model.id}}
{{#if (eq this.title "View generated root")}}
<Toolbar />
{{/if}}
{{#if @model.privateKey}}
<div class="has-top-margin-m">
<Hds::Alert @type="inline" @color="highlight" class="has-bottom-margin-s" as |A|>
<A.Title>Next steps</A.Title>
<A.Description>
The
<code>private_key</code>
is only available once. Make sure you copy and save it now.
</A.Description>
</Hds::Alert>
</div>
{{/if}}
<PkiGenerateRoot
@model={{@model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onSave={{fn (mut this.title) "View generated root"}}
@adapterOptions={{hash actionType="generate-root" useIssuer=@model.canGenerateIssuerRoot}}
/>

View File

@ -5,10 +5,11 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type PkiActionModel from 'vault/vault/models/pki/action';
import type { Breadcrumb } from 'vault/app-types';
interface Args {
model: PkiActionModel;
breadcrumbs: Breadcrumb[];
}
export default class PagePkiIssuerGenerateRootComponent extends Component<Args> {

View File

@ -18,9 +18,8 @@
<Toolbar />
{{/if}}
<PkiImportPemBundle
@model={{@model}}
@useIssuer={{true}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onSave={{fn (mut this.title) "View imported items"}}
@adapterOptions={{hash actionType="import" useIssuer=true}}
/>

View File

@ -9,18 +9,18 @@
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-page-title>
{{if @newRootModel.id "View Issuer Certificate" "Generate New Root"}}
{{if this.newRoot "View Issuer Certificate" "Generate New Root"}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if @newRootModel.id}}
<Toolbar aria-label="manu for managing PKI certificate">
{{#if this.newRoot}}
<Toolbar aria-label="menu for managing PKI certificate">
<ToolbarActions aria-label="actions for managing PKI certificate">
<ToolbarLink
@route="issuers.issuer.cross-sign"
@type="pen-tool"
@models={{array @newRootModel.backend @newRootModel.issuerId}}
@models={{array this.secretMountPath.currentPath this.newRoot.issuer_id}}
data-test-pki-issuer-cross-sign
>
Cross-sign issuers
@ -28,7 +28,7 @@
<ToolbarLink
@route="issuers.issuer.sign"
@type="pen-tool"
@models={{array @newRootModel.backend @newRootModel.issuerId}}
@models={{array this.secretMountPath.currentPath this.newRoot.issuer_id}}
data-test-pki-issuer-sign-int
>
Sign Intermediate
@ -55,7 +55,7 @@
<li>
<DownloadButton
class="link"
@filename={{@newRootModel.issuerId}}
@filename={{this.newRoot.issuer_id}}
@extension="pem"
@fetchData={{fn this.fetchDataForDownload "pem"}}
data-test-issuer-download-type="pem"
@ -66,7 +66,7 @@
<li>
<DownloadButton
class="link"
@filename={{@newRootModel.issuerId}}
@filename={{this.newRoot.issuer_id}}
@extension="der"
@fetchData={{fn this.fetchDataForDownload "der"}}
data-test-issuer-download-type="der"
@ -80,7 +80,7 @@
</BasicDropdown>
<ToolbarLink
@route="issuers.issuer.edit"
@models={{array @newRootModel.backend @newRootModel.issuerId}}
@models={{array this.secretMountPath.currentPath this.newRoot.issuer_id}}
data-test-pki-issuer-configure
>
Configure
@ -89,13 +89,13 @@
</Toolbar>
{{/if}}
{{#if @newRootModel.id}}
{{#if this.newRoot}}
<div class="has-top-margin-m">
<Hds::Alert data-test-rotate-next-steps @type="inline" @color="highlight" class="has-bottom-margin-s" as |A|>
<A.Title>Next steps</A.Title>
<A.Description>
Your new root has been generated.
{{#if @newRootModel.privateKey}}
{{#if this.newRoot.private_key}}
Make sure to copy and save the
<code>private_key</code>
as it is only available once.
@ -108,7 +108,7 @@
@iconPosition="trailing"
@text="Cross-sign issuers"
@route="issuers.issuer.cross-sign"
@models={{array @newRootModel.backend @newRootModel.issuerId}}
@models={{array this.secretMountPath.currentPath this.newRoot.issuer_id}}
/>
</Hds::Alert>
@ -131,9 +131,9 @@
{{/if}}
{{#if (eq this.displayedForm "use-old-settings")}}
{{#if @newRootModel.id}}
<PkiInfoTableRows @model={{@newRootModel}} @displayFields={{this.displayFields}} />
<ParsedCertificateInfoRows @model={{@newRootModel.parsedCertificate}} />
{{#if this.newRoot}}
<PkiInfoTableRows @model={{this.newRoot}} @displayFields={{this.displayFields}} />
<ParsedCertificateInfoRows @model={{this.newParsedCertificate}} />
<div class="field is-grouped is-fullwidth has-top-margin-l has-bottom-margin-s">
<Hds::Button @text="Done" {{on "click" @onComplete}} data-test-done />
</div>
@ -155,12 +155,11 @@
<A.Description>{{this.alertBanner}}</A.Description>
</Hds::Alert>
{{/if}}
{{#let (find-by "name" "commonName" @newRootModel.allFields) as |attr|}}
<FormField @attr={{attr}} @model={{@newRootModel}} @modelValidations={{this.modelValidations}} />
{{/let}}
{{#let (find-by "name" "issuerName" @newRootModel.allFields) as |attr|}}
<FormField @attr={{attr}} @model={{@newRootModel}} @modelValidations={{this.modelValidations}} />
{{/let}}
{{#each this.newRootForm.formFields as |field|}}
{{#if (includes field.name (array "common_name" "issuer_name"))}}
<FormField @attr={{field}} @model={{this.newRootForm}} @modelValidations={{this.modelValidations}} />
{{/if}}
{{/each}}
<div class="box has-slim-padding is-shadowless">
<ToggleButton
data-test-details-toggle
@ -194,9 +193,9 @@
{{/if}}
{{else}}
<PkiGenerateRoot
@model={{@newRootModel}}
@rotateCertData={{@certData}}
@onCancel={{@onCancel}}
@onSave={{fn (mut this.newRoot)}}
@onComplete={{@onComplete}}
@adapterOptions={{hash actionType="rotate-root"}}
/>
{{/if}}

View File

@ -9,19 +9,26 @@ import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { waitFor } from '@ember/test-waiters';
import { task } from 'ember-concurrency';
import errorMessage from 'vault/utils/error-message';
import { underscore, capitalize } from '@ember/string';
import { parseCertificate } from 'vault/utils/parse-pki-cert';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
import type Store from '@ember-data/store';
import type RouterService from '@ember/routing/router';
import type FlashMessageService from 'vault/services/flash-messages';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type PkiIssuerModel from 'vault/models/pki/issuer';
import type PkiActionModel from 'vault/vault/models/pki/action';
import type { Breadcrumb, ValidationMap } from 'vault/vault/app-types';
import type {
PkiGenerateRootResponse,
PkiRotateRootExportedEnum,
PkiRotateRootRequest,
} from '@hashicorp/vault-client-typescript';
import type { ParsedCertificateData } from 'vault/vault/utils/parse-pki-cert';
import type ApiService from 'vault/services/api';
interface Args {
oldRoot: PkiIssuerModel;
newRootModel: PkiActionModel;
certData: ParsedCertificateData;
breadcrumbs: Breadcrumb;
parsingErrors: string;
}
@ -34,17 +41,24 @@ const RADIO_BUTTON_KEY = {
export default class PagePkiIssuerRotateRootComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly secretMountPath: SecretMountPath;
@service declare readonly store: Store;
@service declare readonly api: ApiService;
@service('app-router') declare readonly router: RouterService;
@tracked displayedForm = RADIO_BUTTON_KEY.oldSettings;
@tracked showOldSettings = false;
@tracked modelValidations: ValidationMap | null = null;
@tracked declare newRoot: PkiGenerateRootResponse;
// form alerts below are only for "use old settings" option
// validations/errors for "customize new root" are handled by <PkiGenerateRoot> component
@tracked alertBanner = '';
@tracked invalidFormAlert = '';
newRootForm = new PkiConfigGenerateForm(
'PkiGenerateRootRequest',
{ type: 'internal', ...this.args.certData },
{ isNew: true }
);
get generateOptions() {
return [
{
@ -76,49 +90,45 @@ export default class PagePkiIssuerRotateRootComponent extends Component<Args> {
'keyId',
'serialNumber',
];
return this.args.newRootModel.id ? [...defaultFields, ...addKeyFields] : defaultFields;
return this.newRoot
? [...defaultFields, ...addKeyFields].map((field) => underscore(field))
: defaultFields;
}
checkFormValidity() {
if (this.args.newRootModel.validate) {
const { isValid, state, invalidFormMessage } = this.args.newRootModel.validate();
this.modelValidations = state;
this.invalidFormAlert = invalidFormMessage;
return isValid;
}
return true;
get newParsedCertificate() {
return parseCertificate(this.newRoot?.certificate || '');
}
@task
@waitFor
*save(event: Event) {
event.preventDefault();
const continueSave = this.checkFormValidity();
if (!continueSave) return;
try {
yield this.args.newRootModel.save({ adapterOptions: { actionType: 'rotate-root' } });
this.flashMessages.success('Successfully generated root.');
} catch (e) {
this.alertBanner = errorMessage(e);
this.invalidFormAlert = 'There was a problem generating root.';
}
}
save = task(
waitFor(async (event: Event) => {
event.preventDefault();
const { isValid, state, invalidFormMessage, data } = this.newRootForm.toJSON();
if (isValid) {
const { type } = this.newRootForm.data;
try {
await this.api.secrets.pkiRotateRoot(
type as PkiRotateRootExportedEnum,
this.secretMountPath.currentPath,
data as PkiRotateRootRequest
);
this.flashMessages.success('Successfully generated root.');
} catch (e) {
const { message } = await this.api.parseError(e);
this.alertBanner = message;
this.invalidFormAlert = 'There was a problem generating root.';
}
} else {
this.modelValidations = state;
this.invalidFormAlert = invalidFormMessage;
}
})
);
@action
async fetchDataForDownload(format: string) {
const endpoint = `/v1/${this.secretMountPath.currentPath}/issuer/${this.args.newRootModel.issuerId}/${format}`;
const adapter = this.store.adapterFor('application');
try {
return adapter.rawRequest(endpoint, 'GET', { unauthenticated: true }).then(function (
response: Response
) {
if (format === 'der') {
return response.blob();
}
return response.text();
});
} catch (e) {
return null;
}
fetchDataForDownload(format: 'der' | 'pem') {
const apiKey = `pkiReadIssuer${capitalize(format)}` as 'pkiReadIssuerDer' | 'pkiReadIssuerPem';
return this.api.secrets[apiKey](this.newRoot.issuer_id || '', this.secretMountPath.currentPath).catch(
() => null
);
}
}

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if @model.id}}
{{#if this.config}}
{{! Model only has ID once form has been submitted and saved }}
<main data-test-generate-csr-result>
<div class="box is-sideless is-fullwidth is-shadowless">
@ -11,35 +11,32 @@
<A.Title>Next steps</A.Title>
<A.Description>
Copy the CSR below for a parent issuer to sign and then import the signed certificate back into this mount.
{{#if @model.privateKey}}
{{#if this.config.private_key}}
The
<code>private_key</code>
is only available once. Make sure you copy and save it now.
{{/if}}
</A.Description>
</Hds::Alert>
{{#each this.showFields as |fieldName|}}
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
{{#let (get @model attr.name) as |value|}}
<InfoTableRow
@label={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{value}}
@addCopyButton={{eq attr.name "keyId"}}
>
{{#if (and attr.options.isCertificate value)}}
{{#each this.returnedFields as |fieldName|}}
{{#let (get this.config fieldName) as |value|}}
<InfoTableRow @label={{this.detailLabel fieldName}} @value={{value}} @addCopyButton={{eq fieldName "key_id"}}>
{{#if value}}
{{#if (includes fieldName (array "csr" "private_key"))}}
<CertificateCard @data={{value}} />
{{else if (eq attr.name "keyId")}}
<LinkTo @route="keys.key.details" @models={{array @model.backend @model.keyId}}>
{{@model.keyId}}
{{else if (eq fieldName "key_id")}}
<LinkTo @route="keys.key.details" @models={{array this.secretMountPath.currentPath value}}>
{{value}}
</LinkTo>
{{else if value}}
{{value}}
{{! it's unlikely but if a value is returned for privateKey and privateKeyType we want to display it, otherwise we show the "internal" badge below }}
{{else}}
<Hds::Badge @text="internal" />
{{value}}
{{! it's unlikely but if a value is returned for private_key and private_key_type we want to display it, otherwise we show the "internal" badge below }}
{{/if}}
</InfoTableRow>
{{/let}}
{{else}}
<Hds::Badge @text="internal" />
{{/if}}
</InfoTableRow>
{{/let}}
{{/each}}
</div>
@ -56,17 +53,21 @@
CSR parameters
</h2>
{{#each this.formFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{#each this.defaultFields as |fieldName|}}
{{#let (find-by "name" fieldName this.form.formFields) as |field|}}
<FormField @attr={{field}} @model={{this.form}} @modelValidations={{this.modelValidations}} />
{{/let}}
{{/each}}
<PkiGenerateToggleGroups @model={{@model}} @modelValidations={{this.modelValidations}} />
<PkiGenerateToggleGroups @form={{this.form}} @actionType="generate-csr" @modelValidations={{this.modelValidations}} />
<hr class="has-background-gray-100" />
<Hds::ButtonSet>
<Hds::Button @text="Generate" type="submit" data-test-submit />
<Hds::Button @text="Cancel" @color="secondary" {{on "click" this.cancel}} data-test-cancel />
<Hds::Button @text="Cancel" @color="secondary" {{on "click" @onCancel}} data-test-cancel />
</Hds::ButtonSet>
{{#if this.alert}}
<div class="control">
<AlertInline @type="danger" class="has-top-padding-s" @message={{this.alert}} data-test-alert />

View File

@ -6,19 +6,27 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { 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 errorMessage from 'vault/utils/error-message';
import { toLabel } from 'core/helpers/to-label';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
import type FlashMessageService from 'vault/services/flash-messages';
import type PkiActionModel from 'vault/models/pki/action';
import type { Model, ValidationMap } from 'vault/app-types';
import type CapabilitiesService from 'vault/services/capabilities';
import type ApiService from 'vault/services/api';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type { ValidationMap } from 'vault/app-types';
import type {
PkiGenerateIntermediateExportedEnum,
PkiIssuersGenerateIntermediateExportedEnum,
PkiGenerateIntermediateRequest,
PkiGenerateIntermediateResponse,
PkiIssuersGenerateIntermediateRequest,
PkiIssuersGenerateIntermediateResponse,
} from '@hashicorp/vault-client-typescript';
interface Args {
model: PkiActionModel;
useIssuer: boolean;
form: PkiConfigGenerateForm;
onComplete: CallableFunction;
onCancel: CallableFunction;
onSave?: CallableFunction;
@ -27,7 +35,7 @@ interface Args {
/**
* @module PkiGenerateCsrComponent
* PkiGenerateCsr shows only the fields valid for the generate CSR endpoint.
* This component renders the form, handles the model save and rollback actions,
* This component renders the form, handles saving the data to the server,
* and shows the resulting data on success. onCancel is required for the cancel
* transition, and if onSave is provided it will call that after save for any
* side effects in the parent.
@ -45,65 +53,87 @@ interface Args {
*/
export default class PkiGenerateCsrComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly capabilities: CapabilitiesService;
@service declare readonly api: ApiService;
@service declare readonly secretMountPath: SecretMountPath;
@tracked modelValidations: ValidationMap | null = null;
@tracked error: string | null = null;
@tracked alert: string | null = null;
@tracked declare config: PkiGenerateIntermediateResponse | PkiIssuersGenerateIntermediateResponse;
formFields;
form = new PkiConfigGenerateForm('PkiGenerateIntermediateRequest', {}, { isNew: true });
defaultFields = [
'type',
'common_name',
'exclude_cn_from_sans',
'format',
'subject_serial_number',
'add_basic_constraints',
];
// fields rendered after CSR generation
showFields = ['csr', 'keyId', 'privateKey', 'privateKeyType'];
returnedFields = ['csr', 'key_id', 'private_key', 'private_key_type'];
constructor(owner: unknown, args: Args) {
super(owner, args);
this.formFields = expandAttributeMeta(this.args.model, [
'type',
'commonName',
'excludeCnFromSans',
'format',
'subjectSerialNumber',
'addBasicConstraints',
]);
}
detailLabel = (fieldName: string) => {
return (
{
csr: 'CSR',
key_id: 'Key ID',
}[fieldName] || toLabel([fieldName])
);
};
@action
cancel() {
this.args.model.unloadRecord();
this.args.onCancel();
}
async getCapability(): Promise<boolean> {
async fetchIssuerCapabilities() {
try {
const issuerCapabilities = await this.args.model.generateIssuerCsrPath;
return issuerCapabilities.get('canCreate') === true;
} catch (error) {
const { canCreate } = await this.capabilities.for('pkiIssuersGenerateIntermediate', {
backend: this.secretMountPath.currentPath,
type: this.form.data.type,
});
return canCreate;
} catch (e) {
// fallback to pkiGenerateIntermediate if capabilities fetch fails
return false;
}
}
@task
@waitFor
*save(event: Event): Generator<Promise<boolean | Model>> {
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.');
// This component shows the results, but call `onSave` for any side effects on parent
if (onSave) {
onSave();
}
window?.scrollTo(0, 0);
} else {
this.modelValidations = state;
this.alert = invalidFormMessage;
}
} catch (e) {
this.error = errorMessage(e);
this.alert = 'There was a problem generating the CSR.';
generateCsr(canUseIssuer: boolean, data: PkiConfigGenerateForm['data']) {
if (canUseIssuer) {
return this.api.secrets.pkiIssuersGenerateIntermediate(
this.form.data.type as PkiIssuersGenerateIntermediateExportedEnum,
this.secretMountPath.currentPath,
data as PkiIssuersGenerateIntermediateRequest
);
} else {
return this.api.secrets.pkiGenerateIntermediate(
this.form.data.type as PkiGenerateIntermediateExportedEnum,
this.secretMountPath.currentPath,
data as PkiGenerateIntermediateRequest
);
}
}
save = task(
waitFor(async (event: Event) => {
event.preventDefault();
try {
const { isValid, state, invalidFormMessage, data } = this.form.toJSON();
if (isValid) {
const canUseIssuer = await this.fetchIssuerCapabilities();
this.config = await this.generateCsr(canUseIssuer, data);
this.flashMessages.success('Successfully generated CSR.');
// This component shows the results, but call `onSave` for any side effects on parent
this.args.onSave?.();
window?.scrollTo(0, 0);
} else {
this.modelValidations = state;
this.alert = invalidFormMessage;
}
} catch (e) {
const { message } = await this.api.parseError(e);
this.error = message;
this.alert = 'There was a problem generating the CSR.';
}
})
);
}

View File

@ -3,51 +3,60 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{! Show results if model has an ID, which is only generated after save }}
{{#if @model.id}}
{{! Show results if config is provided, else show form to generate root CA }}
{{#if this.config}}
{{#if (and (not @rotateCertData) this.config.private_key)}}
<div class="has-top-margin-m">
<Hds::Alert data-test-config-next-steps @type="inline" @color="highlight" class="has-bottom-margin-s" as |A|>
<A.Title>Next steps</A.Title>
<A.Description>
The
<code>private_key</code>
is only available once. Make sure you copy and save it now.
</A.Description>
</Hds::Alert>
</div>
{{/if}}
<main class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.returnedFields as |field|}}
{{#let (find-by "name" field @model.allFields) as |attr|}}
{{#if attr.options.detailLinkTo}}
<InfoTableRow
@label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @model attr.name}}
@addCopyButton={{or (eq attr.name "issuerId") (eq attr.name "keyId")}}
>
<LinkTo @route={{attr.options.detailLinkTo}} @models={{array @model.backend (get @model attr.name)}}>{{get
@model
attr.name
}}</LinkTo>
{{#each this.returnedFields as |fieldName|}}
{{#let (this.valueForField fieldName) as |value|}}
{{#if (this.linkForField fieldName)}}
<InfoTableRow @label={{this.detailLabel fieldName}} @addCopyButton={{true}}>
<LinkTo
@route={{this.linkForField fieldName}}
@models={{array this.secretMountPath.currentPath (get this.config fieldName)}}
>
{{value}}
</LinkTo>
</InfoTableRow>
{{else if attr.options.isCertificate}}
<InfoTableRow
@label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}}
>
<CertificateCard @data={{get @model attr.name}} />
{{else if (this.isCertificateField fieldName)}}
<InfoTableRow @label={{this.detailLabel fieldName}}>
<CertificateCard @data={{value}} />
</InfoTableRow>
{{else}}
<InfoTableRow
@label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @model attr.name}}
/>
<InfoTableRow @label={{this.detailLabel fieldName}} @value={{value}} />
{{/if}}
{{/let}}
{{/each}}
<InfoTableRow @label="Private key">
{{#if @model.privateKey}}
<CertificateCard @data={{@model.privateKey}} />
{{#if this.config.private_key}}
<CertificateCard @data={{this.config.private_key}} />
{{else}}
<Hds::Badge @text="internal" />
{{/if}}
</InfoTableRow>
<InfoTableRow @label="Private key type" @value={{@model.privateKeyType}}>
{{#if @model.privateKeyType}}
{{@model.privateKeyType}}
<InfoTableRow @label="Private key type">
{{#if this.config.private_key_type}}
{{this.config.private_key_type}}
{{else}}
<Hds::Badge @text="internal" />
{{/if}}
</InfoTableRow>
<ParsedCertificateInfoRows @model={{@model.parsedCertificate}} />
<ParsedCertificateInfoRows @model={{this.parsedCertificate}} />
</main>
<footer>
<div class="field is-grouped is-fullwidth has-top-margin-l">
@ -60,38 +69,36 @@
<h2 class="title is-size-5 has-border-bottom-light page-header" data-test-generate-root-title="Root parameters">
Root parameters
</h2>
{{#each this.defaultFields as |field|}}
{{#let (find-by "name" field @model.allFields) as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} data-test-field>
{{#if (eq field "customTtl")}}
{{! customTtl attr has editType yield, which will render this }}
<PkiNotValidAfterForm @attr={{attr}} @model={{@model}} />
{{#each this.defaultFields as |fieldName|}}
{{#let (find-by "name" fieldName this.form.formFields) as |field|}}
<FormField @attr={{field}} @model={{this.form}} @modelValidations={{this.modelValidations}} data-test-field>
{{#if (eq fieldName "customTtl")}}
{{! custom_ttl field has editType yield, which will render this }}
<PkiNotValidAfterForm @attr={{field}} @form={{this.form}} />
{{/if}}
</FormField>
{{/let}}
{{/each}}
<PkiGenerateToggleGroups @model={{@model}} @modelValidations={{this.modelValidations}} />
<PkiGenerateToggleGroups @form={{this.form}} @actionType="generate-root" @modelValidations={{this.modelValidations}} />
{{#if @urls}}
{{#if @withUrls}}
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-urls-section>
<h2
class="title is-size-5 page-header {{if @urls.canCreate 'has-border-bottom-light' 'is-borderless'}}"
class="title is-size-5 page-header {{if @canSetUrls 'has-border-bottom-light' 'is-borderless'}}"
data-test-generate-root-title="Issuer URLs"
>
Issuer URLs
</h2>
{{#if @urls.canSet}}
{{#each @urls.allFields as |attr|}}
{{#if (not-eq attr.name "mountPath")}}
<FormField
@attr={{attr}}
@mode="create"
@model={{@urls}}
@showHelpText={{attr.options.showHelpText}}
data-test-urls-field
/>
{{/if}}
{{#if @canSetUrls}}
{{#each this.urlsForm.formFields as |field|}}
<FormField
@attr={{field}}
@mode="create"
@model={{this.urlsForm}}
@showHelpText={{field.options.showHelpText}}
data-test-urls-field
/>
{{/each}}
{{else}}
<EmptyState

View File

@ -4,127 +4,203 @@
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { waitFor } from '@ember/test-waiters';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import errorMessage from 'vault/utils/error-message';
import type PkiActionModel from 'vault/models/pki/action';
import type PkiConfigUrlsModel from 'vault/models/pki/config/urls';
import { toLabel } from 'core/helpers/to-label';
import { parseCertificate, type ParsedCertificateData } from 'vault/utils/parse-pki-cert';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
import PkiUrlsForm from 'vault/forms/secrets/pki/config/urls';
import type FlashMessageService from 'vault/services/flash-messages';
import type RouterService from '@ember/routing/router-service';
import type { ValidationMap } from 'vault/vault/app-types';
import type { ValidationMap } from 'vault/app-types';
import type ApiService from 'vault/services/api';
import type CapabilitiesService from 'vault/services/capabilities';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type {
PkiGenerateRootExportedEnum,
PkiIssuersGenerateRootExportedEnum,
PkiGenerateRootRequest,
PkiGenerateRootResponse,
PkiIssuersGenerateRootRequest,
PkiIssuersGenerateRootResponse,
PkiRotateRootExportedEnum,
PkiRotateRootRequest,
} from '@hashicorp/vault-client-typescript';
interface AdapterOptions {
actionType: string;
useIssuer: boolean | undefined;
}
interface Args {
model: PkiActionModel;
urls: PkiConfigUrlsModel;
withUrls?: boolean;
canSetUrls?: boolean;
rotateCertData?: ParsedCertificateData;
onCancel: CallableFunction;
onComplete: CallableFunction;
onSave?: CallableFunction;
adapterOptions: AdapterOptions;
hideAlertBanner: boolean;
}
/**
* @module PkiGenerateRoot
* PkiGenerateRoot shows only the fields valid for the generate root endpoint.
* This component renders the form, handles the model save and rollback actions,
* This component renders the form, handles saving the data to the server,
* and shows the resulting data on success. onCancel is required for the cancel
* transition, and if onSave is provided it will call that after save for any
* side effects in the parent.
*
* @example
* ```js
* <PkiGenerateRoot @model={{this.model}} @onCancel={{transition-to "vault.cluster"}} @onSave={{fn (mut this.title) "Successful"}} @adapterOptions={{hash actionType="import" useIssuer=false}} />
* ```
*
* @param {Object} model - pki/action model.
* @param {boolean} withUrls - whether or not to show the urls fields.
* @param {boolean} canSetUrls - whether or not the user has capability to set urls.
* @param {boolean} rotateCertData - cert data to be populated in form and rotated.
* @callback onCancel - Callback triggered when cancel button is clicked, after model is unloaded
* @callback onSave - Optional - Callback triggered after model is saved, as a side effect. Results are shown on the same component
* @callback onComplete - Callback triggered when "Done" button clicked, on results view
* @param {Object} adapterOptions - object passed as adapterOptions on the model.save method
*/
export default class PkiGenerateRootComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly api: ApiService;
@service declare readonly capabilities: CapabilitiesService;
@service declare readonly secretMountPath: SecretMountPath;
@tracked modelValidations: ValidationMap | null = null;
@tracked errorBanner = '';
@tracked invalidFormAlert = '';
@tracked declare config: PkiGenerateRootResponse | PkiIssuersGenerateRootResponse;
@tracked declare parsedCertificate: ParsedCertificateData;
form = new PkiConfigGenerateForm('PkiGenerateRootRequest', this.args.rotateCertData, {
isNew: true,
});
urlsForm = new PkiUrlsForm({}, { isNew: true });
get defaultFields() {
return [
'type',
'commonName',
'issuerName',
'customTtl',
'notBeforeDuration',
'common_name',
'issuer_name',
'customTtl', // UI only and used to yield PkiNotValidAfterForm
'not_before_duration',
'format',
'permittedDnsDomains',
'maxPathLength',
'permitted_dns_domains',
'max_path_length',
];
}
get returnedFields() {
return [
'certificate',
'commonName',
'issuerId',
'issuerName',
'issuingCa',
'keyName',
'keyId',
'serialNumber',
'common_name',
'issuer_id',
'issuer_name',
'issuing_ca',
'key_name',
'key_id',
'serial_number',
];
}
@action cancel() {
// Generate root form will always have a new model
this.args.model.unloadRecord();
this.args.onCancel();
}
detailLabel = (fieldName: string) => {
const label = toLabel([fieldName]);
return (
{
issuer_id: 'Issuer ID',
issuing_ca: 'Issuing CA',
key_id: 'Key ID',
}[fieldName] || label
);
};
@action
checkFormValidity() {
if (this.args.model.validate) {
const { isValid, state, invalidFormMessage } = this.args.model.validate();
this.modelValidations = state;
this.invalidFormAlert = invalidFormMessage;
return isValid;
linkForField = (fieldName: string) => {
return {
issuer_id: 'issuers.issuer.details',
key_id: 'keys.key.details',
}[fieldName];
};
valueForField = (fieldName: string) => {
if (fieldName === 'common_name') {
return this.parsedCertificate.common_name;
}
return true;
}
return this.config[fieldName as keyof typeof this.config];
};
@task
@waitFor
*save(event: Event) {
event.preventDefault();
const continueSave = this.checkFormValidity();
if (!continueSave) return;
isCertificateField = (fieldName: string) => {
return ['certificate', 'issuing_ca', 'csr', 'private_key'].includes(fieldName);
};
async fetchIssuerCapabilities() {
try {
yield this.args.model.save({ adapterOptions: this.args.adapterOptions });
// root generation must occur first in case templates are used for URL fields
// this way an issuer_id exists for backend to interpolate into the template
yield this.setUrls();
this.flashMessages.success('Successfully generated root.');
// This component shows the results, but call `onSave` for any side effects on parent
if (this.args.onSave) {
this.args.onSave();
}
window?.scrollTo(0, 0);
const { canCreate } = await this.capabilities.for('pkiIssuersGenerateRoot', {
backend: this.secretMountPath.currentPath,
type: this.form.data.type,
});
return canCreate;
} catch (e) {
this.errorBanner = errorMessage(e);
this.invalidFormAlert = 'There was a problem generating the root.';
// fallback to pkiGenerateRoot if capabilities fetch fails
return false;
}
}
async generateRoot(data: PkiConfigGenerateForm['data']) {
const { type } = this.form.data;
const { currentPath } = this.secretMountPath;
if (this.args.rotateCertData) {
return this.api.secrets.pkiRotateRoot(
type as PkiRotateRootExportedEnum,
currentPath,
data as PkiRotateRootRequest
);
} else {
const canUseIssuer = await this.fetchIssuerCapabilities();
if (canUseIssuer) {
return this.api.secrets.pkiIssuersGenerateRoot(
type as PkiIssuersGenerateRootExportedEnum,
this.secretMountPath.currentPath,
data as PkiIssuersGenerateRootRequest
);
} else {
return this.api.secrets.pkiGenerateRoot(
type as PkiGenerateRootExportedEnum,
this.secretMountPath.currentPath,
data as PkiGenerateRootRequest
);
}
}
}
save = task(
waitFor(async (event: Event) => {
event.preventDefault();
const { isValid, state, invalidFormMessage, data } = this.form.toJSON();
if (isValid) {
try {
this.config = await this.generateRoot(data);
this.parsedCertificate = parseCertificate(this.config.certificate || '');
// root generation must occur first in case templates are used for URL fields
// this way an issuer_id exists for backend to interpolate into the template
await this.setUrls();
this.flashMessages.success('Successfully generated root.');
// This component shows the results, but call `onSave` for any side effects on parent
this.args.onSave?.(this.config);
window?.scrollTo(0, 0);
} catch (e) {
const { message } = await this.api.parseError(e);
this.errorBanner = message;
this.invalidFormAlert = 'There was a problem generating the root.';
}
} else {
this.modelValidations = state;
this.invalidFormAlert = invalidFormMessage;
}
})
);
async setUrls() {
if (!this.args.urls || !this.args.urls.canSet || !this.args.urls.hasDirtyAttributes) return;
return this.args.urls.save();
const { withUrls, canSetUrls } = this.args;
if (withUrls && canSetUrls) {
const { data } = this.urlsForm.toJSON();
await this.api.secrets.pkiConfigureUrls(this.secretMountPath.currentPath, data);
}
}
}

View File

@ -16,17 +16,17 @@
<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")}}
{{#if (eq @form.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")}}
{{else if (eq @form.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")}}
{{else if (eq @form.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")}}
{{else if (eq @form.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}}
@ -34,7 +34,7 @@
{{/if}}
</p>
{{#if this.keyParamFields}}
<PkiKeyParameters @model={{@model}} @fields={{this.keyParamFields}} @modelValidations={{@modelValidations}} />
<PkiKeyParameters @model={{@form}} @fields={{this.keyParamFields}} @modelValidations={{@modelValidations}} />
{{/if}}
{{else}}
<p class="has-bottom-margin-m" data-test-toggle-group-description>
@ -53,11 +53,11 @@
{{/if}}
</p>
{{#each fields as |fieldName|}}
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
{{#let (find-by "name" fieldName (get @form this.fieldsKey)) as |field|}}
<FormField
data-test-field
@attr={{attr}}
@model={{@model}}
@attr={{field}}
@model={{@form}}
@showHelpText={{false}}
@modelValidations={{@modelValidations}}
/>

View File

@ -7,11 +7,13 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { keyParamsByType } from 'pki/utils/action-params';
import type PkiActionModel from 'vault/models/pki/action';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
import type { ModelValidations } from 'vault/vault/app-types';
interface Args {
model: PkiActionModel;
form: PkiConfigGenerateForm;
actionType: string;
groups: Map<[key: string], Array<string>> | null;
modelValidations?: ModelValidations;
}
@ -19,33 +21,43 @@ interface Args {
export default class PkiGenerateToggleGroupsComponent extends Component<Args> {
@tracked showGroup: string | null = null;
// shim until sign-intermediate model is migrated to form
get fieldsKey() {
return this.args.form instanceof PkiConfigGenerateForm ? 'formFields' : 'allFields';
}
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);
});
const { form } = this.args;
if (form.data.type) {
const fields = keyParamsByType(form.data.type);
return fields.map((fieldName) => {
return form.formFields.find((field) => field.name === fieldName);
});
}
return null;
}
get groups() {
if (this.args.groups) return this.args.groups;
const groups = {
'Key parameters': this.keyParamFields,
'Subject Alternative Name (SAN) Options': ['altNames', 'ipSans', 'uriSans', 'otherSans'],
'Subject Alternative Name (SAN) Options': ['alt_names', 'ip_sans', 'uri_sans', 'other_sans'],
'Additional subject fields': [
'ou',
'organization',
'country',
'locality',
'province',
'streetAddress',
'postalCode',
'street_address',
'postal_code',
],
};
// 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', 'subjectSerialNumber');
if (this.args.actionType !== 'generate-csr') {
groups['Subject Alternative Name (SAN) Options'].unshift(
'exclude_cn_from_sans',
'subject_serial_number'
);
}
return groups;
}

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if this.importedResponse}}
{{#if this.importedIssuerKeyMap}}
<div class="is-flex-start has-top-margin-xs">
<div class="is-flex-grow-1 basis-0 has-text-grey has-bottom-margin-xs">
<h2>
@ -17,7 +17,7 @@
</div>
</div>
<div class="box is-fullwidth is-sideless is-marginless is-paddingless" data-test-imported-bundle-mapping>
{{#each this.importedResponse as |imported|}}
{{#each this.importedIssuerKeyMap as |imported|}}
<div
class="box is-marginless no-top-shadow has-slim-padding"
data-test-import-pair={{concat imported.issuer "_" imported.key}}
@ -77,7 +77,7 @@
@text="Cancel"
@color="secondary"
disabled={{this.submitForm.isRunning}}
{{on "click" this.cancel}}
{{on "click" @onCancel}}
data-test-pki-ca-cert-cancel
/>
</Hds::ButtonSet>

View File

@ -9,55 +9,50 @@ import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
import { waitFor } from '@ember/test-waiters';
import errorMessage from 'vault/utils/error-message';
import type FlashMessageService from 'vault/services/flash-messages';
import type PkiActionModel from 'vault/models/pki/action';
import type ApiService from 'vault/services/api';
import type SecretMountPathService from 'vault/services/secret-mount-path';
import type {
PkiIssuersImportBundleResponse,
PkiConfigureCaResponse,
} from '@hashicorp/vault-client-typescript';
/**
* @module PkiImportPemBundle
* PkiImportPemBundle components are used to import PKI CA certificates and keys via pem_bundle.
* https://github.com/hashicorp/vault/blob/main/website/content/api-docs/secret/pki.mdx#import-ca-certificates-and-keys
*
* @example
* ```js
* <PkiImportPemBundle @model={{this.model}} />
* ```
*
* @param {Object} model - certificate model from route
* @callback onCancel - Callback triggered when cancel button is clicked.
* @callback onSave - Callback triggered on submit success.
* @callback onComplete - Callback triggered on "done" button click.
*/
interface AdapterOptions {
actionType: string;
useIssuer: boolean | undefined;
}
interface Args {
onSave?: CallableFunction;
onCancel: CallableFunction;
onComplete: CallableFunction;
model: PkiActionModel;
adapterOptions: AdapterOptions;
useIssuer: boolean;
}
export default class PkiImportPemBundle extends Component<Args> {
@service declare readonly api: ApiService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly secretMountPath: SecretMountPathService;
@tracked pemBundle = '';
@tracked errorBanner = '';
@tracked importedIssuerKeyMap: Array<{ issuer: string; key: string }> | null = null;
get importedResponse() {
const { mapping, importedIssuers, importedKeys } = this.args.model;
mapResponse(response: PkiIssuersImportBundleResponse | PkiConfigureCaResponse) {
const { mapping, imported_issuers, imported_keys } = response;
// Even if there are no imported items, mapping will be an empty object from API response
if (undefined === mapping) return null;
const importList = (importedIssuers || []).map((issuer: string) => {
const key = mapping[issuer];
const importList = (imported_issuers || []).map((issuer: string) => {
const key = mapping[issuer as keyof typeof mapping] as string;
return { issuer, key };
});
// Check each imported key and make sure it's in the list
(importedKeys || []).forEach((key) => {
(imported_keys || []).forEach((key) => {
const matchIdx = importList.findIndex((item) => item.key === key);
// If key isn't accounted for, add it without a matching issuer
if (matchIdx === -1) {
@ -72,36 +67,41 @@ export default class PkiImportPemBundle extends Component<Args> {
return importList;
}
@task
@waitFor
*submitForm(event: Event) {
event.preventDefault();
this.errorBanner = '';
if (!this.args.model.pemBundle) {
this.errorBanner = 'please upload your PEM bundle';
return;
}
try {
yield this.args.model.save({ adapterOptions: this.args.adapterOptions });
this.flashMessages.success('Successfully imported data.');
// This component shows the results, but call `onSave` for any side effects on parent
if (this.args.onSave) {
this.args.onSave();
submitForm = task(
waitFor(async (event: Event) => {
event.preventDefault();
this.errorBanner = '';
if (!this.pemBundle) {
this.errorBanner = 'please upload your PEM bundle';
return;
}
window?.scrollTo(0, 0);
} catch (error) {
this.errorBanner = errorMessage(error);
}
}
try {
const { currentPath } = this.secretMountPath;
const payload = { pem_bundle: this.pemBundle };
let response: PkiIssuersImportBundleResponse | PkiConfigureCaResponse;
if (this.args.useIssuer) {
response = await this.api.secrets.pkiIssuersImportBundle(currentPath, payload);
} else {
response = await this.api.secrets.pkiConfigureCa(currentPath, payload);
}
this.importedIssuerKeyMap = this.mapResponse(response);
this.flashMessages.success('Successfully imported data.');
// This component shows the results, but call `onSave` for any side effects on parent
if (this.args.onSave) {
this.args.onSave();
}
window?.scrollTo(0, 0);
} catch (error) {
const { message } = await this.api.parseError(error);
this.errorBanner = message;
}
})
);
@action
onFileUploaded({ value }: { value: string }) {
this.args.model.pemBundle = value;
}
@action
cancel() {
this.args.model.unloadRecord();
this.args.onCancel();
this.pemBundle = value;
}
}

View File

@ -4,34 +4,35 @@
}}
{{yield}}
{{#each @fields as |attr|}}
{{#if (eq attr.name "keyBits")}}
<div class="field" data-test-field="keyBits">
{{#each @fields as |field|}}
{{#if (eq (camelize field.name) "keyBits")}}
<div class="field" data-test-field="key_bits">
<FormFieldLabel
for={{attr.name}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@subText={{attr.options.subText}}
for={{field.name}}
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
@subText={{field.options.subText}}
/>
<div
class="select is-fullwidth"
{{! these extra parentheses are necessary to conditionally apply the modifier }}
{{! TODO: refactor this component and avoid this conditional modifier pattern }}
{{(unless @model.keyType (modifier "hds-tooltip" "Choose a key type before specifying bit length."))}}
{{(unless (this.getValue "keyType") (modifier "hds-tooltip" "Choose a key type before specifying bit length."))}}
>
<select
id={{attr.name}}
name={{attr.name}}
data-test-input={{attr.name}}
disabled={{unless @model.keyType true}}
id={{field.name}}
name={{field.name}}
data-test-input={{field.name}}
disabled={{unless (this.getValue "keyType") true}}
{{on "change" this.onKeyBitsChange}}
>
{{#if (and attr.options.noDefault (not @model.keyType))}}
{{#if (and field.options.noDefault (not (this.getValue "keyType")))}}
<option value="">
Select one
</option>
{{/if}}
{{#each this.keyBitOptions as |val|}}
<option selected={{loose-equal (get @model "keyBits") val}} value={{val}}>
<option selected={{loose-equal (this.getValue "keyBits") val}} value={{val}}>
{{val}}
</option>
{{/each}}
@ -40,8 +41,8 @@
</div>
{{else}}
<FormField
data-test-field={{attr}}
@attr={{attr}}
data-test-field={{field}}
@attr={{field}}
@model={{@model}}
@modelValidations={{@modelValidations}}
@showHelpText={{false}}

View File

@ -5,6 +5,9 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { underscore } from '@ember/string';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
import type PkiRoleModel from 'vault/models/pki/role';
import type PkiKeyModel from 'vault/models/pki/key';
import type PkiActionModel from 'vault/models/pki/action';
@ -21,11 +24,7 @@ import type { HTMLElementEvent } from 'forms';
* @param {string} fields - Array of attributes from a formFieldGroup generated by the @withFormFields decorator ex: [{ name: 'attrName', type: 'string', options: {...} }]
*/
interface Args {
model: PkiRoleModel | PkiKeyModel | PkiActionModel;
}
interface ModelAttributeName {
keyType: string;
keyBits: string;
model: PkiRoleModel | PkiKeyModel | PkiActionModel | PkiConfigGenerateForm;
}
interface TypeOptions {
rsa: string;
@ -46,18 +45,33 @@ const KEY_BITS_OPTIONS: BitOptions = {
};
export default class PkiKeyParameters extends Component<Args> {
// shim to support both model and form types until all models can be migrated
getValue = (key: string) => {
const { model } = this.args;
if (model instanceof PkiConfigGenerateForm) {
return model.data[underscore(key) as keyof typeof model.data];
}
return model[key as keyof typeof model];
};
setValue = (key: string, value: unknown) => {
const { model } = this.args;
const modelKey = model instanceof PkiConfigGenerateForm ? underscore(key) : key;
model.set(modelKey, value);
};
get keyBitOptions() {
if (!this.args.model.keyType) return [];
return KEY_BITS_OPTIONS[this.args.model.keyType];
const keyType = this.getValue('keyType');
return keyType ? KEY_BITS_OPTIONS[keyType] : [];
}
@action handleSelection(name: string, selection: string) {
this.args.model[name as keyof ModelAttributeName] = selection;
this.setValue(name, selection);
if (name === 'keyType' && Object.keys(KEY_BITS_OPTIONS)?.includes(selection)) {
const bitOptions = KEY_BITS_OPTIONS[selection as keyof TypeOptions];
if (bitOptions) {
this.args.model.keyBits = bitOptions[0];
this.setValue('keyBits', bitOptions[0]);
}
}
}

View File

@ -7,15 +7,18 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { format } from 'date-fns';
import type { HTMLElementEvent } from 'forms';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
/**
* <PkiNotValidAfterForm /> components are used to manage two mutually exclusive role options in the form.
*/
interface Args {
model: {
notAfter: string;
ttl: string | number;
notAfter?: string;
not_after?: string;
set: (key: string, value: string | number) => void;
};
}
@ -29,14 +32,18 @@ export default class PkiNotValidAfterForm extends Component<Args> {
constructor(owner: unknown, args: Args) {
super(owner, args);
const { model } = this.args;
this.cachedNotAfter = model.notAfter || '';
this.formDate = this.calculateFormDate(model.notAfter);
this.cachedNotAfter = model[this.notAfterKey] || '';
this.formDate = this.calculateFormDate(this.cachedNotAfter);
this.cachedTtl = model.ttl || '';
if (model.notAfter) {
if (this.cachedNotAfter) {
this.groupValue = 'specificDate';
}
}
get notAfterKey() {
return this.args.model instanceof PkiConfigGenerateForm ? 'not_after' : 'notAfter';
}
calculateFormDate(value: string) {
// API expects and returns full ISO string
// but the form input only accepts yyyy-MM-dd format
@ -49,14 +56,15 @@ export default class PkiNotValidAfterForm extends Component<Args> {
@action onRadioButtonChange(selection: string) {
this.groupValue = selection;
// Clear the previous selection if they have clicked the other radio button.
const { model } = this.args;
if (selection === 'specificDate') {
this.args.model.ttl = '';
this.args.model.notAfter = this.cachedNotAfter;
model.ttl = '';
model[this.notAfterKey] = this.cachedNotAfter;
this.formDate = this.calculateFormDate(this.cachedNotAfter);
}
if (selection === 'ttl') {
this.args.model.notAfter = '';
this.args.model.ttl = this.cachedTtl;
model[this.notAfterKey] = '';
model.ttl = `${this.cachedTtl}`;
this.formDate = '';
}
}
@ -77,7 +85,7 @@ export default class PkiNotValidAfterForm extends Component<Args> {
if (!setDate) return;
this.cachedNotAfter = setDate;
this.args.model.notAfter = setDate;
this.args.model[this.notAfterKey] = setDate;
this.formDate = this.calculateFormDate(setDate);
}
}

View File

@ -51,7 +51,7 @@
</FormField>
{{/each}}
<PkiGenerateToggleGroups @model={{@model}} @groups={{this.groups}} />
<PkiGenerateToggleGroups @form={{@model}} @groups={{this.groups}} />
</div>
<Hds::ButtonSet class="has-top-padding-s">

View File

@ -5,6 +5,7 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { PkiListIssuersListEnum } from '@hashicorp/vault-client-typescript';
/**
* the overview, roles, issuers, certificates, and key routes all need to be aware of the whether there is a config for the engine
@ -23,8 +24,10 @@ export function withConfig() {
);
return SuperClass;
}
return class CheckConfig extends SuperClass {
class CheckConfig extends SuperClass {
@service secretMountPath;
@service api;
pkiMountHasConfig = false;
async beforeModel() {
@ -32,14 +35,17 @@ export function withConfig() {
// When the engine is configured, it creates a default issuer.
// If the issuers list is empty, we know it hasn't been configured
return (
this.store
.query('pki/issuer', { backend: this.secretMountPath.currentPath })
.then(() => (this.pkiMountHasConfig = true))
// this endpoint is unauthenticated, so we're not worried about permissions errors
.catch(() => (this.pkiMountHasConfig = false))
);
try {
await this.api.secrets.pkiListIssuers(
this.secretMountPath.currentPath,
PkiListIssuersListEnum.TRUE
);
this.pkiMountHasConfig = true;
} catch (e) {
this.pkiMountHasConfig = false;
}
}
};
}
return CheckConfig;
};
}

View File

@ -17,7 +17,9 @@ export default class PkiEngine extends Engine {
Resolver = Resolver;
dependencies = {
services: [
'api',
'auth',
'capabilities',
'download',
'flash-messages',
'namespace',

View File

@ -8,16 +8,46 @@ import { service } from '@ember/service';
import { hash } from 'rsvp';
export default class PkiConfigurationRoute extends Route {
@service store;
@service api;
@service capabilities;
@service secretMountPath;
async fetchCapabilities(backend) {
const { pathFor } = this.capabilities;
const pathMap = {
import: pathFor('pkiIssuersImportBundle', { backend }),
configAcme: pathFor('pkiConfigAcme', { backend }),
configCluster: pathFor('pkiConfigCluster', { backend }),
configCrl: pathFor('pkiConfigCrl', { backend }),
configUrls: pathFor('pkiConfigUrls', { backend }),
root: pathFor('pkiRoot', { backend }),
};
const perms = await this.capabilities.fetch(Object.values(pathMap));
return {
canImportBundle: perms[pathMap.import].canCreate,
canSetAcme: perms[pathMap.configAcme].canUpdate,
canSetCluster: perms[pathMap.configCluster].canUpdate,
canSetCrl: perms[pathMap.configCrl].canUpdate,
canSetUrls: perms[pathMap.configUrls].canUpdate,
canDeleteAllIssuers: perms[pathMap.root].canDelete,
};
}
model() {
const engine = this.modelFor('application');
const errorHandler = (e) => e.response?.status;
const { currentPath } = this.secretMountPath;
return hash({
engine,
acme: this.store.findRecord('pki/config/acme', engine.id).catch((e) => e.httpStatus),
cluster: this.store.findRecord('pki/config/cluster', engine.id).catch((e) => e.httpStatus),
urls: this.store.findRecord('pki/config/urls', engine.id).catch((e) => e.httpStatus),
crl: this.store.findRecord('pki/config/crl', engine.id).catch((e) => e.httpStatus),
acme: this.api.secrets
.pkiReadAcmeConfiguration(currentPath)
.then((resp) => resp.data) // response type is VoidResponse
.catch(errorHandler),
cluster: this.api.secrets.pkiReadClusterConfiguration(currentPath).catch(errorHandler),
urls: this.api.secrets.pkiReadUrlsConfiguration(currentPath).catch(errorHandler),
crl: this.api.secrets.pkiReadCrlConfiguration(currentPath).catch(errorHandler),
capabilities: this.fetchCapabilities(currentPath),
});
}
}

View File

@ -5,20 +5,9 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
import { hash } from 'rsvp';
@withConfirmLeave('model.config', ['model.urls'])
export default class PkiConfigurationCreateRoute extends Route {
@service secretMountPath;
@service store;
model() {
return hash({
config: this.store.createRecord('pki/action'),
urls: this.modelFor('configuration').urls,
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);

View File

@ -5,20 +5,23 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
import PkiConfigAcmeForm from 'vault/forms/secrets/pki/config/acme';
import PkiConfigClusterForm from 'vault/forms/secrets/pki/config/cluster';
import PkiConfigCrlForm from 'vault/forms/secrets/pki/config/crl';
import PkiConfigUrlsForm from 'vault/forms/secrets/pki/config/urls';
@withConfirmLeave('model.config', ['model.urls', 'model.crl'])
export default class PkiConfigurationEditRoute extends Route {
@service secretMountPath;
model() {
const { acme, cluster, urls, crl, engine } = this.modelFor('configuration');
const { acme, cluster, urls, crl, engine, capabilities } = this.modelFor('configuration');
return {
engineId: engine.id,
acme,
cluster,
urls,
crl,
engine,
capabilities,
acmeForm: new PkiConfigAcmeForm(acme),
clusterForm: new PkiConfigClusterForm(cluster),
urlsForm: new PkiConfigUrlsForm(urls),
crlForm: new PkiConfigCrlForm(crl),
};
}

View File

@ -21,16 +21,11 @@ export default class ConfigurationIndexRoute extends Route {
}
model() {
const { acme, cluster, urls, crl, engine } = this.modelFor('configuration');
const configRouteModel = this.modelFor('configuration');
return hash({
hasConfig: this.pkiMountHasConfig,
engine,
acme,
cluster,
urls,
crl,
mountConfig: this.fetchMountConfig(engine.id),
issuerModel: this.store.createRecord('pki/issuer'),
mountConfig: this.fetchMountConfig(configRouteModel.engine.id),
...configRouteModel,
});
}

View File

@ -5,17 +5,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
@withConfirmLeave()
export default class PkiIssuersGenerateIntermediateRoute extends Route {
@service store;
@service secretMountPath;
model() {
return this.store.createRecord('pki/action', { actionType: 'generate-csr' });
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [

View File

@ -5,16 +5,9 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
@withConfirmLeave()
export default class PkiIssuersGenerateRootRoute extends Route {
@service secretMountPath;
@service store;
model() {
return this.store.createRecord('pki/action', { actionType: 'generate-root' });
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);

View File

@ -7,10 +7,7 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { parseCertificate } from 'vault/utils/parse-pki-cert';
import camelizeKeys from 'vault/utils/camelize-object-keys';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
@withConfirmLeave('model.newRootModel')
export default class PkiIssuerRotateRootRoute extends Route {
@service secretMountPath;
@service store;
@ -23,14 +20,9 @@ export default class PkiIssuerRotateRootRoute extends Route {
const errorMessage = certData.parsing_errors.map((e) => e.message).join(', ');
parsingErrors = errorMessage;
}
const newRootModel = this.store.createRecord('pki/action', {
actionType: 'rotate-root',
type: 'internal',
...camelizeKeys(certData), // copy old root settings over to new one
});
return hash({
oldRoot,
newRootModel,
certData,
parsingErrors,
backend: this.secretMountPath.currentPath,
});

View File

@ -5,7 +5,6 @@
<Page::PkiConfigureCreate
@breadcrumbs={{this.breadcrumbs}}
@config={{this.model.config}}
@urls={{this.model.urls}}
@capabilities={{this.model.capabilities}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.overview"}}
/>

View File

@ -16,9 +16,10 @@
</PageHeader>
<Page::PkiConfigurationEdit
@acme={{this.model.acme}}
@cluster={{this.model.cluster}}
@urls={{this.model.urls}}
@crl={{this.model.crl}}
@backend={{this.model.engineId}}
@acmeForm={{this.model.acmeForm}}
@clusterForm={{this.model.clusterForm}}
@urlsForm={{this.model.urlsForm}}
@crlForm={{this.model.crlForm}}
@backend={{this.model.engine.id}}
@capabilities={{this.model.capabilities}}
/>

View File

@ -12,8 +12,7 @@
@urls={{this.model.urls}}
@crl={{this.model.crl}}
@backend={{this.model.engine.id}}
@canDeleteAllIssuers={{this.model.issuerModel.canDeleteAllIssuers}}
@hasConfig={{this.model.hasConfig}}
@canDeleteAllIssuers={{this.model.capabilities.canDeleteAllIssuers}}
/>
{{else}}
<Toolbar>

View File

@ -3,4 +3,4 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::PkiIssuerGenerateIntermediate @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />
<Page::PkiIssuerGenerateIntermediate @breadcrumbs={{this.breadcrumbs}} />

View File

@ -3,4 +3,4 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::PkiIssuerGenerateRoot @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />
<Page::PkiIssuerGenerateRoot @breadcrumbs={{this.breadcrumbs}} />

View File

@ -5,7 +5,7 @@
<Page::PkiIssuerRotateRoot
@oldRoot={{this.model.oldRoot}}
@newRootModel={{this.model.newRootModel}}
@certData={{this.model.certData}}
@parsingErrors={{this.model.parsingErrors}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.issuer.details"}}

View File

@ -9,13 +9,13 @@
* @returns array of valid key-related attribute names (camelCase). NOTE: Key params are not used on all action endpoints
*/
export function keyParamsByType(type) {
let fields = ['keyName', 'keyType', 'keyBits'];
let fields = ['key_name', 'key_type', 'key_bits'];
if (type === 'existing') {
fields = ['keyRef'];
fields = ['key_ref'];
} else if (type === 'kms') {
fields = ['keyName', 'managedKeyName', 'managedKeyId'];
fields = ['key_name', 'managed_key_name', 'managed_key_id'];
} else if (type === 'exported') {
fields = [...fields, 'privateKeyFormat'];
fields = [...fields, 'private_key_format'];
}
return fields;
}

View File

@ -13,6 +13,7 @@
"ember-concurrency": "*",
"ember-auto-import": "*",
"@hashicorp/design-system-components": "*",
"ember-template-lint": "*",
"@types/ember": "latest",
"@types/ember-data": "latest",
"@types/ember-data__adapter": "latest",

View File

@ -8,6 +8,7 @@ import { setupApplicationTest } from 'ember-qunit';
import { click, currentURL, fillIn, typeIn, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { v4 as uuidv4 } from 'uuid';
import sinon from 'sinon';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
@ -27,6 +28,16 @@ module('Acceptance | pki action forms test', function (hooks) {
const mountPath = `pki-workflow-${uuidv4()}`;
await enablePage.enable('pki', mountPath);
this.mountPath = mountPath;
this.importStub = sinon
.stub(this.owner.lookup('service:api').secrets, 'pkiIssuersImportBundle')
.resolves({
imported_issuers: ['my-imported-issuer', 'imported2'],
imported_keys: ['my-imported-key', 'imported3'],
mapping: {
'my-imported-issuer': 'my-imported-key',
imported2: '',
},
});
await visit('/vault/logout');
});
@ -44,7 +55,7 @@ module('Acceptance | pki action forms test', function (hooks) {
});
test('happy path', async function (assert) {
await login(this.pkiAdminToken);
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/overview`);
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/overview`);
await click(`${GENERAL.emptyStateActions} a`);
@ -77,20 +88,7 @@ module('Acceptance | pki action forms test', function (hooks) {
);
});
test('with many imports', async function (assert) {
this.server.post(`${this.mountPath}/config/ca`, () => {
return {
request_id: 'some-config-id',
data: {
imported_issuers: ['my-imported-issuer', 'imported2'],
imported_keys: ['my-imported-key', 'imported3'],
mapping: {
'my-imported-issuer': 'my-imported-key',
imported2: '',
},
},
};
});
await login(this.pkiAdminToken);
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/configuration/create`);
await click(PKI_CONFIGURE_CREATE.optionByKey('import'));
assert.dom(PKI_CONFIGURE_CREATE.importForm).exists('import form is shown save');
@ -118,20 +116,16 @@ module('Acceptance | pki action forms test', function (hooks) {
);
});
test('shows imported items when keys is empty', async function (assert) {
this.server.post(`${this.mountPath}/config/ca`, () => {
return {
request_id: 'some-config-id',
data: {
imported_issuers: ['my-imported-issuer', 'my-imported-issuer2'],
imported_keys: null,
mapping: {
'my-imported-issuer': '',
'my-imported-issuer2': '',
},
},
};
this.importStub.resolves({
imported_issuers: ['my-imported-issuer', 'my-imported-issuer2'],
imported_keys: null,
mapping: {
'my-imported-issuer': '',
'my-imported-issuer2': '',
},
});
await login(this.pkiAdminToken);
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/configuration/create`);
await click(PKI_CONFIGURE_CREATE.optionByKey('import'));
assert.dom(PKI_CONFIGURE_CREATE.importForm).exists('import form is shown save');
@ -147,17 +141,13 @@ module('Acceptance | pki action forms test', function (hooks) {
assert.dom(PKI_CONFIGURE_CREATE.importedKey).hasText('None', 'Shows placeholder value for key');
});
test('shows None for imported items if nothing new imported', async function (assert) {
this.server.post(`${this.mountPath}/config/ca`, () => {
return {
request_id: 'some-config-id',
data: {
imported_issuers: null,
imported_keys: null,
mapping: {},
},
};
this.importStub.resolves({
imported_issuers: null,
imported_keys: null,
mapping: {},
});
await login(this.pkiAdminToken);
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/configuration/create`);
await click(PKI_CONFIGURE_CREATE.optionByKey('import'));
assert.dom(PKI_CONFIGURE_CREATE.importForm).exists('import form is shown save');
@ -191,10 +181,10 @@ module('Acceptance | pki action forms test', function (hooks) {
assert.dom(PKI_GENERATE_ROOT.urlField).exists({ count: 5 });
// Fill in form
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await typeIn(GENERAL.inputByAttr('commonName'), commonName);
await typeIn(GENERAL.inputByAttr('issuerName'), issuerName);
await typeIn(GENERAL.inputByAttr('common_name'), commonName);
await typeIn(GENERAL.inputByAttr('issuer_name'), issuerName);
await click(GENERAL.button('Key parameters'));
await typeIn(GENERAL.inputByAttr('keyName'), keyName);
await typeIn(GENERAL.inputByAttr('key_name'), keyName);
await click(GENERAL.submitButton);
assert.strictEqual(
@ -222,7 +212,7 @@ module('Acceptance | pki action forms test', function (hooks) {
await click(PKI_CONFIGURE_CREATE.optionByKey('generate-root'));
// Fill in form
await fillIn(GENERAL.inputByAttr('type'), 'exported');
await typeIn(GENERAL.inputByAttr('commonName'), commonName);
await typeIn(GENERAL.inputByAttr('common_name'), commonName);
await click(GENERAL.submitButton);
assert.strictEqual(
@ -258,7 +248,7 @@ module('Acceptance | pki action forms test', function (hooks) {
assert.dom(GENERAL.title).hasText('Configure PKI');
await click(PKI_CONFIGURE_CREATE.optionByKey('generate-csr'));
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'my-common-name');
await fillIn(GENERAL.inputByAttr('common_name'), 'my-common-name');
await click('[data-test-submit]');
assert.dom(GENERAL.title).hasText('View Generated CSR');
await assert.dom(PKI_CONFIGURE_CREATE.csrDetails).exists('renders CSR details after save');
@ -275,7 +265,7 @@ module('Acceptance | pki action forms test', function (hooks) {
await click(`${GENERAL.emptyStateActions} a`);
await click(PKI_CONFIGURE_CREATE.optionByKey('generate-csr'));
await fillIn(GENERAL.inputByAttr('type'), 'exported');
await fillIn(GENERAL.inputByAttr('commonName'), 'my-common-name');
await fillIn(GENERAL.inputByAttr('common_name'), 'my-common-name');
await click('[data-test-submit]');
await assert.dom(PKI_CONFIGURE_CREATE.csrDetails).exists('renders CSR details after save');
assert.dom(GENERAL.title).hasText('View Generated CSR');

View File

@ -37,7 +37,6 @@ module('Acceptance | pki configuration test', function (hooks) {
});
hooks.afterEach(async function () {
await logout();
await login();
// Cleanup engine
await runCmd([`delete sys/mounts/${this.mountPath}`]);
@ -47,15 +46,15 @@ module('Acceptance | pki configuration test', function (hooks) {
setupMirage(hooks);
test('it shows the delete all issuers modal', async function (assert) {
await login(this.pkiAdminToken);
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/configuration`);
await click(PKI_CONFIGURE_CREATE.configureButton);
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/configuration/create`);
await settled();
await click(PKI_CONFIGURE_CREATE.generateRootOption);
await fillIn(GENERAL.inputByAttr('type'), 'exported');
await fillIn(GENERAL.inputByAttr('commonName'), 'issuer-common-0');
await fillIn(GENERAL.inputByAttr('issuerName'), 'issuer-0');
await fillIn(GENERAL.inputByAttr('common_name'), 'issuer-common-0');
await fillIn(GENERAL.inputByAttr('issuer_name'), 'issuer-0');
await click(GENERAL.submitButton);
await click(PKI_CONFIGURE_CREATE.doneButton);
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/overview`);
@ -77,7 +76,7 @@ module('Acceptance | pki configuration test', function (hooks) {
});
test('it shows the correct empty state message if certificates exists after delete all issuers', async function (assert) {
await login(this.pkiAdminToken);
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/configuration`);
await click(PKI_CONFIGURE_CREATE.configureButton);
assert.strictEqual(
@ -87,8 +86,8 @@ module('Acceptance | pki configuration test', function (hooks) {
);
await click(PKI_CONFIGURE_CREATE.generateRootOption);
await fillIn(GENERAL.inputByAttr('type'), 'exported');
await fillIn(GENERAL.inputByAttr('commonName'), 'issuer-common-0');
await fillIn(GENERAL.inputByAttr('issuerName'), 'issuer-0');
await fillIn(GENERAL.inputByAttr('common_name'), 'issuer-common-0');
await fillIn(GENERAL.inputByAttr('issuer_name'), 'issuer-0');
await click(GENERAL.submitButton);
await click(PKI_CONFIGURE_CREATE.doneButton);
assert.strictEqual(
@ -156,15 +155,15 @@ module('Acceptance | pki configuration test', function (hooks) {
});
test('it shows the correct empty state message if roles and certificates exists after delete all issuers', async function (assert) {
await login(this.pkiAdminToken);
await login();
// Configure PKI
await visit(`/vault/secrets-engines/${this.mountPath}/pki/configuration`);
await click(PKI_CONFIGURE_CREATE.configureButton);
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/configuration/create`);
await click(PKI_CONFIGURE_CREATE.generateRootOption);
await fillIn(GENERAL.inputByAttr('type'), 'exported');
await fillIn(GENERAL.inputByAttr('commonName'), 'issuer-common-0');
await fillIn(GENERAL.inputByAttr('issuerName'), 'issuer-0');
await fillIn(GENERAL.inputByAttr('common_name'), 'issuer-common-0');
await fillIn(GENERAL.inputByAttr('issuer_name'), 'issuer-0');
await click(GENERAL.submitButton);
await click(PKI_CONFIGURE_CREATE.doneButton);
// Create role and root CA"
@ -230,18 +229,18 @@ module('Acceptance | pki configuration test', function (hooks) {
// test coverage for ed25519 certs not displaying because the verify() function errors
test('it generates and displays a root issuer of key type = ed25519', async function (assert) {
assert.expect(4);
await login(this.pkiAdminToken);
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/overview`);
await click(GENERAL.secretTab('Issuers'));
await click(PKI_ISSUER_LIST.generateIssuerDropdown);
await click(PKI_ISSUER_LIST.generateIssuerRoot);
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'my-certificate');
await fillIn(GENERAL.inputByAttr('common_name'), 'my-certificate');
await click(GENERAL.button('Key parameters'));
await fillIn(GENERAL.inputByAttr('keyType'), 'ed25519');
await fillIn(GENERAL.inputByAttr('key_type'), 'ed25519');
await click(GENERAL.submitButton);
const issuerId = find(PKI_GENERATE_ROOT.saved.issuerLink).innerHTML;
const issuerId = find(PKI_GENERATE_ROOT.saved.issuerLink).innerHTML.trim();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/issuers`);
assert.dom(PKI_ISSUER_LIST.issuerListItem(issuerId)).exists();
assert

View File

@ -54,7 +54,7 @@ module('Acceptance | pki/pki cross sign', function (hooks) {
await visit(`/vault/secrets-engines/${this.intMountPath}/pki/configuration/create`);
await click(PKI_CONFIGURE_CREATE.optionByKey('generate-csr'));
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'Short-Lived Int R1');
await fillIn(GENERAL.inputByAttr('common_name'), 'Short-Lived Int R1');
await click(GENERAL.submitButton);
await click(PKI_CROSS_SIGN.copyButton('CSR'));
const csr = clipboardSpy.firstCall.args[0];

View File

@ -43,43 +43,6 @@ module('Acceptance | pki engine route cleanup test', function (hooks) {
await runCmd([`delete sys/mounts/${this.mountPath}`]);
});
module('configuration', function () {
test('create config', async function (assert) {
let configs, urls, config;
await login(this.pkiAdminToken);
await visit(`/vault/secrets-engines/${this.mountPath}/pki/overview`);
await click(`${GENERAL.emptyStateActions} a`);
configs = this.store.peekAll('pki/action');
urls = this.store.peekRecord('pki/config/urls', this.mountPath);
config = configs.at(0);
assert.strictEqual(configs.length, 1, 'One config model present');
assert.false(urls.hasDirtyAttributes, 'URLs is loaded from endpoint');
assert.true(config.hasDirtyAttributes, 'Config model is dirty');
// Cancel button rolls it back
await click(GENERAL.cancelButton);
configs = this.store.peekAll('pki/action');
urls = this.store.peekRecord('pki/config/urls', this.mountPath);
assert.strictEqual(configs.length, 0, 'config model is rolled back on cancel');
assert.strictEqual(urls.id, this.mountPath, 'Urls still exists on exit');
await click(`${GENERAL.emptyStateActions} a`);
configs = this.store.peekAll('pki/action');
urls = this.store.peekRecord('pki/config/urls', this.mountPath);
config = configs.at(0);
assert.strictEqual(configs.length, 1, 'One config model present');
assert.false(urls.hasDirtyAttributes, 'URLs is loaded from endpoint');
assert.true(config.hasDirtyAttributes, 'Config model is dirty');
// Exit page via link rolls it back
await click(OVERVIEW_BREADCRUMB);
configs = this.store.peekAll('pki/action');
urls = this.store.peekRecord('pki/config/urls', this.mountPath);
assert.strictEqual(configs.length, 0, 'config model is rolled back on cancel');
assert.strictEqual(urls.id, this.mountPath, 'Urls still exists on exit');
});
});
module('role routes', function (hooks) {
hooks.beforeEach(async function () {
await login();
@ -88,7 +51,7 @@ module('Acceptance | pki engine route cleanup test', function (hooks) {
await click(`${GENERAL.emptyStateActions} a`);
await click(PKI_CONFIGURE_CREATE.optionByKey('generate-root'));
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'my-root-cert');
await fillIn(GENERAL.inputByAttr('common_name'), 'my-root-cert');
await click(GENERAL.submitButton);
});
@ -211,90 +174,6 @@ module('Acceptance | pki engine route cleanup test', function (hooks) {
issuers = this.store.peekAll('pki/action');
assert.strictEqual(issuers.length, 0, 'Issuer is removed from store');
});
test('generate root exit via cancel', async function (assert) {
let actions;
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/overview`);
await click(GENERAL.secretTab('Issuers'));
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'No actions exist yet');
await click(PKI_ISSUER_LIST.generateIssuerDropdown);
await click(PKI_ISSUER_LIST.generateIssuerRoot);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 1, 'Action model for generate-root created');
const action = actions.at(0);
assert.true(action.hasDirtyAttributes, 'Action has dirty attrs');
assert.true(action.isNew, 'Action is new');
assert.strictEqual(action.actionType, 'generate-root', 'Action type is correct');
// Exit
await click(GENERAL.cancelButton);
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/issuers`);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'Action is removed from store');
});
test('generate root exit via breadcrumb', async function (assert) {
let actions;
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/overview`);
await click(GENERAL.secretTab('Issuers'));
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'No actions exist yet');
await click(PKI_ISSUER_LIST.generateIssuerDropdown);
await click(PKI_ISSUER_LIST.generateIssuerRoot);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 1, 'Action model for generate-root created');
const action = actions.at(0);
assert.true(action.hasDirtyAttributes, 'Action has dirty attrs');
assert.true(action.isNew, 'Action is new');
assert.strictEqual(action.actionType, 'generate-root');
// Exit
await click(OVERVIEW_BREADCRUMB);
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/overview`);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'Action is removed from store');
});
test('generate intermediate csr exit via cancel', async function (assert) {
let actions;
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/overview`);
await click(GENERAL.secretTab('Issuers'));
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'No actions exist yet');
await await click(PKI_ISSUER_LIST.generateIssuerDropdown);
await click(PKI_ISSUER_LIST.generateIssuerIntermediate);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 1, 'Action model for generate-csr created');
const action = actions.at(0);
assert.true(action.hasDirtyAttributes, 'Action has dirty attrs');
assert.true(action.isNew, 'Action is new');
assert.strictEqual(action.actionType, 'generate-csr');
// Exit
await click('[data-test-cancel]');
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/issuers`);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'Action is removed from store');
});
test('generate intermediate csr exit via breadcrumb', async function (assert) {
let actions;
await login();
await visit(`/vault/secrets-engines/${this.mountPath}/pki/overview`);
await click(GENERAL.secretTab('Issuers'));
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'No actions exist yet');
await click(PKI_ISSUER_LIST.generateIssuerDropdown);
await click(PKI_ISSUER_LIST.generateIssuerIntermediate);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 1, 'Action model for generate-csr created');
const action = actions.at(0);
assert.true(action.hasDirtyAttributes, 'Action has dirty attrs');
assert.true(action.isNew, 'Action is new');
assert.strictEqual(action.actionType, 'generate-csr');
// Exit
await click(OVERVIEW_BREADCRUMB);
assert.strictEqual(currentURL(), `/vault/secrets-engines/${this.mountPath}/pki/overview`);
actions = this.store.peekAll('pki/action');
assert.strictEqual(actions.length, 0, 'Action is removed from store');
});
test('edit issuer exit', async function (assert) {
let issuers, issuer;
await login();
@ -302,7 +181,7 @@ module('Acceptance | pki engine route cleanup test', function (hooks) {
await click(`${GENERAL.emptyStateActions} a`);
await click(PKI_CONFIGURE_CREATE.optionByKey('generate-root'));
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'my-root-cert');
await fillIn(GENERAL.inputByAttr('common_name'), 'my-root-cert');
await click(GENERAL.submitButton);
// Go to list view so we fetch all the issuers
await visit(`/vault/secrets-engines/${this.mountPath}/pki/issuers`);
@ -332,7 +211,7 @@ module('Acceptance | pki engine route cleanup test', function (hooks) {
await click(`${GENERAL.emptyStateActions} a`);
await click(PKI_CONFIGURE_CREATE.optionByKey('generate-root'));
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'my-root-cert');
await fillIn(GENERAL.inputByAttr('common_name'), 'my-root-cert');
await click(GENERAL.submitButton);
});
test('create key exit', async function (assert) {

View File

@ -491,7 +491,7 @@ module('Acceptance | pki workflow', function (hooks) {
'it navigates to root rotate form'
);
assert
.dom('[data-test-input="commonName"]')
.dom('[data-test-input="common_name"]')
.hasValue('Hashicorp Test', 'form prefilled with parent issuer cn');
});
});
@ -520,8 +520,8 @@ module('Acceptance | pki workflow', function (hooks) {
'Not all of the certificate values can be parsed and transferred to a new root',
'it renders warning banner'
);
assert.dom('[data-test-input="commonName"]').hasValue('fancy-cert-unsupported-subj-and-ext-oids');
await fillIn('[data-test-input="issuerName"]', 'existing-issuer');
assert.dom('[data-test-input="common_name"]').hasValue('fancy-cert-unsupported-subj-and-ext-oids');
await fillIn('[data-test-input="issuer_name"]', 'existing-issuer');
await click(GENERAL.submitButton);
assert
.dom('[data-test-rotate-error]')

View File

@ -25,6 +25,14 @@ export function clearRecords(store: Store) {
store.unloadAll('capabilities');
}
export const configCapabilities = {
canImportBundle: true,
canSetAcme: true,
canSetCluster: true,
canSetCrl: true,
canSetUrls: true,
};
/**
* The following are certificate values used for testing. They are exported under the CERTIFICATES object.
*/

View File

@ -126,9 +126,11 @@ export const PKI_CONFIG_EDIT = {
acmeEditSection: '[data-test-acme-edit-section]',
configEditSection: '[data-test-cluster-config-edit-section]',
configInput: (attr: string) => `[data-test-input="${attr}"]`,
stringListInput: (attr: string) => `[data-test-input="${attr}"] [data-test-string-list-input="0"]`,
stringListInput: (attr: string, number = 0) =>
`[data-test-input="${attr}"] [data-test-string-list-input="${number}"]`,
urlsEditSection: '[data-test-urls-edit-section]',
urlFieldInput: (attr: string) => `[data-test-input="${attr}"] textarea`,
urlFieldInput: (attr: string, isTextarea = true) =>
`[data-test-input="${attr}"]${isTextarea ? ' textarea' : ''}`,
urlFieldLabel: (attr: string) => `[data-test-input="${attr}"] label`,
crlEditSection: '[data-test-crl-edit-section]',
crlToggleInput: (attr: string) => `[data-test-input="${attr}"] input`,

View File

@ -20,55 +20,86 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
setupEngine(hooks, 'pki');
hooks.beforeEach(function () {
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.store = this.owner.lookup('service:store');
this.cluster = this.store.createRecord('pki/config/cluster', {
id: 'pki-test',
this.cluster = {
path: 'https://pr-a.vault.example.com/v1/ns1/pki-root',
});
this.urls = this.store.createRecord('pki/config/urls', {
id: 'pki-test',
issuingCertificates: 'example.com',
});
this.crl = this.store.createRecord('pki/config/crl', {
id: 'pki-test',
};
this.urls = {
issuing_certificates: 'example.com',
};
this.crl = {
expiry: '20h',
disable: false,
autoRebuild: true,
autoRebuildGracePeriod: '13h',
enableDelta: true,
deltaRebuildInterval: '15m',
ocspExpiry: '77h',
ocspDisable: false,
crossClusterRevocation: true,
unifiedCrl: true,
unifiedCrlOnExistingPaths: true,
});
auto_rebuild: true,
auto_rebuild_grace_period: '13h',
enable_delta: true,
delta_rebuild_interval: '15m',
ocsp_expiry: '77h',
ocsp_disable: false,
cross_cluster_revocation: true,
unified_crl: true,
unified_crl_on_existing_paths: true,
};
this.acme = {
enabled: true,
default_directory_policy: 'foo',
allowed_roles: 'test',
allow_role_ext_key_usage: false,
allowed_issuers: 'bar',
eab_policy: 'not-required',
dns_resolver: 'resolver',
max_ttl: '72h',
};
// Fails on #ember-testing-container
setRunOptions({
rules: {
'scrollable-region-focusable': { enabled: false },
},
});
this.renderComponent = () =>
render(
hbs`
<Page::PkiConfigurationDetails
@acme={{this.acme}}
@cluster={{this.cluster}}
@urls={{this.urls}}
@crl={{this.crl}}
@backend="pki-test"
@canDeleteAllIssuers={{this.canDeleteAllIssuers}}
/>
`,
{ owner: this.engine }
);
});
test('shows the correct information on cluster config', async function (assert) {
await render(hbs`<Page::PkiConfigurationDetails @cluster={{this.cluster}} @hasConfig={{true}} />,`, {
owner: this.engine,
});
await this.renderComponent();
assert
.dom(GENERAL.infoRowValue("Mount's API path"))
.hasText('https://pr-a.vault.example.com/v1/ns1/pki-root', 'mount API path row renders');
assert.dom(GENERAL.infoRowValue('AIA path')).hasText('None', "renders 'None' when no data");
});
test('shows the correct information on acme config', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('ACME enabled')).hasText('Yes', 'enabled value renders');
assert
.dom(GENERAL.infoRowValue('Default directory policy'))
.hasText('foo', 'default_directory_policy value renders');
assert.dom(GENERAL.infoRowValue('Allowed roles')).hasText('test', 'allowed_roles value renders');
assert
.dom(GENERAL.infoRowValue('Allow role ExtKeyUsage'))
.hasText('None', 'allow_role_ext_key_usage value renders');
assert.dom(GENERAL.infoRowValue('Allowed issuers')).hasText('bar', 'allowed_issuers value renders');
assert.dom(GENERAL.infoRowValue('EAB policy')).hasText('not-required', 'eab_policy value renders');
assert.dom(GENERAL.infoRowValue('DNS resolver')).hasText('resolver', 'dns_resolver value renders');
assert.dom(GENERAL.infoRowValue('Max TTL')).hasText('3 days', 'max ttl value renders');
});
test('shows the correct information on global urls section', async function (assert) {
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
await this.renderComponent();
assert
.dom(GENERAL.infoRowLabel('Issuing certificates'))
@ -76,11 +107,10 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
assert
.dom(GENERAL.infoRowValue('Issuing certificates'))
.hasText('example.com', 'issuing certificate value renders');
this.urls.issuingCertificates = null;
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
this.urls.issuing_certificates = null;
await this.renderComponent();
assert
.dom(GENERAL.infoRowValue('Issuing certificates'))
.hasText('None', 'issuing certificate value renders None if none is configured');
@ -93,10 +123,7 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
});
test('shows the correct information on crl section', async function (assert) {
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
await this.renderComponent();
assert.dom(GENERAL.infoRowLabel('CRL building')).hasText('CRL building', 'crl expiry row label renders');
assert.dom(GENERAL.infoRowValue('CRL building')).hasText('Enabled', 'enabled renders');
@ -117,12 +144,10 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
.hasText('Enabled', 'responder apis value renders Enabled if ocsp_disable=false');
assert.dom(GENERAL.infoRowValue('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}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
this.crl.auto_rebuild = false;
this.crl.enable_delta = false;
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('Auto-rebuild')).hasText('Off', 'it renders falsy auto build');
assert.dom(SELECTORS.rowIcon('Auto-rebuild', 'x-square'));
assert
@ -135,14 +160,12 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
.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.auto_rebuild = true;
this.crl.enable_delta = true;
this.crl.disable = true;
this.crl.ocspDisable = true;
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
this.crl.ocsp_disable = true;
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('CRL building')).hasText('Disabled', 'disabled renders');
assert.dom(GENERAL.infoRowValue('Expiry')).doesNotExist();
assert
@ -156,12 +179,9 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
});
test('it renders enterprise params in crl section', async function (assert) {
this.version = this.owner.lookup('service:version');
this.version.type = 'enterprise';
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
this.owner.lookup('service:version').type = 'enterprise';
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('Cross-cluster revocation')).hasText('Yes');
assert.dom(SELECTORS.rowIcon('Cross-cluster revocation', 'check-circle'));
assert.dom(GENERAL.infoRowValue('Unified CRL')).hasText('Yes');
@ -171,12 +191,9 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
});
test('it does not render enterprise params in crl section', async function (assert) {
this.version = this.owner.lookup('service:version');
this.version.type = 'community';
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
this.owner.lookup('service:version').type = 'community';
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('Cross-cluster revocation')).doesNotExist();
assert.dom(GENERAL.infoRowValue('Unified CRL')).doesNotExist();
assert.dom(GENERAL.infoRowValue('Unified CRL on existing paths')).doesNotExist();

View File

@ -8,40 +8,30 @@ 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 { Response } from 'miragejs';
import sinon from 'sinon';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { PKI_CONFIG_EDIT } from 'vault/tests/helpers/pki/pki-selectors';
import { configCapabilities } from 'vault/tests/helpers/pki/pki-helpers';
import PkiConfigClusterForm from 'vault/forms/secrets/pki/config/cluster';
import PkiConfigAcmeForm from 'vault/forms/secrets/pki/config/acme';
import PkiConfigCrlForm from 'vault/forms/secrets/pki/config/crl';
import PkiConfigUrlsForm from 'vault/forms/secrets/pki/config/urls';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
module('Integration | Component | page/pki-configuration-edit', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(async function () {
// test context setup
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.router = this.owner.lookup('service:router');
sinon.stub(this.router, 'transitionTo');
// component data setup
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/config/cluster', {
modelName: 'pki/config/cluster',
id: this.backend,
});
this.store.pushPayload('pki/config/acme', {
modelName: 'pki/config/acme',
id: this.backend,
});
this.store.pushPayload('pki/config/crl', {
modelName: 'pki/config/crl',
id: this.backend,
this.clusterForm = new PkiConfigClusterForm();
this.acmeForm = new PkiConfigAcmeForm();
this.crlForm = new PkiConfigCrlForm({
auto_rebuild: false,
auto_rebuild_grace_period: '12h',
delta_rebuild_interval: '15m',
@ -51,127 +41,79 @@ module('Integration | Component | page/pki-configuration-edit', function (hooks)
ocsp_disable: false,
ocsp_expiry: '12h',
});
this.store.pushPayload('pki/config/urls', {
modelName: 'pki/config/urls',
id: this.backend,
this.urlsForm = new PkiConfigUrlsForm({
issuing_certificates: ['hashicorp.com'],
crl_distribution_points: ['some-crl-distribution.com'],
ocsp_servers: ['ocsp-stuff.com'],
});
this.acme = this.store.peekRecord('pki/config/acme', this.backend);
this.cluster = this.store.peekRecord('pki/config/cluster', this.backend);
this.crl = this.store.peekRecord('pki/config/crl', this.backend);
this.urls = this.store.peekRecord('pki/config/urls', this.backend);
});
this.capabilities = configCapabilities;
hooks.afterEach(function () {
this.router.transitionTo.restore();
// api stubs
const { secrets } = this.owner.lookup('service:api');
this.clusterStub = sinon.stub(secrets, 'pkiConfigureCluster').resolves();
this.acmeStub = sinon.stub(secrets, 'pkiConfigureAcme').resolves();
this.urlsStub = sinon.stub(secrets, 'pkiConfigureUrls').resolves();
this.crlStub = sinon.stub(secrets, 'pkiConfigureCrl').resolves();
this.renderComponent = () =>
render(
hbs`
<Page::PkiConfigurationEdit
@acmeForm={{this.acmeForm}}
@clusterForm={{this.clusterForm}}
@urlsForm={{this.urlsForm}}
@crlForm={{this.crlForm}}
@backend={{this.backend}}
@capabilities={{this.capabilities}}
/>
`,
{ owner: this.engine }
);
});
test('it renders with config data and updates config', async function (assert) {
assert.expect(32);
this.server.post(`/${this.backend}/config/acme`, (schema, req) => {
assert.ok(true, 'request made to save acme config');
assert.propEqual(
JSON.parse(req.requestBody),
{
allowed_issuers: ['*'],
allowed_roles: ['my-role'],
dns_resolver: 'some-dns',
eab_policy: 'new-account-required',
enabled: true,
},
'it updates acme config model attributes'
);
});
this.server.post(`/${this.backend}/config/cluster`, (schema, req) => {
assert.ok(true, 'request made to save cluster config');
assert.propEqual(
JSON.parse(req.requestBody),
{
path: 'https://pr-a.vault.example.com/v1/ns1/pki-root',
aia_path: 'http://another-path.com',
},
'it updates cluster config model attributes'
);
});
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 config 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 config model attributes'
);
});
await render(
hbs`
<Page::PkiConfigurationEdit
@acme={{this.acme}}
@cluster={{this.cluster}}
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
assert.expect(30);
await this.renderComponent();
assert.dom(PKI_CONFIG_EDIT.configEditSection).exists('renders config section');
assert.dom(PKI_CONFIG_EDIT.urlsEditSection).exists('renders urls section');
assert.dom(PKI_CONFIG_EDIT.crlEditSection).exists('renders crl section');
assert.dom(PKI_CONFIG_EDIT.cancelButton).exists();
this.urls.eachAttribute((name) => {
assert.dom(PKI_CONFIG_EDIT.urlFieldInput(name)).exists(`renders ${name} input`);
this.urlsForm.formFields.forEach(({ name }) => {
const isTextarea = name !== 'enable_templating';
assert.dom(PKI_CONFIG_EDIT.urlFieldInput(name, isTextarea)).exists(`renders ${name} input`);
});
assert.dom(PKI_CONFIG_EDIT.urlFieldInput('issuingCertificates')).hasValue('hashicorp.com');
assert.dom(PKI_CONFIG_EDIT.urlFieldInput('crlDistributionPoints')).hasValue('some-crl-distribution.com');
assert.dom(PKI_CONFIG_EDIT.urlFieldInput('ocspServers')).hasValue('ocsp-stuff.com');
assert.dom(PKI_CONFIG_EDIT.urlFieldInput('issuing_certificates')).hasValue('hashicorp.com');
assert
.dom(PKI_CONFIG_EDIT.urlFieldInput('crl_distribution_points'))
.hasValue('some-crl-distribution.com');
assert.dom(PKI_CONFIG_EDIT.urlFieldInput('ocsp_servers')).hasValue('ocsp-stuff.com');
// cluster config
await fillIn(PKI_CONFIG_EDIT.configInput('path'), 'https://pr-a.vault.example.com/v1/ns1/pki-root');
await fillIn(PKI_CONFIG_EDIT.configInput('aiaPath'), 'http://another-path.com');
await fillIn(PKI_CONFIG_EDIT.configInput('aia_path'), 'http://another-path.com');
// acme config;
await click(PKI_CONFIG_EDIT.configInput('enabled'));
await fillIn(PKI_CONFIG_EDIT.stringListInput('allowedRoles'), 'my-role');
await fillIn(PKI_CONFIG_EDIT.stringListInput('allowedIssuers'), '*');
await fillIn(PKI_CONFIG_EDIT.configInput('eabPolicy'), 'new-account-required');
await fillIn(PKI_CONFIG_EDIT.configInput('dnsResolver'), 'some-dns');
await fillIn(PKI_CONFIG_EDIT.stringListInput('allowed_roles'), 'my-role');
await fillIn(PKI_CONFIG_EDIT.stringListInput('allowed_issuers'), '*');
await fillIn(PKI_CONFIG_EDIT.configInput('eab_policy'), 'new-account-required');
await fillIn(PKI_CONFIG_EDIT.configInput('dns_resolver'), 'some-dns');
// urls
await fillIn(PKI_CONFIG_EDIT.urlFieldInput('issuingCertificates'), 'update-hashicorp.com');
await fillIn(PKI_CONFIG_EDIT.urlFieldInput('crlDistributionPoints'), 'test-crl.com');
await fillIn(PKI_CONFIG_EDIT.urlFieldInput('ocspServers'), 'ocsp.com');
await fillIn(PKI_CONFIG_EDIT.urlFieldInput('issuing_certificates'), 'update-hashicorp.com');
await fillIn(PKI_CONFIG_EDIT.urlFieldInput('crl_distribution_points'), 'test-crl.com');
await fillIn(PKI_CONFIG_EDIT.urlFieldInput('ocsp_servers'), 'ocsp.com');
// confirm default toggle state and text
this.crl.eachAttribute((name, { options }) => {
if (['expiry', 'ocspExpiry'].includes(name)) {
this.crlForm.formFields.forEach(({ name, options }) => {
if (['expiry', 'ocsp_expiry'].includes(name)) {
assert.dom(PKI_CONFIG_EDIT.crlToggleInput(name)).isChecked(`${name} defaults to toggled on`);
assert.dom(PKI_CONFIG_EDIT.crlFieldLabel(name)).hasTextContaining(options.label);
assert.dom(PKI_CONFIG_EDIT.crlFieldLabel(name)).hasTextContaining(options.helperTextEnabled);
}
if (['autoRebuildGracePeriod', 'deltaRebuildInterval'].includes(name)) {
if (['auto_rebuild_grace_period', 'delta_rebuild_interval'].includes(name)) {
assert.dom(PKI_CONFIG_EDIT.crlToggleInput(name)).isNotChecked(`${name} defaults off`);
assert.dom(PKI_CONFIG_EDIT.crlFieldLabel(name)).hasTextContaining(options.labelDisabled);
assert.dom(PKI_CONFIG_EDIT.crlFieldLabel(name)).hasTextContaining(options.helperTextDisabled);
@ -179,16 +121,16 @@ module('Integration | Component | page/pki-configuration-edit', function (hooks)
});
// toggle everything on
await click(PKI_CONFIG_EDIT.crlToggleInput('autoRebuildGracePeriod'));
await click(PKI_CONFIG_EDIT.crlToggleInput('auto_rebuild_grace_period'));
assert
.dom(PKI_CONFIG_EDIT.crlFieldLabel('autoRebuildGracePeriod'))
.dom(PKI_CONFIG_EDIT.crlFieldLabel('auto_rebuild_grace_period'))
.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(PKI_CONFIG_EDIT.crlToggleInput('deltaRebuildInterval'));
await click(PKI_CONFIG_EDIT.crlToggleInput('delta_rebuild_interval'));
assert
.dom(PKI_CONFIG_EDIT.crlFieldLabel('deltaRebuildInterval'))
.dom(PKI_CONFIG_EDIT.crlFieldLabel('delta_rebuild_interval'))
.hasTextContaining(
'Delta CRL building on Vault will rebuild the delta CRL at the interval below:',
'it renders delta crl build toggled on text'
@ -201,193 +143,167 @@ module('Integration | Component | page/pki-configuration-edit', function (hooks)
await fillIn(PKI_CONFIG_EDIT.crlTtlInput('OCSP responder APIs enabled'), '24');
await click(PKI_CONFIG_EDIT.saveButton);
assert.true(
this.acmeStub.calledWith(this.backend, {
allowed_issuers: ['*'],
allowed_roles: ['my-role'],
dns_resolver: 'some-dns',
eab_policy: 'new-account-required',
enabled: true,
}),
'request made to update acme config'
);
assert.true(
this.clusterStub.calledWith(this.backend, {
path: 'https://pr-a.vault.example.com/v1/ns1/pki-root',
aia_path: 'http://another-path.com',
}),
'request made to update cluster config'
);
assert.true(
this.urlsStub.calledWith(this.backend, {
crl_distribution_points: ['test-crl.com'],
issuing_certificates: ['update-hashicorp.com'],
ocsp_servers: ['ocsp.com'],
}),
'request made to update urls config'
);
assert.true(
this.crlStub.calledWith(this.backend, {
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',
}),
'request made to update crl config'
);
});
test('it removes urls and sends false crl values', async function (assert) {
assert.expect(8);
this.server.post(`/${this.backend}/config/acme`, () => {});
this.server.post(`/${this.backend}/config/cluster`, () => {});
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
@acme={{this.acme}}
@cluster={{this.cluster}}
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
assert.expect(6);
await click(PKI_CONFIG_EDIT.deleteButton('issuingCertificates'));
await click(PKI_CONFIG_EDIT.deleteButton('crlDistributionPoints'));
await click(PKI_CONFIG_EDIT.deleteButton('ocspServers'));
await this.renderComponent();
await click(PKI_CONFIG_EDIT.deleteButton('issuing_certificates'));
await click(PKI_CONFIG_EDIT.deleteButton('crl_distribution_points'));
await click(PKI_CONFIG_EDIT.deleteButton('ocsp_servers'));
// toggle everything off
await click(PKI_CONFIG_EDIT.crlToggleInput('expiry'));
assert.dom(PKI_CONFIG_EDIT.crlFieldLabel('expiry')).hasText('No expiry The CRL will not be built.');
assert
.dom(PKI_CONFIG_EDIT.crlToggleInput('autoRebuildGracePeriod'))
.dom(PKI_CONFIG_EDIT.crlToggleInput('auto_rebuild_grace_period'))
.doesNotExist('expiry off hides the auto rebuild toggle');
assert
.dom(PKI_CONFIG_EDIT.crlToggleInput('deltaRebuildInterval'))
.dom(PKI_CONFIG_EDIT.crlToggleInput('delta_rebuild_interval'))
.doesNotExist('expiry off hides delta crl toggle');
await click(PKI_CONFIG_EDIT.crlToggleInput('ocspExpiry'));
await click(PKI_CONFIG_EDIT.crlToggleInput('ocsp_expiry'));
assert
.dom(PKI_CONFIG_EDIT.crlFieldLabel('ocspExpiry'))
.dom(PKI_CONFIG_EDIT.crlFieldLabel('ocsp_expiry'))
.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(PKI_CONFIG_EDIT.saveButton);
assert.true(
this.crlStub.calledWith(this.backend, {
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',
}),
'request made to update crl config with false values'
);
assert.true(
this.urlsStub.calledWith(this.backend, {
crl_distribution_points: [],
issuing_certificates: [],
ocsp_servers: [],
}),
'request made to update urls config with empty arrays'
);
});
test('it renders enterprise only params', async function (assert) {
assert.expect(6);
this.version = this.owner.lookup('service:version');
this.version.type = 'enterprise';
this.server.post(`/${this.backend}/config/acme`, () => {});
this.server.post(`/${this.backend}/config/cluster`, () => {});
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: false,
enable_delta: false,
expiry: '72h',
ocsp_disable: false,
ocsp_expiry: '12h',
cross_cluster_revocation: true,
unified_crl: true,
unified_crl_on_existing_paths: true,
},
'crl payload includes enterprise params'
);
});
this.server.post(`/${this.backend}/config/urls`, () => {
assert.ok(true, 'request made to save urls config');
});
await render(
hbs`
<Page::PkiConfigurationEdit
@acme={{this.acme}}
@cluster={{this.cluster}}
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
assert.expect(4);
this.owner.lookup('service:version').type = 'enterprise';
await this.renderComponent();
assert.dom(PKI_CONFIG_EDIT.groupHeader('Certificate Revocation List (CRL)')).exists();
assert.dom(PKI_CONFIG_EDIT.groupHeader('Online Certificate Status Protocol (OCSP)')).exists();
assert.dom(PKI_CONFIG_EDIT.groupHeader('Unified Revocation')).exists();
await click(PKI_CONFIG_EDIT.checkboxInput('crossClusterRevocation'));
await click(PKI_CONFIG_EDIT.checkboxInput('unifiedCrl'));
await click(PKI_CONFIG_EDIT.checkboxInput('unifiedCrlOnExistingPaths'));
await click(PKI_CONFIG_EDIT.checkboxInput('cross_cluster_revocation'));
await click(PKI_CONFIG_EDIT.checkboxInput('unified_crl'));
await click(PKI_CONFIG_EDIT.checkboxInput('unified_crl_on_existing_paths'));
await click(PKI_CONFIG_EDIT.saveButton);
assert.true(
this.crlStub.calledWith(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',
cross_cluster_revocation: true,
unified_crl: true,
unified_crl_on_existing_paths: true,
}),
'request made to save crl config with enterprise params'
);
});
test('it does not render enterprise only params for OSS', async function (assert) {
assert.expect(9);
this.version = this.owner.lookup('service:version');
this.version.type = 'community';
this.server.post(`/${this.backend}/config/acme`, () => {});
this.server.post(`/${this.backend}/config/cluster`, () => {});
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: false,
enable_delta: false,
expiry: '72h',
ocsp_disable: false,
ocsp_expiry: '12h',
},
'crl payload does not include enterprise params'
);
});
this.server.post(`/${this.backend}/config/urls`, () => {
assert.ok(true, 'request made to save urls config');
});
await render(
hbs`
<Page::PkiConfigurationEdit
@acme={{this.acme}}
@cluster={{this.cluster}}
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
assert.expect(7);
assert.dom(PKI_CONFIG_EDIT.checkboxInput('crossClusterRevocation')).doesNotExist();
assert.dom(PKI_CONFIG_EDIT.checkboxInput('unifiedCrl')).doesNotExist();
assert.dom(PKI_CONFIG_EDIT.checkboxInput('unifiedCrlOnExistingPaths')).doesNotExist();
this.owner.lookup('service:version').type = 'community';
await this.renderComponent();
assert.dom(PKI_CONFIG_EDIT.checkboxInput('cross_cluster_revocation')).doesNotExist();
assert.dom(PKI_CONFIG_EDIT.checkboxInput('unified_crl')).doesNotExist();
assert.dom(PKI_CONFIG_EDIT.checkboxInput('unified_crl_on_existing_paths')).doesNotExist();
assert.dom(PKI_CONFIG_EDIT.groupHeader('Certificate Revocation List (CRL)')).exists();
assert.dom(PKI_CONFIG_EDIT.groupHeader('Online Certificate Status Protocol (OCSP)')).exists();
assert.dom(PKI_CONFIG_EDIT.groupHeader('Unified Revocation')).doesNotExist();
await click(PKI_CONFIG_EDIT.saveButton);
assert.true(
this.crlStub.calledWith(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',
}),
'request made to save crl config without enterprise params'
);
});
test('it renders empty states if no update capabilities', async function (assert) {
assert.expect(4);
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub(['read']));
await render(
hbs`
<Page::PkiConfigurationEdit
@acme={{this.acme}}
@cluster={{this.cluster}}
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
this.capabilities = {};
await this.renderComponent();
assert
.dom(`${PKI_CONFIG_EDIT.configEditSection} [data-test-component="empty-state"]`)
@ -413,30 +329,13 @@ module('Integration | Component | page/pki-configuration-edit', function (hooks)
test('it renders alert banner and endpoint respective error', async function (assert) {
assert.expect(4);
this.server.post(`/${this.backend}/config/acme`, () => {
return new Response(500, {}, { errors: ['something wrong with acme'] });
});
this.server.post(`/${this.backend}/config/cluster`, () => {
return new Response(500, {}, { errors: ['something wrong with cluster'] });
});
this.server.post(`/${this.backend}/config/crl`, () => {
return new Response(500, {}, { errors: ['something wrong with crl'] });
});
this.server.post(`/${this.backend}/config/urls`, () => {
return new Response(500, {}, { errors: ['something wrong with urls'] });
});
await render(
hbs`
<Page::PkiConfigurationEdit
@acme={{this.acme}}
@cluster={{this.cluster}}
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
this.acmeStub.rejects(getErrorResponse({ errors: ['something wrong with acme'] }, 500));
this.clusterStub.rejects(getErrorResponse({ errors: ['something wrong with cluster'] }, 500));
this.crlStub.rejects(getErrorResponse({ errors: ['something wrong with crl'] }, 500));
this.urlsStub.rejects(getErrorResponse({ errors: ['something wrong with urls'] }, 500));
await this.renderComponent();
await click(PKI_CONFIG_EDIT.saveButton);
assert
@ -447,9 +346,9 @@ module('Integration | Component | page/pki-configuration-edit', function (hooks)
assert.dom(`${PKI_CONFIG_EDIT.errorBanner} ul`).hasClass('bullet');
// change 3 out of 4 requests to be successful to assert single error renders correctly
this.server.post(`/${this.backend}/config/acme`, () => new Response(200));
this.server.post(`/${this.backend}/config/cluster`, () => new Response(200));
this.server.post(`/${this.backend}/config/crl`, () => new Response(200));
this.acmeStub.resolves();
this.clusterStub.resolves();
this.crlStub.resolves();
await click(PKI_CONFIG_EDIT.saveButton);
assert.dom(PKI_CONFIG_EDIT.errorBanner).hasText('Error POST config/urls: something wrong with urls');

View File

@ -11,6 +11,7 @@ import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { PKI_CONFIGURE_CREATE } from 'vault/tests/helpers/pki/pki-selectors';
import { configCapabilities } from 'vault/tests/helpers/pki/pki-helpers';
module('Integration | Component | page/pki-configure-create', function (hooks) {
setupRenderingTest(hooks);
@ -18,26 +19,23 @@ module('Integration | Component | page/pki-configure-create', function (hooks) {
hooks.beforeEach(function () {
this.context = { owner: this.engine }; // this.engine set by setupEngine
this.store = this.owner.lookup('service:store');
this.cancelSpy = sinon.spy();
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: 'pki', route: 'overview', model: 'pki' },
{ label: 'Configure' },
];
this.config = this.store.createRecord('pki/action');
this.urls = this.store.createRecord('pki/config/urls');
this.capabilities = configCapabilities;
});
test('it renders', async function (assert) {
await render(
hbs`
<Page::PkiConfigureCreate
@breadcrumbs={{this.breadcrumbs}}
@config={{this.config}}
@urls={{this.urls}}
@onCancel={{this.cancelSpy}}
/>
<Page::PkiConfigureCreate
@breadcrumbs={{this.breadcrumbs}}
@capabilities={{this.capabilities}}
@onCancel={{this.cancelSpy}}
/>
`,
this.context
);

View File

@ -55,7 +55,7 @@ module('Integration | Component | page/pki-issuer-generate-intermediate', functi
);
assert.dom(GENERAL.title).hasText('Generate intermediate CSR');
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'foobar');
await fillIn(GENERAL.inputByAttr('common_name'), 'foobar');
await click('[data-test-submit]');
assert.dom(GENERAL.title).hasText('View Generated CSR');
});
@ -76,7 +76,7 @@ module('Integration | Component | page/pki-issuer-generate-intermediate', functi
assert.dom(GENERAL.title).hasText('Generate intermediate CSR');
// Fill in
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'foobar');
await fillIn(GENERAL.inputByAttr('common_name'), 'foobar');
await click('[data-test-submit]');
assert
.dom(GENERAL.title)

View File

@ -25,11 +25,7 @@ module('Integration | Component | page/pki-issuer-generate-root', function (hook
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.breadcrumbs = [{ label: 'something' }];
this.model = this.store.createRecord('pki/action', {
actionType: 'generate-csr',
});
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-component';
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
@ -46,7 +42,7 @@ module('Integration | Component | page/pki-issuer-generate-root', function (hook
test('it renders correct title before and after submit', async function (assert) {
assert.expect(3);
this.server.post(`/pki-component/root/generate/internal`, () => {
this.server.post('/pki-component/issuers/generate/root/internal', () => {
assert.true(true, 'Root endpoint called');
return {
request_id: uuidv4(),
@ -57,33 +53,30 @@ module('Integration | Component | page/pki-issuer-generate-root', function (hook
};
});
await render(
hbs`<Page::PkiIssuerGenerateRoot @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`,
{
owner: this.engine,
}
);
await render(hbs`<Page::PkiIssuerGenerateRoot @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
assert.dom(GENERAL.title).hasText('Generate root');
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'foobar');
await fillIn(GENERAL.inputByAttr('common_name'), 'foobar');
await click(GENERAL.submitButton);
assert.dom(GENERAL.title).hasText('View generated root');
});
test('it does not update title if API response is an error', async function (assert) {
assert.expect(2);
this.server.post(`/pki-component/root/generate/internal`, () => new Response(404, {}, { errors: [] }));
await render(
hbs`<Page::PkiIssuerGenerateRoot @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`,
{
owner: this.engine,
}
this.server.post(
`/pki-component/issuers/generate/root/internal`,
() => new Response(404, {}, { errors: [] })
);
await render(hbs`<Page::PkiIssuerGenerateRoot @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
assert.dom(GENERAL.title).hasText('Generate root');
// Fill in
await fillIn(GENERAL.inputByAttr('type'), 'internal');
await fillIn(GENERAL.inputByAttr('commonName'), 'foobar');
await fillIn(GENERAL.inputByAttr('common_name'), 'foobar');
await click(GENERAL.submitButton);
assert.dom(GENERAL.title).hasText('Generate root', 'title does not change if response is unsuccessful');
});

View File

@ -9,12 +9,11 @@ import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import sinon from 'sinon';
import { hbs } from 'ember-cli-htmlbars';
import camelizeKeys from 'vault/utils/camelize-object-keys';
import { parseCertificate } from 'vault/utils/parse-pki-cert';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CERTIFICATES } from 'vault/tests/helpers/pki/pki-helpers';
import { PKI_CONFIGURE_CREATE } from 'vault/tests/helpers/pki/pki-selectors';
import { PKI_CONFIGURE_CREATE, PKI_CONFIG_EDIT } from 'vault/tests/helpers/pki/pki-selectors';
const SELECTORS = {
nextSteps: '[data-test-rotate-next-steps]',
@ -41,25 +40,23 @@ module('Integration | Component | page/pki-issuer-rotate-root', function (hooks)
this.store = this.owner.lookup('service:store');
this.backend = 'test-pki';
this.owner.lookup('service:secret-mount-path').update(this.backend);
this.api = this.owner.lookup('service:api');
this.onCancel = sinon.spy();
this.onComplete = sinon.spy();
this.rotateStub = sinon.stub(this.api.secrets, 'pkiRotateRoot').resolves();
this.breadcrumbs = [{ label: 'rotate root' }];
this.oldRootData = {
certificate: loadedCert,
issuer_id: 'old-issuer-id',
issuer_name: 'old-issuer',
};
this.parsedRootCert = camelizeKeys(parseCertificate(loadedCert));
this.store.pushPayload('pki/issuer', { modelName: 'pki/issuer', data: this.oldRootData });
this.oldRoot = this.store.peekRecord('pki/issuer', 'old-issuer-id');
this.newRootModel = this.store.createRecord('pki/action', {
actionType: 'rotate-root',
type: 'internal',
...this.parsedRootCert, // copy old root settings over to new one
});
this.certData = parseCertificate(loadedCert);
this.returnedData = {
id: 'response-id',
certificate: loadedCert,
expiration: 1682735724,
issuer_id: 'some-issuer-id',
@ -69,6 +66,7 @@ module('Integration | Component | page/pki-issuer-rotate-root', function (hooks)
key_name: 'my-key',
serial_number: '3a:3c:17:..',
};
setRunOptions({
rules: {
// TODO: fix RadioCard component (replace with HDS)
@ -76,30 +74,43 @@ module('Integration | Component | page/pki-issuer-rotate-root', function (hooks)
'nested-interactive': { enabled: false },
},
});
this.renderComponent = () =>
render(
hbs`
<Page::PkiIssuerRotateRoot
@oldRoot={{this.oldRoot}}
@certData={{this.certData}}
@parsingErrors={{this.parsingErrors}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onComplete={{this.onComplete}}
/>
`,
{ owner: this.engine }
);
this.customizeAndSubmit = async () => {
await click(SELECTORS.customRadioSelect);
await fillIn(GENERAL.inputByAttr('type'), 'exported');
await fillIn(GENERAL.inputByAttr('common_name'), 'foo');
await click(GENERAL.submitButton);
};
});
test('it renders', async function (assert) {
assert.expect(17);
await render(
hbs`
<Page::PkiIssuerRotateRoot
@oldRoot={{this.oldRoot}}
@newRootModel={{this.newRootModel}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onComplete={{this.onComplete}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
assert.dom(GENERAL.title).hasText('Generate New Root');
assert.dom(SELECTORS.oldRadioSelect).isChecked('defaults to use-old-settings');
assert.dom(SELECTORS.rotateRootForm).exists('it renders old settings form');
assert
.dom(GENERAL.inputByAttr('commonName'))
.hasValue(this.parsedRootCert.commonName, 'common name prefilled with root cert cn');
.dom(GENERAL.inputByAttr('common_name'))
.hasValue(this.certData.common_name, 'common name prefilled with root cert cn');
assert.dom(SELECTORS.toggle).hasText('Old root settings', 'toggle renders correct text');
assert.dom(GENERAL.inputByAttr('issuerName')).exists('renders issuer name input');
assert.dom(GENERAL.inputByAttr('issuer_name')).exists('renders issuer name input');
assert.strictEqual(findAll('[data-test-row-label]').length, 0, 'it hides the old root info table rows');
await click(SELECTORS.toggle);
assert.strictEqual(findAll('[data-test-row-label]').length, 19, 'it shows the old root info table rows');
@ -113,8 +124,11 @@ module('Integration | Component | page/pki-issuer-rotate-root', function (hooks)
await click(SELECTORS.customRadioSelect);
assert.dom(SELECTORS.generateRootForm).exists('it renders generate root form');
assert
.dom(GENERAL.inputByAttr('permittedDnsDomains'))
.hasValue(this.parsedRootCert.permittedDnsDomains, 'form is prefilled with values from old root');
.dom(PKI_CONFIG_EDIT.stringListInput('permitted_dns_domains', 0))
.hasValue(
this.certData.permitted_dns_domains.split(',')[0],
'form is prefilled with values from old root'
);
await click(GENERAL.cancelButton);
assert.ok(this.onCancel.calledOnce, 'custom form calls @onCancel passed from parent');
await click(SELECTORS.oldRadioSelect);
@ -122,85 +136,55 @@ module('Integration | Component | page/pki-issuer-rotate-root', function (hooks)
assert.ok(this.onCancel.calledTwice, 'old root settings form calls @onCancel from parent');
// validations
await fillIn(GENERAL.inputByAttr('commonName'), '');
await fillIn(GENERAL.inputByAttr('issuerName'), 'default');
await fillIn(GENERAL.inputByAttr('common_name'), '');
await fillIn(GENERAL.inputByAttr('issuer_name'), 'default');
await click(GENERAL.submitButton);
assert.dom(SELECTORS.validationError).hasText('There are 2 errors with this form.');
assert.dom(GENERAL.validationErrorByAttr('commonName')).exists();
assert.dom(GENERAL.validationErrorByAttr('issuerName')).exists();
assert.dom(GENERAL.validationErrorByAttr('common_name')).exists();
assert.dom(GENERAL.validationErrorByAttr('issuer_name')).exists();
});
test('it sends request to rotate/internal on save when using old root settings', async function (assert) {
assert.expect(1);
this.server.post(`/${this.backend}/root/rotate/internal`, () => {
assert.ok('request made to correct default endpoint type=internal');
});
await render(
hbs`
<Page::PkiIssuerRotateRoot
@oldRoot={{this.oldRoot}}
@newRootModel={{this.newRootModel}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onComplete={{this.onComplete}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
await click(GENERAL.submitButton);
assert.true(
this.rotateStub.calledWith('internal', this.backend),
'rotateStub called with correct params'
);
});
function testEndpoint(test, type) {
test(`it sends request to rotate/${type} endpoint on save with custom root settings`, async function (assert) {
test.each(
`it sends request to rotate with correct type on save with custom root settings`,
['internal', 'exported', 'existing', 'kms'],
async function (assert, type) {
assert.expect(1);
this.server.post(`/${this.backend}/root/rotate/${type}`, () => {
assert.ok('request is made to correct endpoint');
});
await render(
hbs`
<Page::PkiIssuerRotateRoot
@oldRoot={{this.oldRoot}}
@newRootModel={{this.newRootModel}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onComplete={{this.onComplete}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
await click(SELECTORS.customRadioSelect);
await fillIn(GENERAL.inputByAttr('type'), type);
await click(GENERAL.submitButton);
});
}
testEndpoint(test, 'internal');
testEndpoint(test, 'exported');
testEndpoint(test, 'existing');
testEndpoint(test, 'kms');
assert.true(this.rotateStub.calledWith(type, this.backend), 'rotateStub called with correct params');
}
);
test('it renders details after save for exported key type', async function (assert) {
assert.expect(10);
const keyData = {
this.rotateStub.resolves({
...this.returnedData,
private_key: `-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAtc9yU`,
private_key_type: 'rsa',
};
this.store.pushPayload('pki/action', {
modelName: 'pki/action',
data: { ...this.returnedData, ...keyData },
});
this.newRootModel = this.store.peekRecord('pki/action', 'response-id');
await render(
hbs`
<Page::PkiIssuerRotateRoot
@oldRoot={{this.oldRoot}}
@newRootModel={{this.newRootModel}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onComplete={{this.onComplete}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
await this.customizeAndSubmit();
assert.dom(GENERAL.title).hasText('View Issuer Certificate');
assert
.dom(SELECTORS.nextSteps)
@ -216,28 +200,17 @@ module('Integration | Component | page/pki-issuer-rotate-root', function (hooks)
assert.dom(GENERAL.infoRowValue('Key ID')).hasText(this.returnedData.key_id);
await click(PKI_CONFIGURE_CREATE.doneButton);
assert.ok(this.onComplete.calledOnce, 'clicking done fires @onComplete from parent');
assert.true(this.onComplete.calledOnce, 'clicking done fires @onComplete from parent');
});
test('it renders details after save for internal key type', async function (assert) {
assert.expect(13);
this.store.pushPayload('pki/action', {
modelName: 'pki/action',
data: this.returnedData,
});
this.newRootModel = this.store.peekRecord('pki/action', 'response-id');
await render(
hbs`
<Page::PkiIssuerRotateRoot
@oldRoot={{this.oldRoot}}
@newRootModel={{this.newRootModel}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onComplete={{this.onComplete}}
/>
`,
{ owner: this.engine }
);
this.rotateStub.resolves(this.returnedData);
await this.renderComponent();
await this.customizeAndSubmit();
assert.dom(GENERAL.title).hasText('View Issuer Certificate');
assert.dom(SELECTORS.toolbarCrossSign).exists();
assert.dom(SELECTORS.toolbarSignInt).exists();

View File

@ -8,30 +8,27 @@ 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';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
import sinon from 'sinon';
module('Integration | Component | pki-generate-csr', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(async function () {
this.owner.lookup('service:secretMountPath').update('pki-test');
this.store = this.owner.lookup('service:store');
this.onComplete = () => {};
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'],
},
}));
this.capabilitiesForStub = sinon
.stub(this.owner.lookup('service:capabilities'), 'for')
.resolves({ canCreate: false });
const api = this.owner.lookup('service:api');
this.issuersGenerateStub = sinon.stub(api.secrets, 'pkiIssuersGenerateIntermediate');
this.generateStub = sinon.stub(api.secrets, 'pkiGenerateIntermediate');
setRunOptions({
rules: {
// something strange happening here
@ -39,6 +36,32 @@ module('Integration | Component | pki-generate-csr', function (hooks) {
},
});
this.clipboardSpy = sinon.stub(navigator.clipboard, 'writeText').resolves();
this.form = new PkiConfigGenerateForm('PkiGenerateIntermediateRequest', {}, { isNew: true });
this.onCancel = sinon.stub();
this.onComplete = sinon.stub();
this.onSave = sinon.stub();
this.renderComponent = () =>
render(
hbs`
<PkiGenerateCsr
@form={{this.form}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
@onComplete={{this.onComplete}}
/>
`,
{
owner: this.engine,
}
);
this.fillInAndSubmit = async (type) => {
await fillIn(GENERAL.inputByAttr('type'), type);
await fillIn(GENERAL.inputByAttr('common_name'), 'foo');
await click(GENERAL.submitButton);
};
});
hooks.afterEach(function () {
@ -46,28 +69,17 @@ module('Integration | Component | pki-generate-csr', function (hooks) {
});
test('it should render fields and save', async function (assert) {
assert.expect(11);
assert.expect(12);
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');
return {
request_id: '123',
data: {},
};
});
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onComplete={{this.onComplete}} />`, {
owner: this.engine,
});
await this.renderComponent();
const fields = [
'type',
'commonName',
'excludeCnFromSans',
'common_name',
'exclude_cn_from_sans',
'format',
'subjectSerialNumber',
'addBasicConstraints',
'subject_serial_number',
'add_basic_constraints',
];
fields.forEach((key) => {
assert.dom(`[data-test-input="${key}"]`).exists(`${key} form field renders`);
@ -79,51 +91,76 @@ module('Integration | Component | pki-generate-csr', function (hooks) {
.dom(GENERAL.button('Additional subject fields'))
.exists('Additional subject fields toggle renders');
await fillIn(GENERAL.inputByAttr('type'), 'exported');
await fillIn(GENERAL.inputByAttr('commonName'), 'foo');
await click(GENERAL.submitButton);
const savedRecord = this.store.peekAll('pki/action')[0];
assert.false(savedRecord.isNew, 'record is saved');
await this.fillInAndSubmit('exported');
assert.true(this.onSave.calledOnce, 'onSave action fires');
assert.true(
this.generateStub.calledWith('exported', 'pki-test'),
'generateStub called with correct params'
);
assert.strictEqual(
this.generateStub.lastCall.args[2].common_name,
'foo',
'common_name is sent in payload'
);
});
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}} @onComplete={{this.onComplete}} />`,
{
owner: this.engine,
}
);
await this.renderComponent();
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('type'))
.hasText('Type is required.', 'Type validation error renders');
assert
.dom(GENERAL.validationErrorByAttr('commonName'))
.dom(GENERAL.validationErrorByAttr('common_name'))
.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]');
assert.ok(this.onCancel.calledOnce, 'onCancel action fires');
});
test('it should use correct endpoint based on issuer permissions', async function (assert) {
assert.expect(4);
this.capabilitiesForStub.resolves({ canCreate: true });
this.issuersGenerateStub.rejects(getErrorResponse());
await this.renderComponent();
await this.fillInAndSubmit('exported');
assert.true(
this.capabilitiesForStub.calledWith('pkiIssuersGenerateIntermediate', {
backend: 'pki-test',
type: 'exported',
})
);
assert.true(this.issuersGenerateStub.calledOnce, 'issuers generate endpoint used when permitted');
assert.dom(GENERAL.messageError).exists('error message shown');
this.capabilitiesForStub.resolves({ canCreate: false });
this.generateStub.resolves({});
await click(GENERAL.submitButton);
assert.true(this.generateStub.calledOnce, 'generate endpoint used when there is no issuer permission');
});
test('it should show generated CSR for type=exported', async function (assert) {
assert.expect(6);
this.model.id = '1235-someId';
this.model.csr = '-----BEGIN CERTIFICATE REQUEST-----...-----END CERTIFICATE REQUEST-----';
this.model.keyId = '9179de78-1275-a1cf-ebb0-a4eb2e376636';
this.model.privateKey = '-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----';
this.model.privateKeyType = 'rsa';
this.onComplete = () => assert.ok(true, 'onComplete action fires');
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onComplete={{this.onComplete}} />`, {
owner: this.engine,
});
const data = {
csr: '-----BEGIN CERTIFICATE REQUEST-----...-----END CERTIFICATE REQUEST-----',
key_id: '9179de78-1275-a1cf-ebb0-a4eb2e376636',
private_key: '-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----',
private_key_type: 'rsa',
};
this.generateStub.resolves(data);
await this.renderComponent();
await this.fillInAndSubmit('exported');
assert
.dom('[data-test-next-steps-csr]')
.hasText(
@ -132,34 +169,34 @@ module('Integration | Component | pki-generate-csr', function (hooks) {
);
await click(`${GENERAL.infoRowValue('CSR')} ${GENERAL.copyButton}`);
assert.strictEqual(this.clipboardSpy.firstCall.args[0], this.model.csr, 'copy value is csr');
assert.strictEqual(this.clipboardSpy.firstCall.args[0], data.csr, 'copy value is csr');
await click(`${GENERAL.infoRowValue('Key ID')} ${GENERAL.copyButton}`);
assert.strictEqual(this.clipboardSpy.secondCall.args[0], this.model.keyId, 'copy value is key_id');
assert.strictEqual(this.clipboardSpy.secondCall.args[0], data.key_id, 'copy value is key_id');
await click(`${GENERAL.infoRowValue('Private key')} ${GENERAL.copyButton}`);
assert.strictEqual(
this.clipboardSpy.thirdCall.args[0],
this.model.privateKey,
'copy value is private_key'
);
assert.strictEqual(this.clipboardSpy.thirdCall.args[0], data.private_key, 'copy value is private_key');
assert
.dom(GENERAL.infoRowValue('Private key type'))
.hasText(this.model.privateKeyType, 'renders private_key_type');
.hasText(data.private_key_type, 'renders private_key_type');
await click('[data-test-done]');
assert.ok(this.onComplete.calledOnce, 'onComplete action fires');
});
test('it should show generated CSR for type=internal', async function (assert) {
assert.expect(5);
this.model.id = '1235-someId';
this.model.csr = '-----BEGIN CERTIFICATE REQUEST-----...-----END CERTIFICATE REQUEST-----';
this.model.keyId = '9179de78-1275-a1cf-ebb0-a4eb2e376636';
this.onComplete = () => {};
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onComplete={{this.onComplete}} />`, {
owner: this.engine,
});
const data = {
csr: '-----BEGIN CERTIFICATE REQUEST-----...-----END CERTIFICATE REQUEST-----',
key_id: '9179de78-1275-a1cf-ebb0-a4eb2e376636',
};
this.generateStub.resolves(data);
await this.renderComponent();
await this.fillInAndSubmit('internal');
assert
.dom('[data-test-next-steps-csr]')
.hasText(
@ -167,10 +204,10 @@ module('Integration | Component | pki-generate-csr', function (hooks) {
'renders Next steps alert banner'
);
await click(`${GENERAL.infoRowValue('CSR')} ${GENERAL.copyButton}`);
assert.strictEqual(this.clipboardSpy.firstCall.args[0], this.model.csr, 'copy value is csr');
assert.strictEqual(this.clipboardSpy.firstCall.args[0], data.csr, 'copy value is csr');
await click(`${GENERAL.infoRowValue('Key ID')} ${GENERAL.copyButton}`);
assert.strictEqual(this.clipboardSpy.secondCall.args[0], this.model.keyId, 'copy value is key_id');
assert.strictEqual(this.clipboardSpy.secondCall.args[0], data.key_id, 'copy value is key_id');
assert.dom(GENERAL.infoRowValue('Private key')).hasText('internal', 'does not render private key');
assert

View File

@ -9,10 +9,12 @@ 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';
import Sinon from 'sinon';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { PKI_GENERATE_ROOT } from 'vault/tests/helpers/pki/pki-selectors';
import { CERTIFICATES } from 'vault/tests/helpers/pki/pki-helpers';
import { parseCertificate } from 'vault/utils/parse-pki-cert';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
import sinon from 'sinon';
module('Integration | Component | pki-generate-root', function (hooks) {
setupRenderingTest(hooks);
@ -20,23 +22,51 @@ module('Integration | Component | pki-generate-root', function (hooks) {
setupEngine(hooks, 'pki');
hooks.beforeEach(async function () {
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.urls = this.store.createRecord('pki/config/urls', { id: 'pki-test' });
this.model = this.store.createRecord('pki/action');
this.onSave = Sinon.spy();
this.onCancel = Sinon.spy();
this.owner.lookup('service:secretMountPath').update('pki-test');
this.capabilitiesForStub = sinon
.stub(this.owner.lookup('service:capabilities'), 'for')
.resolves({ canCreate: false });
const api = this.owner.lookup('service:api');
this.issuersGenerateStub = sinon.stub(api.secrets, 'pkiIssuersGenerateRoot');
this.generateStub = sinon.stub(api.secrets, 'pkiGenerateRoot');
this.rotateStub = sinon.stub(api.secrets, 'pkiRotateRoot');
this.withUrls = true;
this.canSetUrls = true;
this.onCancel = sinon.stub();
this.onComplete = sinon.stub();
this.onSave = sinon.stub();
this.renderComponent = () =>
render(
hbs`
<PkiGenerateRoot
@withUrls={{this.withUrls}}
@canSetUrls={{this.canSetUrls}}
@rotateCertData={{this.rotateCertData}}
@capabilities={{this.capabilities}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
@onComplete={{this.onComplete}}
/>
`,
{
owner: this.engine,
}
);
this.fillInAndSubmit = async (type) => {
await fillIn(GENERAL.inputByAttr('type'), type);
await fillIn(GENERAL.inputByAttr('common_name'), 'foo');
await click(GENERAL.submitButton);
};
});
test('it renders with correct sections', async function (assert) {
await render(
hbs`<PkiGenerateRoot @model={{this.model}} @onSave={{this.onSave}} @onCancel={{this.onCancel}} />`,
{
owner: this.engine,
}
);
this.withUrls = false;
await this.renderComponent();
assert.dom('h2').exists({ count: 1 }, 'One H2 title without @urls');
assert.dom(PKI_GENERATE_ROOT.mainSectionTitle).hasText('Root parameters');
@ -48,12 +78,7 @@ module('Integration | Component | pki-generate-root', function (hooks) {
});
test('it shows the appropriate fields under the toggles', async function (assert) {
await render(
hbs`<PkiGenerateRoot @model={{this.model}} @onSave={{this.onSave}} @onCancel={{this.onCancel}} />`,
{
owner: this.engine,
}
);
await this.renderComponent();
await click(GENERAL.button('Additional subject fields'));
assert
@ -86,19 +111,14 @@ module('Integration | Component | pki-generate-root', function (hooks) {
});
test('it renders the correct form fields in key params', async function (assert) {
this.set('type', '');
await render(
hbs`<PkiGenerateRoot @model={{this.model}} @onSave={{this.onSave}} @onCancel={{this.onCancel}} />`,
{
owner: this.engine,
}
);
await this.renderComponent();
await click(GENERAL.button('Key parameters'));
assert
.dom(PKI_GENERATE_ROOT.groupFields('Key parameters'))
.exists({ count: 0 }, '0 form fields under keyParams toggle');
this.set('type', 'exported');
this.type = 'exported';
await fillIn(GENERAL.inputByAttr('type'), this.type);
assert
.dom(PKI_GENERATE_ROOT.toggleGroupDescription)
@ -106,15 +126,14 @@ module('Integration | Component | pki-generate-root', function (hooks) {
'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.',
`has correct description for type=${this.type}`
);
assert.strictEqual(this.model.type, this.type);
assert
.dom(PKI_GENERATE_ROOT.groupFields('Key parameters'))
.exists({ count: 4 }, '4 form fields under keyParams toggle');
assert.dom(GENERAL.fieldByAttr('keyName')).exists(`Key name field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('keyType')).exists(`Key type field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('keyBits')).exists(`Key bits field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_name')).exists(`Key name field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_type')).exists(`Key type field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_bits')).exists(`Key bits field shown when type=${this.type}`);
this.set('type', 'internal');
this.type = 'internal';
await fillIn(GENERAL.inputByAttr('type'), this.type);
assert
.dom(PKI_GENERATE_ROOT.toggleGroupDescription)
@ -122,13 +141,12 @@ module('Integration | Component | pki-generate-root', function (hooks) {
'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.',
`has correct description for type=${this.type}`
);
assert.strictEqual(this.model.type, this.type);
assert
.dom(PKI_GENERATE_ROOT.groupFields('Key parameters'))
.exists({ count: 3 }, '3 form fields under keyParams toggle');
assert.dom(GENERAL.fieldByAttr('keyName')).exists(`Key name field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('keyType')).exists(`Key type field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('keyBits')).exists(`Key bits field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_name')).exists(`Key name field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_type')).exists(`Key type field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_bits')).exists(`Key bits field shown when type=${this.type}`);
this.set('type', 'existing');
await fillIn(GENERAL.inputByAttr('type'), this.type);
@ -138,11 +156,10 @@ module('Integration | Component | pki-generate-root', function (hooks) {
'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.',
`has correct description for type=${this.type}`
);
assert.strictEqual(this.model.type, this.type);
assert
.dom(PKI_GENERATE_ROOT.groupFields('Key parameters'))
.exists({ count: 1 }, '1 form field under keyParams toggle');
assert.dom(GENERAL.fieldByAttr('keyRef')).exists(`Key reference field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_ref')).exists(`Key reference field shown when type=${this.type}`);
this.set('type', 'kms');
await fillIn(GENERAL.inputByAttr('type'), this.type);
@ -152,53 +169,69 @@ module('Integration | Component | pki-generate-root', function (hooks) {
'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. Learn more about managed keys.',
`has correct description for type=${this.type}`
);
assert.strictEqual(this.model.type, this.type);
assert
.dom(PKI_GENERATE_ROOT.groupFields('Key parameters'))
.exists({ count: 3 }, '3 form fields under keyParams toggle');
assert.dom(GENERAL.fieldByAttr('keyName')).exists(`Key name field shown when type=${this.type}`);
assert.dom(GENERAL.fieldByAttr('key_name')).exists(`Key name field shown when type=${this.type}`);
assert
.dom(GENERAL.fieldByAttr('managedKeyName'))
.dom(GENERAL.fieldByAttr('managed_key_name'))
.exists(`Managed key name field shown when type=${this.type}`);
assert
.dom(GENERAL.fieldByAttr('managedKeyId'))
.dom(GENERAL.fieldByAttr('managed_key_id'))
.exists(`Managed key id field shown when type=${this.type}`);
});
test('it shows errors before submit if form is invalid', async function (assert) {
const saveSpy = Sinon.spy();
this.set('onSave', saveSpy);
await render(
hbs`<PkiGenerateRoot @model={{this.model}} @onSave={{this.onSave}} @onCancel={{this.onCancel}} />`,
{
owner: this.engine,
}
);
await this.renderComponent();
await click(GENERAL.submitButton);
assert.dom(PKI_GENERATE_ROOT.formInvalidError).exists('Shows overall error form');
assert.ok(saveSpy.notCalled);
assert.true(this.onSave.notCalled);
});
test('it should use correct endpoint based on issuer permissions', async function (assert) {
assert.expect(6);
this.rotateCertData = parseCertificate(CERTIFICATES.loadedCert);
this.rotateStub.rejects(getErrorResponse());
await this.renderComponent();
await this.fillInAndSubmit('exported');
assert.true(this.rotateStub.calledOnce, 'rotate endpoint used when rotating');
assert.dom(GENERAL.messageError).exists('error message shown');
this.rotateCertData = undefined;
this.capabilitiesForStub.resolves({ canCreate: true });
this.issuersGenerateStub.rejects(getErrorResponse());
await this.renderComponent();
await this.fillInAndSubmit('exported');
assert.true(
this.capabilitiesForStub.calledWith('pkiIssuersGenerateRoot', {
backend: 'pki-test',
type: 'exported',
})
);
assert.true(this.issuersGenerateStub.calledOnce, 'issuers generate endpoint used when permitted');
assert.dom(GENERAL.messageError).exists('error message shown');
this.capabilitiesForStub.resolves({ canCreate: false });
this.generateStub.resolves({});
await click(GENERAL.submitButton);
assert.true(this.generateStub.calledOnce, 'generate endpoint used when there is no issuer permission');
});
module('URLs section', function () {
test('it does not render when no urls passed', async function (assert) {
await render(
hbs`<PkiGenerateRoot @model={{this.model}} @onSave={{this.onSave}} @onCancel={{this.onCancel}} />`,
{
owner: this.engine,
}
);
this.withUrls = false;
await this.renderComponent();
assert.dom(PKI_GENERATE_ROOT.urlsSection).doesNotExist();
});
test('it renders when urls model passed', async function (assert) {
await render(
hbs`<PkiGenerateRoot @model={{this.model}} @urls={{this.urls}} @onSave={{this.onSave}} @onCancel={{this.onCancel}} />`,
{
owner: this.engine,
}
);
await this.renderComponent();
assert.dom(PKI_GENERATE_ROOT.urlsSection).exists();
assert.dom('h2').exists({ count: 2 }, 'two H2 titles are visible on page load');
assert.dom(PKI_GENERATE_ROOT.urlSectionTitle).hasText('Issuer URLs');

View File

@ -9,19 +9,24 @@ import { click, render, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate';
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' });
this.form = new PkiConfigGenerateForm('PkiGenerateRootRequest', {}, { isNew: true });
this.actionType = 'generate-root';
this.renderComponent = () =>
render(
hbs`<PkiGenerateToggleGroups @form={{this.form}} @actionType={{this.actionType}} @groups={{this.groups}} @modelValidations={{this.modelValidations}} />`,
{ owner: this.engine }
);
});
test('it should render key parameters', async function (assert) {
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
await this.renderComponent();
assert.dom(GENERAL.button('Key parameters')).hasText('Key parameters', 'Key parameters group renders');
@ -34,13 +39,13 @@ module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
'Placeholder renders for key params when type is not selected'
);
const fields = {
exported: ['keyName', 'keyType', 'keyBits', 'privateKeyFormat'],
internal: ['keyName', 'keyType', 'keyBits'],
existing: ['keyRef'],
kms: ['keyName', 'managedKeyName', 'managedKeyId'],
exported: ['key_name', 'key_type', 'key_bits', 'private_key_format'],
internal: ['key_name', 'key_type', 'key_bits'],
existing: ['key_ref'],
kms: ['key_name', 'managed_key_name', 'managed_key_id'],
};
for (const type in fields) {
this.model.type = type;
this.form.data.type = type;
await settled();
assert
.dom('[data-test-field]')
@ -52,7 +57,7 @@ module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
});
test('it should render SAN options', async function (assert) {
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
await this.renderComponent();
assert
.dom(GENERAL.button('Subject Alternative Name (SAN) Options'))
@ -60,29 +65,37 @@ module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
await click(GENERAL.button('Subject Alternative Name (SAN) Options'));
const fields = ['excludeCnFromSans', 'subjectSerialNumber', 'altNames', 'ipSans', 'uriSans', 'otherSans'];
const fields = [
'exclude_cn_from_sans',
'subject_serial_number',
'alt_names',
'ip_sans',
'uri_sans',
'other_sans',
];
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();
this.actionType = 'generate-csr';
await this.renderComponent();
await click(GENERAL.button('Subject Alternative Name (SAN) Options'));
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');
.dom('[data-test-input="exclude_cn_from_sans"]')
.doesNotExist('exclude_cn_from_sans field hidden for generate-csr actionType');
assert
.dom('[data-test-input="serialNumber"]')
.doesNotExist('serialNumber field hidden for generate-csr actionType');
.dom('[data-test-input="serial_number"]')
.doesNotExist('serial_number 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 });
await this.renderComponent();
assert
.dom(GENERAL.button('Additional subject fields'))
@ -90,7 +103,7 @@ module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
await click(GENERAL.button('Additional subject fields'));
const fields = ['ou', 'organization', 'country', 'locality', 'province', 'streetAddress', 'postalCode'];
const fields = ['ou', 'organization', 'country', 'locality', 'province', 'street_address', 'postal_code'];
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`);
@ -100,14 +113,12 @@ module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
test('it should render groups according to the passed @groups', async function (assert) {
assert.expect(11);
const fieldsA = ['ou', 'organization'];
const fieldsZ = ['country', 'locality', 'province', 'streetAddress', 'postalCode'];
this.set('groups', {
const fieldsZ = ['country', 'locality', 'province', 'street_address', 'postal_code'];
this.groups = {
'Group A': fieldsA,
'Group Z': fieldsZ,
});
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} @groups={{this.groups}} />`, {
owner: this.engine,
});
};
await this.renderComponent();
assert.dom(GENERAL.button('Group A')).hasText('Group A', 'First group renders');
assert.dom(GENERAL.button('Group Z')).hasText('Group Z', 'Second group renders');

View File

@ -8,163 +8,98 @@ import { setupRenderingTest } from 'ember-qunit';
import { render, click, fillIn } 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';
import { CERTIFICATES } from 'vault/tests/helpers/pki/pki-helpers';
import { PKI_CONFIGURE_CREATE } from 'vault/tests/helpers/pki/pki-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
const { issuerPemBundle } = CERTIFICATES;
module('Integration | Component | PkiImportPemBundle', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
setupEngine(hooks, 'pki'); // https://github.com/ember-engines/ember-engines/pull/653
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.model = this.store.createRecord('pki/action');
this.backend = 'pki-test';
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = this.backend;
this.owner.lookup('service:secret-mount-path').update(this.backend);
this.pemBundle = issuerPemBundle;
this.onComplete = () => {};
this.onComplete = sinon.stub();
this.onCancel = sinon.stub();
this.onSave = sinon.stub();
const response = {
request_id: 'test',
data: {
mapping: { 'issuer-id': 'key-id' },
},
};
const api = this.owner.lookup('service:api');
this.issuersImportStub = sinon.stub(api.secrets, 'pkiIssuersImportBundle').resolves(response);
this.importStub = sinon.stub(api.secrets, 'pkiConfigureCa').resolves(response);
this.renderComponent = () =>
render(
hbs`
<PkiImportPemBundle
@useIssuer={{this.useIssuer}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
@onComplete={{this.onComplete}}
/>`,
{ owner: this.engine }
);
});
test('it renders import and updates model', async function (assert) {
assert.expect(3);
await render(
hbs`
<PkiImportPemBundle
@model={{this.model}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
@onComplete={{this.onComplete}}
/>
`,
{ owner: this.engine }
);
test('it renders import form', async function (assert) {
assert.expect(2);
await this.renderComponent();
assert.dom('[data-test-pki-import-pem-bundle-form]').exists('renders form');
assert.dom('[data-test-component="text-file"]').exists('renders text file input');
await click(GENERAL.textToggle);
await fillIn(GENERAL.maskedInput, this.pemBundle);
assert.strictEqual(this.model.pemBundle, this.pemBundle);
});
test('it sends correct payload to import endpoint', async function (assert) {
assert.expect(4);
this.server.post(`/${this.backend}/issuers/import/bundle`, (schema, req) => {
assert.ok(true, 'Request made to the correct endpoint to import issuer');
const request = JSON.parse(req.requestBody);
assert.propEqual(
request,
{
pem_bundle: `${this.pemBundle}`,
},
'sends params in correct type'
);
return {
request_id: 'test',
data: {
mapping: { 'issuer-id': 'key-id' },
},
};
});
assert.expect(2);
this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
await render(
hbs`
<PkiImportPemBundle
@model={{this.model}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
@onComplete={{this.onComplete}}
@adapterOptions={{hash actionType="import" useIssuer=true}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
await click(GENERAL.textToggle);
await fillIn(GENERAL.maskedInput, this.pemBundle);
assert.strictEqual(this.model.pemBundle, this.pemBundle, 'PEM bundle updated on model');
await click(PKI_CONFIGURE_CREATE.importSubmit);
assert.true(this.importStub.calledWith(this.backend, { pem_bundle: this.pemBundle }));
assert.true(this.onSave.calledOnce, 'onSave callback fires on save success');
});
test('it hits correct endpoint when userIssuer=false', async function (assert) {
assert.expect(4);
this.server.post(`${this.backend}/config/ca`, (schema, req) => {
assert.ok(true, 'Request made to the correct endpoint to import issuer');
const request = JSON.parse(req.requestBody);
assert.propEqual(
request,
{
pem_bundle: `${this.pemBundle}`,
},
'sends params in correct type'
);
return {
request_id: 'test',
data: {
mapping: {},
},
};
});
test('it hits correct endpoint when userIssuer=true', async function (assert) {
assert.expect(2);
this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
await render(
hbs`
<PkiImportPemBundle
@model={{this.model}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
@onComplete={{this.onComplete}}
@adapterOptions={{hash actionType="import" useIssuer=false}}
/>
`,
{ owner: this.engine }
);
this.useIssuer = true;
await this.renderComponent();
await click(GENERAL.textToggle);
await fillIn(GENERAL.maskedInput, this.pemBundle);
assert.strictEqual(this.model.pemBundle, this.pemBundle);
await click(PKI_CONFIGURE_CREATE.importSubmit);
assert.true(this.issuersImportStub.calledWith(this.backend, { pem_bundle: this.pemBundle }));
assert.true(this.onSave.calledOnce, 'onSave callback fires on save success');
});
test('it shows the bundle mapping on success', async function (assert) {
assert.expect(9);
this.server.post(`/${this.backend}/issuers/import/bundle`, () => {
return {
request_id: 'test',
data: {
imported_issuers: ['issuer-id', 'another-issuer'],
imported_keys: ['key-id', 'another-key'],
mapping: { 'issuer-id': 'key-id', 'another-issuer': null },
},
};
assert.expect(10);
this.importStub.resolves({
imported_issuers: ['issuer-id', 'another-issuer'],
imported_keys: ['key-id', 'another-key'],
mapping: { 'issuer-id': 'key-id', 'another-issuer': null },
});
this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
this.onComplete = () => assert.ok(true, 'onComplete callback fires on done button click');
await render(
hbs`
<PkiImportPemBundle
@model={{this.model}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
@onComplete={{this.onComplete}}
@adapterOptions={{hash actionType="import" useIssuer=true}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
await click(GENERAL.textToggle);
await fillIn(GENERAL.maskedInput, this.pemBundle);
await click(PKI_CONFIGURE_CREATE.importSubmit);
assert.true(this.onSave.calledOnce, 'onSave callback fires on save success');
assert.true(this.importStub.calledOnce, 'import endpoint called once');
assert
.dom('[data-test-import-pair]')
.exists({ count: 3 }, 'Shows correct number of rows for imported items');
@ -178,24 +113,14 @@ module('Integration | Component | PkiImportPemBundle', function (hooks) {
assert.dom('[data-test-import-pair="_another-key"] [data-test-imported-issuer]').hasText('None');
assert.dom('[data-test-import-pair="_another-key"] [data-test-imported-key]').hasText('another-key');
await click('[data-test-done]');
assert.true(this.onComplete.calledOnce, 'onComplete callback fires on done button click');
});
test('it should unload record on cancel', async function (assert) {
assert.expect(2);
this.onCancel = () => assert.ok(true, 'onCancel callback fires');
await render(
hbs`
<PkiImportPemBundle
@model={{this.model}}
@onCancel={{this.onCancel}}
@onComplete={{this.onComplete}}
@onSave={{this.onSave}}
/>
`,
{ owner: this.engine }
);
test('it should fire callback on cancel', async function (assert) {
assert.expect(1);
await this.renderComponent();
await click('[data-test-pki-ca-cert-cancel]');
assert.true(this.model.isDestroyed, 'new model is unloaded on cancel');
assert.true(this.onCancel.calledOnce, 'onCancel callback fires on cancel click');
});
});

View File

@ -1,6 +1,8 @@
{
"extends": "@tsconfig/ember/tsconfig.json",
"compilerOptions": {
"module": "esnext",
"resolveJsonModule": true,
"experimentalDecorators": true,
"allowJs": true,
"strict": true,

View File

@ -33,17 +33,15 @@ export type Validator =
| 'containsWhiteSpace'
| 'endsInSlash'
| 'hasWhitespace'
| 'isNonString';
| 'isNonString'
| 'isNot';
export type ValidatorOption =
| {
nullable?: boolean;
}
| {
nullable?: boolean;
min?: number;
max?: number;
};
export type ValidatorOption = {
nullable?: boolean;
min?: number;
max?: number;
value?: string | number | boolean;
};
export type Validation =
| {