ui: VAULT-6511 PKI Overview Page (#18599)

* Inital pki overview page code setup

* Add more properties to pki-overview

* Remove previous selectable card component and update template

* Add capability check for roles and issuers

* Add acceptance tests for overview page

* Update SelectableCardForm component

* Code refactor!

* Add selectable-card-form test

* More code cleanup and move function to test helper file

* Address most feedback. Pending refactor of issue certificate card!

* Add integration test

* Moves form to SelectableCard and add tests

* Add jsdoc props to SelectableCard and fix placeholder

* Move back SelectableCard

* Covert to typescript and finish up tests

* Dont use try catch for hasConfig

* Add overview card test

* More overview card tests

* Address feedback!
This commit is contained in:
Kianna 2023-01-17 13:30:31 -08:00 committed by GitHub
parent 64f38ffd57
commit f3a8fdd4f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 468 additions and 43 deletions

View File

@ -29,3 +29,12 @@
padding: $spacing-l 0 14px $spacing-l; // modify bottom spacing to better align with other cards
}
}
.selectable-card-container.has-grid.has-two-col-grid {
grid-template-columns: 2fr 2fr;
grid-template-rows: none;
}
.selectable-card-container.has-grid.has-three-col-grid {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: none;
}

View File

@ -0,0 +1,18 @@
<div class="selectable-card is-rounded no-flex" data-test-selectable-card-container={{@cardTitle}}>
<div class="is-flex-between is-fullwidth card-details" data-test-selectable-card={{@cardTitle}}>
<h3 class="title is-5">{{@cardTitle}}</h3>
{{#if @actionText}}
<LinkTo
class="has-icon-right is-ghost is-no-underline has-text-semibold"
@route={{@actionTo}}
@query={{hash @actionQuery}}
data-test-action-text={{@actionText}}
>
{{@actionText}}
<Icon @name="chevron-right" />
</LinkTo>
{{/if}}
</div>
<p class="has-text-grey is-size-8 {{unless @actionText 'has-top-margin-xs'}}">{{@subText}}</p>
{{yield}}
</div>

View File

@ -0,0 +1 @@
export { default } from 'core/components/input-search';

View File

@ -0,0 +1 @@
export { default } from 'core/components/overview-card';

View File

@ -0,0 +1,82 @@
<div
class="selectable-card-container has-grid has-top-margin-l has-two-col-grid
{{if (eq @roles 403) 'has-three-col-grid' 'has-two-col-grid'}}"
>
<OverviewCard
@cardTitle="Issuers"
@subText="The total number of issuers in this PKI mount. Includes both root and intermediate certificates."
@actionText="View issuers"
@actionTo="issuers"
>
<h2 class="title-number">{{format-number (if (eq @issuers 404) 0 @issuers.length)}}</h2>
</OverviewCard>
{{#if (not (eq @roles 403))}}
<OverviewCard
@cardTitle="Roles"
@subText="The total number of roles in this PKI mount that have been created to generate certificates."
@actionText="View roles"
@actionTo="roles"
>
<h2 class="title-number">{{format-number (if (eq @roles 404) 0 @roles.length)}}</h2>
</OverviewCard>
{{/if}}
<OverviewCard @cardTitle="Issue certificate" @subText="Begin issuing a certificate by choosing a role.">
<form
aria-label="issue certificate"
data-test-selectable-card="Issue certificate"
{{on "submit" this.transitionToIssueCertificates}}
>
<div class="has-top-padding-s is-flex">
<SearchSelect
class="is-flex-1"
@selectLimit="1"
@models={{array "pki/role"}}
@backend={{@engine.id}}
@placeholder="Type to find a role..."
@disallowNewItems={{true}}
@onChange={{this.handleRolesInput}}
@fallbackComponent="input-search"
data-test-issue-certificate-input
/>
<button
type="submit"
class="button is-secondary has-left-margin-s"
disabled={{unless this.rolesValue true}}
data-test-issue-certificate-button
>
Issue
</button>
</div>
</form>
</OverviewCard>
<OverviewCard @cardTitle="View certificate" @subText="Quickly view a certificate by typing its serial number.">
<form
aria-label="view certificate"
data-test-selectable-card="View certificate"
{{on "submit" this.transitionToViewCertificates}}
>
<div class="has-top-padding-s is-flex">
<SearchSelect
class="is-flex-1"
@selectLimit="1"
@models={{array "pki/certificate"}}
@backend={{@engine.id}}
@placeholder="33:a3:..."
@disallowNewItems={{true}}
@onChange={{this.handleCertificateInput}}
@fallbackComponent="input-search"
data-test-view-certificate-input
/>
<button
type="submit"
class="button is-secondary has-left-margin-s"
disabled={{unless this.certificateValue true}}
data-test-view-certificate-button
>
View
</button>
</div>
</form>
</OverviewCard>
</div>

View File

@ -0,0 +1,55 @@
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';
// TYPES
import Store from '@ember-data/store';
import RouterService from '@ember/routing/router-service';
import PkiIssuerModel from 'vault/models/pki/issuer';
import PkiRoleModel from 'vault/models/pki/role';
interface Args {
issuers: PkiIssuerModel | number;
roles: PkiRoleModel | number;
engine: string;
}
export default class PkiOverview extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly store: Store;
@tracked rolesValue = '';
@tracked certificateValue = '';
@action
transitionToViewCertificates(event: Event) {
event.preventDefault();
this.router.transitionTo(
'vault.cluster.secrets.backend.pki.certificates.certificate.details',
this.certificateValue
);
}
@action
transitionToIssueCertificates(event: Event) {
event.preventDefault();
this.router.transitionTo('vault.cluster.secrets.backend.pki.roles.role.generate', this.rolesValue);
}
@action
handleRolesInput(roles: string) {
if (Array.isArray(roles)) {
this.rolesValue = roles[0];
} else {
this.rolesValue = roles;
}
}
@action
handleCertificateInput(certificate: string) {
if (Array.isArray(certificate)) {
this.certificateValue = certificate[0];
} else {
this.certificateValue = certificate;
}
}
}

View File

@ -15,30 +15,46 @@ export default class PkiOverviewRoute extends Route {
// When the engine is configured, it creates a default issuer.
// If the issuers list is empty, we know it hasn't been configured
const endpoint = `${this.win.origin}/v1/${this.secretMountPath.currentPath}/issuers?list=true`;
return this.auth
.ajax(endpoint, 'GET', {})
.then(() => true)
.catch(() => false);
}
async fetchEngine() {
const model = await this.store.query('secret-engine', {
path: this.secretMountPath.currentPath,
});
return model.get('firstObject');
}
async fetchAllRoles() {
try {
return await this.store.query('pki/role', { backend: this.secretMountPath.currentPath });
} catch (e) {
return e.httpStatus;
}
}
async fetchAllIssuers() {
try {
return await this.store.query('pki/issuer', { backend: this.secretMountPath.currentPath });
} catch (e) {
return e.httpStatus;
}
}
async model() {
return hash({
hasConfig: this.hasConfig(),
engine: this.store
.query('secret-engine', {
path: this.secretMountPath.currentPath,
})
.then((model) => {
if (model) {
return model.get('firstObject');
}
}),
engine: this.fetchEngine(),
roles: this.fetchAllRoles(),
issuers: this.fetchAllIssuers(),
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const backend = this.secretMountPath.currentPath || 'pki';
controller.breadcrumbs = [{ label: 'secrets', route: 'secrets', linkExternal: true }, { label: backend }];
}
}

View File

@ -19,7 +19,7 @@
</Toolbar>
{{#if this.model.hasConfig}}
{{! TODO show overview items }}
<Page::PkiOverview @issuers={{this.model.issuers}} @roles={{this.model.roles}} @engine={{this.model.engine}} />
{{else}}
<EmptyState @title="PKI not configured" @message="This PKI mount hasn't yet been configured with a certificate issuer.">
<LinkTo @route="configuration.create" @model={{this.model.engine}}>

View File

@ -1,42 +1,12 @@
import { create } from 'ember-cli-page-object';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import authPage from 'vault/tests/pages/auth';
import logout from 'vault/tests/pages/logout';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { click, currentURL, fillIn, find, isSettled, visit } from '@ember/test-helpers';
import { SELECTORS } from 'vault/tests/helpers/pki/workflow';
import { adminPolicy, readerPolicy, updatePolicy } from 'vault/tests/helpers/policy-generator/pki';
const consoleComponent = create(consoleClass);
const tokenWithPolicy = async function (name, policy) {
await consoleComponent.runCommands([
`write sys/policies/acl/${name} policy=${btoa(policy)}`,
`write -field=client_token auth/token/create policies=${name}`,
]);
return consoleComponent.lastLogOutput;
};
const runCommands = async function (commands) {
try {
await consoleComponent.runCommands(commands);
const res = consoleComponent.lastLogOutput;
if (res.includes('Error')) {
throw new Error(res);
}
return res;
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`The following occurred when trying to run the command(s):\n ${commands.join('\n')} \n\n ${
consoleComponent.lastLogOutput
}`
);
throw error;
}
};
import { tokenWithPolicy, runCommands } from 'vault/tests/helpers/pki/pki-run-commands';
/**
* This test module should test the PKI workflow, including:

View File

@ -0,0 +1,112 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import authPage from 'vault/tests/pages/auth';
import logout from 'vault/tests/pages/logout';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import { click, currentURL, currentRouteName, visit } from '@ember/test-helpers';
import { SELECTORS } from 'vault/tests/helpers/pki/overview';
import { tokenWithPolicy, runCommands } from 'vault/tests/helpers/pki/pki-run-commands';
module('Acceptance | pki overview', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(async function () {
await authPage.login();
// Setup PKI engine
const mountPath = `pki`;
await enablePage.enable('pki', mountPath);
this.mountPath = mountPath;
await runCommands([`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test"`]);
const pki_admin_policy = `
path "${this.mountPath}/*" {
capabilities = ["create", "read", "update", "delete", "list"]
},
`;
const pki_issuers_list_policy = `
path "${this.mountPath}/issuers" {
capabilities = ["list"]
},
`;
const pki_roles_list_policy = `
path "${this.mountPath}/roles" {
capabilities = ["list"]
},
`;
this.pkiRolesList = await tokenWithPolicy('pki-roles-list', pki_roles_list_policy);
this.pkiIssuersList = await tokenWithPolicy('pki-issuers-list', pki_issuers_list_policy);
this.pkiAdminToken = await tokenWithPolicy('pki-admin', pki_admin_policy);
await logout.visit();
});
hooks.afterEach(async function () {
await logout.visit();
await authPage.login();
// Cleanup engine
await runCommands([`delete sys/mounts/${this.mountPath}`]);
await logout.visit();
});
test('navigates to view issuers when link is clicked on issuer card', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(SELECTORS.issuersCardTitle).hasText('Issuers');
assert.dom(SELECTORS.issuersCardOverviewNum).hasText('1');
await click(SELECTORS.issuersCardLink);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/issuers`);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
});
test('navigates to view roles when link is clicked on roles card', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(SELECTORS.rolesCardTitle).hasText('Roles');
assert.dom(SELECTORS.rolesCardOverviewNum).hasText('0');
await click(SELECTORS.rolesCardLink);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/roles`);
await runCommands([
`write ${this.mountPath}/roles/some-role \
issuer_ref="default" \
allowed_domains="example.com" \
allow_subdomains=true \
max_ttl="720h"`,
]);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(SELECTORS.rolesCardOverviewNum).hasText('1');
});
test('hides roles card if user does not have permissions', async function (assert) {
await authPage.login(this.pkiIssuersList);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(SELECTORS.rolesCardTitle).doesNotExist('Roles card does not exist');
assert.dom(SELECTORS.issuersCardTitle).exists('Issuers card exists');
});
test('navigates to generate certificate page for Issue Certificates card', async function (assert) {
await authPage.login(this.pkiAdminToken);
await runCommands([
`write ${this.mountPath}/roles/some-role \
issuer_ref="default" \
allowed_domains="example.com" \
allow_subdomains=true \
max_ttl="720h"`,
]);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
await click(SELECTORS.issueCertificatePowerSearch);
await click(SELECTORS.firstPowerSelectOption);
await click(SELECTORS.issueCertificateButton);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.roles.role.generate');
});
test('navigates to certificate details page for View Certificates card', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
await click(SELECTORS.viewCertificatePowerSearch);
await click(SELECTORS.firstPowerSelectOption);
await click(SELECTORS.viewCertificateButton);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.pki.certificates.certificate.details'
);
});
});

View File

@ -0,0 +1,19 @@
export const SELECTORS = {
issuersCardTitle: '[data-test-selectable-card-container="Issuers"] h3',
issuersCardSubtitle: '[data-test-selectable-card-container="Issuers"] p',
issuersCardLink: '[data-test-selectable-card-container="Issuers"] a',
issuersCardOverviewNum: '[data-test-selectable-card-container="Issuers"] .title-number',
rolesCardTitle: '[data-test-selectable-card-container="Roles"] h3',
rolesCardSubtitle: '[data-test-selectable-card-container="Roles"] p',
rolesCardLink: '[data-test-selectable-card-container="Roles"] a',
rolesCardOverviewNum: '[data-test-selectable-card-container="Roles"] .title-number',
issueCertificate: '[data-test-selectable-card-container="Issue certificate"] h3',
issueCertificateInput: '[data-test-issue-certificate-input]',
issueCertificatePowerSearch: '[data-test-issue-certificate-input] span',
issueCertificateButton: '[data-test-issue-certificate-button]',
viewCertificate: '[data-test-selectable-card-container="View certificate"] h3',
viewCertificateInput: '[data-test-view-certificate-input]',
viewCertificatePowerSearch: '[data-test-view-certificate-input] span',
viewCertificateButton: '[data-test-view-certificate-button]',
firstPowerSelectOption: '[data-option-index="0"]',
};

View File

@ -0,0 +1,31 @@
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { create } from 'ember-cli-page-object';
const consoleComponent = create(consoleClass);
export const tokenWithPolicy = async function (name, policy) {
await consoleComponent.runCommands([
`write sys/policies/acl/${name} policy=${btoa(policy)}`,
`write -field=client_token auth/token/create policies=${name}`,
]);
return consoleComponent.lastLogOutput;
};
export const runCommands = async function (commands) {
try {
await consoleComponent.runCommands(commands);
const res = consoleComponent.lastLogOutput;
if (res.includes('Error')) {
throw new Error(res);
}
return res;
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`The following occurred when trying to run the command(s):\n ${commands.join('\n')} \n\n ${
consoleComponent.lastLogOutput
}`
);
throw error;
}
};

View File

@ -0,0 +1,34 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
const CARD_TITLE = 'Card title';
const ACTION_TEXT = 'View card';
const SUBTEXT = 'This is subtext for card';
module('Integration | Component overview-card', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.set('cardTitle', CARD_TITLE);
this.set('actionText', ACTION_TEXT);
this.set('subText', SUBTEXT);
});
test('it returns card title, ', async function (assert) {
await render(hbs`<OverviewCard @cardTitle={{this.cardTitle}}/>`);
const titleText = this.element.querySelector('.title').innerText;
assert.strictEqual(titleText, 'Card title');
});
test('it returns card subtext, ', async function (assert) {
await render(hbs`<OverviewCard @cardTitle={{this.cardTitle}} @subText={{this.subText}} />`);
const titleText = this.element.querySelector('p').innerText;
assert.strictEqual(titleText, 'This is subtext for card');
});
test('it returns card action text', async function (assert) {
await render(hbs`<OverviewCard @cardTitle={{this.cardTitle}} @actionText={{this.actionText}}/>`);
const titleText = this.element.querySelector('a').innerText;
assert.strictEqual(titleText, 'View card ');
});
});

View File

@ -0,0 +1,77 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/overview';
module('Integration | Component | Page::PkiOverview', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.store.createRecord('pki/issuer', { issuerId: 'abcd-efgh' });
this.store.createRecord('pki/issuer', { issuerId: 'ijkl-mnop' });
this.store.createRecord('pki/role', { name: 'role-0' });
this.store.createRecord('pki/role', { name: 'role-1' });
this.store.createRecord('pki/role', { name: 'role-2' });
this.store.createRecord('pki/certificate', { serialNumber: '22:2222:22222:2222' });
this.store.createRecord('pki/certificate', { serialNumber: '33:3333:33333:3333' });
this.issuers = this.store.peekAll('pki/issuer');
this.roles = this.store.peekAll('pki/role');
this.engineId = 'pki';
});
test('shows the correct information on issuer card', async function (assert) {
await render(
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.issuersCardTitle).hasText('Issuers');
assert.dom(SELECTORS.issuersCardOverviewNum).hasText('2');
assert.dom(SELECTORS.issuersCardLink).hasText('View issuers');
});
test('shows the correct information on roles card', async function (assert) {
await render(
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.rolesCardTitle).hasText('Roles');
assert.dom(SELECTORS.rolesCardOverviewNum).hasText('3');
assert.dom(SELECTORS.rolesCardLink).hasText('View roles');
this.roles = 404;
await render(
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.rolesCardOverviewNum).hasText('0');
});
test('shows the input search fields for View Certificates card', async function (assert) {
await render(
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.issueCertificate).hasText('Issue certificate');
assert.dom(SELECTORS.issueCertificateInput).exists();
assert.dom(SELECTORS.issueCertificateButton).hasText('Issue');
});
test('shows the input search fields for Issue Certificates card', async function (assert) {
await render(
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.viewCertificate).hasText('View certificate');
assert.dom(SELECTORS.viewCertificateInput).exists();
assert.dom(SELECTORS.viewCertificateButton).hasText('View');
});
});