UI: PKI generate cert from role (#18300)

This commit is contained in:
Chelsea Shaw 2022-12-13 12:37:10 -06:00 committed by GitHub
parent c9531431a4
commit 790156a07b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 739 additions and 51 deletions

View file

@ -0,0 +1,21 @@
import { encodePath } from 'vault/utils/path-encoding-helpers';
import ApplicationAdapter from '../../application';
export default class PkiCertificateGenerateAdapter extends ApplicationAdapter {
namespace = 'v1';
deleteRecord(store, type, snapshot) {
const { backend, serialNumber, certificate } = snapshot.record;
// Revoke certificate requires either serial_number or certificate
const data = serialNumber ? { serial_number: serialNumber } : { certificate };
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/revoke`, 'POST', { data });
}
urlForCreateRecord(modelName, snapshot) {
const { name, backend } = snapshot.record;
if (!name || !backend) {
throw new Error('URL for create record is missing required attributes');
}
return `${this.buildURL()}/${encodePath(backend)}/issue/${encodePath(name)}`;
}
}

View file

@ -59,4 +59,29 @@ export default class PkiRoleAdapter extends ApplicationAdapter {
const { id, record } = snapshot;
return this.ajax(this._urlForRole(record.backend, id), 'DELETE');
}
generateCertificate(backend, roleName, data) {
const url = `${this.buildURL()}/${encodePath(backend)}/issue/${roleName}`;
const options = {
data,
};
return this.ajax(url, 'POST', options).then((resp) => {
return resp.data;
});
}
signCertificate(backend, roleName, data) {
const url = `${this.buildURL()}/${encodePath(backend)}/sign/${roleName}`;
const options = {
data,
};
return this.ajax(url, 'POST', options);
}
revokeCertificate(backend, data) {
const url = `${this.buildURL()}/${encodePath(backend)}/revoke`;
return this.ajax(url, 'POST', {
data,
});
}
}

View file

@ -52,6 +52,7 @@ export default class App extends Application {
dependencies: {
services: [
'auth',
'download',
'flash-messages',
'namespace',
'path-help',

View file

@ -37,7 +37,7 @@ import { inject as service } from '@ember/service';
export default class Attribution extends Component {
@tracked showCSVDownloadModal = false;
@service downloadCsv;
@service download;
get hasCsvData() {
return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
@ -185,7 +185,7 @@ export default class Attribution extends Component {
@action
exportChartData(filename) {
const contents = this.generateCsvData();
this.downloadCsv.download(filename, contents);
this.download.csv(filename, contents);
this.showCSVDownloadModal = false;
}
}

View file

@ -3,15 +3,10 @@ import * as asn1js from 'asn1js';
import { fromBase64, stringToArrayBuffer } from 'pvutils';
import { Certificate } from 'pkijs';
export function parsePkiCert([model]) {
// model has to be the responseJSON from PKI serializer
// return if no certificate or if the "certificate" is actually a CRL
if (!model.certificate || model.certificate.includes('BEGIN X509 CRL')) {
return;
}
export function parseCertificate(certificateContent) {
let cert;
try {
const cert_base64 = model.certificate.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, '');
const cert_base64 = certificateContent.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, '');
const cert_der = fromBase64(cert_base64);
const cert_asn1 = asn1js.fromBER(stringToArrayBuffer(cert_der));
cert = new Certificate({ schema: cert_asn1.result });
@ -21,7 +16,6 @@ export function parsePkiCert([model]) {
can_parse: false,
};
}
// We wish to get the CN element out of this certificate's subject. A
// subject is a list of RDNs, where each RDN is a (type, value) tuple
// and where a type is an OID. The OID for CN can be found here:
@ -54,7 +48,18 @@ export function parsePkiCert([model]) {
common_name: commonName,
expiry_date: expiryDate,
issue_date: issueDate,
not_valid_after: expiryDate.valueOf(),
not_valid_before: issueDate.valueOf(),
};
}
export function parsePkiCert([model]) {
// model has to be the responseJSON from PKI serializer
// return if no certificate or if the "certificate" is actually a CRL
if (!model.certificate || model.certificate.includes('BEGIN X509 CRL')) {
return;
}
return parseCertificate(model.certificate);
}
export default helper(parsePkiCert);

View file

@ -0,0 +1,49 @@
import Model, { attr } from '@ember-data/model';
import { assert } from '@ember/debug';
import { service } from '@ember/service';
import { withFormFields } from 'vault/decorators/model-form-fields';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
/**
* There are many ways to generate a cert, but we want to display them in a consistent way.
* This base certificate model will set the attributes we want to display, and other
* models under pki/certificate will extend this model and have their own required
* attributes and adapter methods.
*/
const certDisplayFields = ['certificate', 'commonName', 'serialNumber', 'notValidAfter', 'notValidBefore'];
@withFormFields(certDisplayFields)
export default class PkiCertificateBaseModel extends Model {
@service secretMountPath;
get useOpenAPI() {
return true;
}
get backend() {
return this.secretMountPath.currentPath;
}
getHelpUrl() {
assert('You must provide a helpUrl for OpenAPI', true);
}
// Required input for all certificates
@attr('string') commonName;
// Attrs that come back from API POST request
@attr() caChain;
@attr('string') certificate;
@attr('number') expiration;
@attr('string') issuingCa;
@attr('string') privateKey;
@attr('string') privateKeyType;
@attr('string') serialNumber;
// Parsed from cert in serializer
@attr('date') notValidAfter;
@attr('date') notValidBefore;
@lazyCapabilities(apiPath`${'backend'}/revoke`, 'backend') revokePath;
get canRevoke() {
return this.revokePath.get('isLoading') || this.revokePath.get('canCreate') !== false;
}
}

View file

@ -0,0 +1,29 @@
import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
import PkiCertificateBaseModel from './base';
const generateFromRole = [
{
default: ['commonName'],
},
{
Options: [
'altNames',
'ipSans',
'uriSans',
'otherSans',
'ttl',
'format',
'privateKeyFormat',
'excludeCnFromSans',
'notAfter',
],
},
];
@withFormFields(null, generateFromRole)
export default class PkiCertificateGenerateModel extends PkiCertificateBaseModel {
getHelpUrl(backend) {
return `/v1/${backend}/issue/example?help=1`;
}
@attr('string') name; // associated role
}

View file

@ -0,0 +1,29 @@
import { parseCertificate } from 'vault/helpers/parse-pki-cert';
import ApplicationSerializer from '../../application';
export default class PkiCertificateGenerateSerializer extends ApplicationSerializer {
primaryKey = 'serial_number';
serialize() {
const json = super.serialize(...arguments);
// role name is part of the URL, remove from payload
delete json.name;
return json;
}
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (requestType === 'createRecord' && payload.data.certificate) {
// Parse certificate back from the API and add to payload
const parsedCert = parseCertificate(payload.data.certificate);
const json = super.normalizeResponse(
store,
primaryModelClass,
{ ...payload, ...parsedCert },
id,
requestType
);
return json;
}
return super.normalizeResponse(...arguments);
}
}

View file

@ -1,26 +0,0 @@
import Service from '@ember/service';
// SAMPLE CSV FORMAT ('content' argument)
// Must be a string with each row \n separated and each column comma separated
// 'Namespace path,Authentication method,Total clients,Entity clients,Non-entity clients\n
// namespacelonglonglong4/,,191,171,20\n
// namespacelonglonglong4/,auth/method/uMGBU,35,20,15\n'
export default class DownloadCsvService extends Service {
download(filename, content) {
// even though Blob type 'text/csv' is specified below, some browsers (ex. Firefox) require the filename has an explicit extension
const formattedFilename = `${filename?.replace(/\s+/g, '-')}.csv` || 'vault-data.csv';
const { document, URL } = window;
const downloadElement = document.createElement('a');
downloadElement.download = formattedFilename;
downloadElement.href = URL.createObjectURL(
new Blob([content], {
type: 'text/csv',
})
);
document.body.appendChild(downloadElement);
downloadElement.click();
URL.revokeObjectURL(downloadElement.href);
downloadElement.remove();
}
}

View file

@ -0,0 +1,36 @@
import Service from '@ember/service';
export default class DownloadService extends Service {
download(filename: string, mimetype: string, content: string) {
const { document, URL } = window;
const downloadElement = document.createElement('a');
downloadElement.download = filename;
downloadElement.href = URL.createObjectURL(
new Blob([content], {
type: mimetype,
})
);
document.body.appendChild(downloadElement);
downloadElement.click();
URL.revokeObjectURL(downloadElement.href);
downloadElement.remove();
}
// SAMPLE CSV FORMAT ('content' argument)
// Must be a string with each row \n separated and each column comma separated
// 'Namespace path,Authentication method,Total clients,Entity clients,Non-entity clients\n
// namespacelonglonglong4/,,191,171,20\n
// namespacelonglonglong4/,auth/method/uMGBU,35,20,15\n'
csv(filename: string, content: string) {
// even though Blob type 'text/csv' is specified below, some browsers (ex. Firefox) require the filename has an explicit extension
const formattedFilename = `${filename?.replace(/\s+/g, '-')}.csv` || 'vault-data.csv';
this.download(formattedFilename, 'text/csv', content);
return formattedFilename;
}
pem(filename: string, content: string) {
const formattedFilename = `${filename?.replace(/\s+/g, '-')}.pem` || 'vault-cert.pem';
this.download(formattedFilename, 'application/x-pem-file', content);
return formattedFilename;
}
}

View file

@ -3,12 +3,12 @@ import Service from '@ember/service';
// this service tracks the path of the currently viewed secret mount
// so that we can access that inside of engines where parent route params
// are not accessible
export default Service.extend({
currentPath: null,
export default class SecretMountPath extends Service {
currentPath = '';
update(path) {
this.set('currentPath', path);
},
this.currentPath = path;
}
get() {
return this.currentPath;
},
});
}
}

View file

@ -1,4 +1,4 @@
{{#each @model.fieldGroups as |fieldGroup|}}
{{#each (get @model this.fieldGroups) as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{#if (eq group "default")}}
{{#each fields as |attr|}}

View file

@ -12,6 +12,10 @@ import Component from '@glimmer/component';
* @param {string} mode - "create" or "update" used to hide the name form field. TODO: not ideal, would prefer to disable it to follow new design patterns.
* @param {function} [modelValidations] - Passed through to formField.
* @param {boolean} [showHelpText] - Passed through to formField.
* @param {string} [groupName="fieldGroups"] - option to override key on the model where groups are located
*/
/* eslint ember/no-empty-glimmer-component-classes: 'warn' */
export default class FormFieldGroupsLoop extends Component {}
export default class FormFieldGroupsLoop extends Component {
get fieldGroups() {
return this.args.groupName || 'fieldGroups';
}
}

View file

@ -0,0 +1,77 @@
{{#if @model.serialNumber}}
<Toolbar>
<ToolbarActions>
<button type="button" class="toolbar-link" {{on "click" this.downloadCert}} data-test-pki-cert-download-button>
Download
<Chevron @direction="down" @isButton={{true}} />
</button>
{{#if @model.canRevoke}}
<button
type="button"
class="toolbar-link"
{{on "click" (perform this.revoke)}}
disabled={{this.revoke.isRunning}}
data-test-pki-cert-revoke-button
>
Revoke certificate
<Chevron @direction="right" @isButton={{true}} />
</button>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#each @model.formFields as |attr|}}
{{#if (eq attr.name "certificate")}}
<InfoTableRow @label="Certificate" @value={{@model.certificate}}>
<MaskedInput @value={{@model.certificate}} @name="Certificate" @displayOnly={{true}} @allowCopy={{true}} />
</InfoTableRow>
{{else}}
<InfoTableRow
@label={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{get @model attr.name}}
@formatDate={{if (eq attr.type "date") "MMM d yyyy HH:mm:ss a zzzz"}}
/>
{{/if}}
{{/each}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-pki-generate-back
>
Back
</button>
</div>
</div>
{{else}}
<form {{on "submit" (perform this.save)}} data-test-pki-generate-cert-form>
<div class="box is-bottomless is-fullwidth is-marginless">
<MessageError @errorMessage={{this.errorBanner}} />
<NamespaceReminder @mode={{if @model.isNew "create" "edit"}} @noun="policy" />
<FormFieldGroupsLoop @model={{@model}} @mode="create" @groupName="formFieldGroups" />
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-pki-generate-button
>
Generate
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-pki-generate-cancel
>
Cancel
</button>
</div>
</div>
</form>
{{/if}}

View file

@ -0,0 +1,87 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { service } from '@ember/service';
import Router from '@ember/routing/router';
import Store from '@ember-data/store';
import { tracked } from '@glimmer/tracking';
import errorMessage from 'vault/utils/error-message';
import FlashMessageService from 'vault/services/flash-messages';
import DownloadService from 'vault/services/download';
interface Args {
onSuccess: CallableFunction;
model: PkiCertificateGenerateModel;
}
interface PkiCertificateGenerateModel {
name: string;
backend: string;
serialNumber: string;
certificate: string;
formFields: FormField[];
formFieldsGroup: {
[k: string]: FormField[];
}[];
save: () => void;
rollbackAttributes: () => void;
unloadRecord: () => void;
destroyRecord: () => void;
canRevoke: boolean;
}
interface FormField {
name: string;
type: string;
options: unknown;
}
export default class PkiRoleGenerate extends Component<Args> {
@service declare readonly router: Router;
@service declare readonly store: Store;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly download: DownloadService;
@tracked errorBanner = '';
transitionToRole() {
this.router.transitionTo('vault.cluster.secrets.backend.pki.roles.role.details');
}
@task
*save(evt: Event) {
evt.preventDefault();
this.errorBanner = '';
const { model, onSuccess } = this.args;
try {
yield model.save();
onSuccess();
} catch (err) {
this.errorBanner = errorMessage(err, 'Could not generate certificate. See Vault logs for details.');
}
}
@task
*revoke() {
try {
yield this.args.model.destroyRecord();
this.flashMessages.success('The certificate has been revoked.');
this.transitionToRole();
} catch (err) {
this.errorBanner = errorMessage(err, 'Could not revoke certificate. See Vault logs for details.');
}
}
@action downloadCert() {
try {
const formattedSerial = this.args.model.serialNumber?.replace(/(\s|:)+/g, '-');
this.download.pem(formattedSerial, this.args.model.certificate);
this.flashMessages.info('Your download has started.');
} catch (err) {
this.flashMessages.danger(errorMessage(err, 'Unable to prepare certificate for download.'));
}
}
@action cancel() {
this.args.model.unloadRecord();
this.transitionToRole();
}
}

View file

@ -0,0 +1,12 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class PkiRolesGenerateController extends Controller {
@tracked hasSubmitted = false;
@action
toggleTitle() {
this.hasSubmitted = !this.hasSubmitted;
}
}

View file

@ -13,6 +13,7 @@ export default class PkiEngine extends Engine {
dependencies = {
services: [
'auth',
'download',
'flash-messages',
'namespace',
'path-help',

View file

@ -0,0 +1,22 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class PkiRolesErrorRoute extends Route {
@service secretMountPath;
setupController(controller) {
super.setupController(...arguments);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath || 'pki', route: 'overview' },
];
controller.tabs = [
{ label: 'Overview', route: 'overview' },
{ label: 'Roles', route: 'roles.index' },
{ label: 'Issuers', route: 'issuers.index' },
{ label: 'Certificates', route: 'certificates.index' },
{ label: 'Keys', route: 'keys.index' },
];
controller.title = this.secretMountPath.currentPath;
}
}

View file

@ -1,3 +1,35 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class PkiRoleGenerateRoute extends Route {
@service store;
@service secretMountPath;
@service pathHelp;
export default class PkiRoleGenerateRoute extends Route {}
beforeModel() {
// Must call this promise before the model hook otherwise
// the model doesn't hydrate from OpenAPI correctly.
return this.pathHelp.getNewModel('pki/certificate/generate', this.secretMountPath.currentPath);
}
async model() {
const { role } = this.paramsFor('roles/role');
return this.store.createRecord('pki/certificate/generate', {
name: role,
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const { role } = this.paramsFor('roles/role');
const backend = this.secretMountPath.currentPath || 'pki';
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: backend, route: 'overview' },
{ label: 'roles', route: 'roles.index' },
{ label: role, route: 'roles.role.details' },
{ label: 'generate certificate' },
];
// This is updated on successful generate in the controller
controller.hasSubmitted = false;
}
}

View file

@ -0,0 +1,48 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
<Icon @name="file-text" @size="24" class="has-text-grey-light" />
{{this.title}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
<nav class="tabs" aria-label="secret tabs">
<ul>
{{#each this.tabs as |oTab|}}
<LinkTo @route={{oTab.route}} data-test-tab={{oTab.route}}>
{{oTab.label}}
</LinkTo>
{{/each}}
</ul>
</nav>
</div>
<div class="box is-sideless has-background-white-bis has-text-grey has-text-centered has-tall-padding" data-test-pki-error>
{{#if (eq this.model.httpStatus 404)}}
<h1 class="title is-3 has-text-grey">
404 Not Found
</h1>
<p>Sorry, we were unable to find any content at <code>{{or this.model.path this.path}}</code>.</p>
{{else if (eq this.model.httpStatus 403)}}
<h1 class="title is-3 has-text-grey">
Not authorized
</h1>
<p>You are not authorized to access content at <code>{{or this.model.path this.path}}</code>.</p>
{{else}}
<h1 class="title is-3 has-text-grey">
Error
</h1>
<p>
{{#if this.model.message}}
<p>{{this.model.message}}</p>
{{/if}}
{{#each this.model.errors as |error|}}
<p>{{error}}</p>
{{/each}}
</p>
{{/if}}
</div>

View file

@ -1 +1,13 @@
route: roles.role.generate
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-pki-role-page-title>
<Icon @name="certificate" @size="24" class="has-text-grey-light" />
{{if this.hasSubmitted "View generated certificate" "Generate certificate"}}
</h1>
</p.levelLeft>
</PageHeader>
<PkiRoleGenerate @onSuccess={{this.toggleTitle}} @model={{this.model}} />

View file

@ -101,6 +101,12 @@ module('Acceptance | pki workflow', function (hooks) {
path "${this.mountPath}/roles/*" {
capabilities = ["read", "update"]
},
path "${this.mountPath}/issue/*" {
capabilities = ["update"]
},
path "${this.mountPath}/sign/*" {
capabilities = ["update"]
},
`;
this.pkiRoleReader = await tokenWithPolicy('pki-reader', pki_reader_policy);
this.pkiRoleEditor = await tokenWithPolicy('pki-editor', pki_editor_policy);
@ -173,11 +179,33 @@ module('Acceptance | pki workflow', function (hooks) {
await click('.linked-block');
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/roles/some-role/details`);
assert.dom(SELECTORS.deleteRoleButton).doesNotExist('Delete role button is not shown');
assert.dom(SELECTORS.generateCertLink).doesNotExist('Generate cert link is not shown');
assert.dom(SELECTORS.signCertLink).doesNotExist('Sign cert link is not shown');
assert.dom(SELECTORS.generateCertLink).exists('Generate cert link is shown');
assert.dom(SELECTORS.signCertLink).exists('Sign cert link is shown');
assert.dom(SELECTORS.editRoleLink).exists('Edit link is shown');
await click(SELECTORS.editRoleLink);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/roles/some-role/edit`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/roles/some-role/edit`,
'Links to edit view'
);
await click(SELECTORS.roleForm.roleCancelButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/roles/some-role/details`,
'Cancel from edit goes to details'
);
await click(SELECTORS.generateCertLink);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/roles/some-role/generate`,
'Generate cert button goes to generate page'
);
await click(SELECTORS.generateCertForm.cancelButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/roles/some-role/details`,
'Cancel from generate goes to details'
);
});
test('create role happy path', async function (assert) {

View file

@ -0,0 +1,11 @@
export const SELECTORS = {
form: '[data-test-pki-generate-cert-form]',
commonNameField: '[data-test-input="commonName"]',
optionsToggle: '[data-test-toggle-group="Options"]',
generateButton: '[data-test-pki-generate-button]',
cancelButton: '[data-test-pki-generate-cancel]',
downloadButton: '[data-test-pki-cert-download-button]',
revokeButton: '[data-test-pki-cert-revoke-button]',
serialNumber: '[data-test-value-div="Serial number"]',
certificate: '[data-test-value-div="Certificate"]',
};

View file

@ -1,4 +1,5 @@
import { SELECTORS as ROLEFORM } from './roles/form';
import { SELECTORS as GENERATECERT } from './pki-role-generate';
export const SELECTORS = {
breadcrumbContainer: '[data-test-breadcrumbs]',
breadcrumbs: '[data-test-breadcrumbs] li',
@ -19,4 +20,7 @@ export const SELECTORS = {
roleForm: {
...ROLEFORM,
},
generateCertForm: {
...GENERATECERT,
},
};

37
ui/tests/helpers/stubs.js Normal file
View file

@ -0,0 +1,37 @@
export function capabilitiesStub(requestPath, capabilitiesArray) {
// sample of capabilitiesArray: ['read', 'update']
return {
request_id: '40f7e44d-af5c-9b60-bd20-df72eb17e294',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
capabilities: capabilitiesArray,
[requestPath]: capabilitiesArray,
},
wrap_info: null,
warnings: null,
auth: null,
};
}
/**
* allowAllCapabilitiesStub mocks the response from capabilities-self
* that allows the user to do any action (root user)
* EXAMPLE USAGE:
* this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub);
*/
export function allowAllCapabilitiesStub() {
return {
request_id: '40f7e44d-af5c-9b60-bd20-df72eb17e294',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
capabilities: ['root'],
},
wrap_info: null,
warnings: null,
auth: null,
};
}

View file

@ -0,0 +1,72 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setupEngine } from 'ember-engines/test-support';
import Sinon from 'sinon';
import { SELECTORS } from 'vault/tests/helpers/pki/pki-role-generate';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
module('Integration | Component | pki-role-generate', function (hooks) {
setupRenderingTest(hooks);
setupMirage(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.model = this.store.createRecord('pki/certificate/generate', {
role: 'my-role',
});
this.onSuccess = Sinon.spy();
});
test('it should render the component with the form by default', async function (assert) {
assert.expect(4);
await render(
hbs`
<div class="has-top-margin-xxl">
<PkiRoleGenerate
@model={{this.model}}
@onSuccess={{this.onSuccess}}
/>
</div>
`,
{ owner: this.engine }
);
assert.dom(SELECTORS.form).exists('shows the cert generate form');
assert.dom(SELECTORS.commonNameField).exists('shows the common name field');
assert.dom(SELECTORS.optionsToggle).exists('toggle exists');
await fillIn(SELECTORS.commonNameField, 'example.com');
assert.strictEqual(this.model.commonName, 'example.com', 'Filling in the form updates the model');
});
test('it should render the component displaying the cert', async function (assert) {
assert.expect(5);
const record = this.store.createRecord('pki/certificate/generate', {
role: 'my-role',
serialNumber: 'abcd-efgh-ijkl',
certificate: 'my-very-cool-certificate',
});
this.set('model', record);
await render(
hbs`
<div class="has-top-margin-xxl">
<PkiRoleGenerate
@model={{this.model}}
@onSuccess={{this.onSuccess}}
/>
</div>
`,
{ owner: this.engine }
);
assert.dom(SELECTORS.form).doesNotExist('Does not show the form');
assert.dom(SELECTORS.downloadButton).exists('shows the download button');
assert.dom(SELECTORS.revokeButton).exists('shows the revoke button');
assert.dom(SELECTORS.certificate).exists({ count: 1 }, 'shows certificate info row');
assert.dom(SELECTORS.serialNumber).hasText('abcd-efgh-ijkl', 'shows serial number info row');
});
});

View file

@ -0,0 +1,54 @@
import { module, test } from 'qunit';
import { setupTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Unit | Adapter | pki/certificate/generate', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.backend = 'pki-test';
this.secretMountPath.currentPath = this.backend;
this.data = {
serial_number: 'my-serial-number',
certificate: 'some-cert',
};
});
test('it should make request to correct endpoint on create', async function (assert) {
assert.expect(1);
const generateData = {
name: 'my-role',
common_name: 'example.com',
};
this.server.post(`${this.backend}/issue/${generateData.name}`, () => {
assert.ok(true, 'request made to correct endpoint on create');
return {
data: {
serial_number: 'this-serial-number',
},
};
});
const model = await this.store.createRecord('pki/certificate/generate', generateData);
await model.save();
});
test('it should make request to correct endpoint on delete', async function (assert) {
assert.expect(2);
this.store.pushPayload('pki/certificate/generate', {
modelName: 'pki/certificate/generate',
...this.data,
});
this.server.post(`${this.backend}/revoke`, (schema, req) => {
assert.deepEqual(JSON.parse(req.requestBody), { serial_number: 'my-serial-number' });
assert.ok(true, 'request made to correct endpoint on delete');
return { data: {} };
});
const model = await this.store.peekRecord('pki/certificate/generate', this.data.serial_number);
await model.destroyRecord();
});
});

View file

@ -0,0 +1,9 @@
import Adapter from 'ember-data/adapter';
import ModelRegistry from 'ember-data/types/registries/model';
/**
* Catch-all for ember-data.
*/
export default interface AdapterRegistry {
[key: keyof ModelRegistry]: Adapter;
}

9
ui/types/vault/adapters/pki/role.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
import Store from '@ember-data/store';
import { AdapterRegistry } from 'ember-data/adapter';
export default interface PkiRoleAdapter extends AdapterRegistry {
namespace: string;
_urlForRole(backend: string, id: string): string;
_optionsForQuery(id: string): { data: unknown };
generateCertificate(backend: string, roleName: string, data: unknown): unknown;
}