diff --git a/ui/app/components/sentinel-policy-editor.hbs b/ui/app/components/sentinel-policy-editor.hbs index 5b75e2a88..3c9cb6652 100644 --- a/ui/app/components/sentinel-policy-editor.hbs +++ b/ui/app/components/sentinel-policy-editor.hbs @@ -56,21 +56,21 @@ Advisory Soft Mandatory Hard Mandatory diff --git a/ui/app/templates/administration/sentinel-policies/gallery.hbs b/ui/app/templates/administration/sentinel-policies/gallery.hbs index 5d11488ce..05ecd344e 100644 --- a/ui/app/templates/administration/sentinel-policies/gallery.hbs +++ b/ui/app/templates/administration/sentinel-policies/gallery.hbs @@ -16,10 +16,12 @@ Select a Template {{#each this.templates as |template|}} - - {{template.displayName}} + + {{template.displayName}} {{template.description}} {{/each}} diff --git a/ui/app/templates/administration/sentinel-policies/index.hbs b/ui/app/templates/administration/sentinel-policies/index.hbs index 736adeed3..f1ca6a872 100644 --- a/ui/app/templates/administration/sentinel-policies/index.hbs +++ b/ui/app/templates/administration/sentinel-policies/index.hbs @@ -27,7 +27,7 @@ SPDX-License-Identifier: BUSL-1.1 label="Create Policy from Template" }} > - + {{else}} @@ -44,8 +44,8 @@ SPDX-License-Identifier: BUSL-1.1 {{B.data.name}} - {{B.data.description}} - {{B.data.enforcementLevel}} + {{B.data.description}} + {{B.data.enforcementLevel}} {{#if (can "destroy sentinel-policy")}} {{else}} -
-

+
+

No Sentinel Policies

diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs index 2f48a1a63..915134422 100644 --- a/ui/app/templates/components/global-header.hbs +++ b/ui/app/templates/components/global-header.hbs @@ -10,6 +10,7 @@ data-test-header-gutter-toggle class="gutter-toggle" aria-label="menu" + role="img" onclick={{action this.onHamburgerClick}} > diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index 8dc681130..598303411 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -14,6 +14,7 @@ data-test-gutter-gutter-toggle class="gutter-toggle" aria-label="menu" + role="img" onclick={{action this.onHamburgerClick}} > diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index c53489499..a724a4703 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -43,7 +43,7 @@ export default Mixin.create({ frameMisses: 0, handleResponse(frame) { - if (frame.error) { + if (!frame || frame.error) { this.incrementProperty('frameMisses'); if (this.frameMisses >= this.maxFrameMisses) { // Missing enough data consecutively is effectively a pause diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 3c79d9fcc..4e1dd0c8a 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -936,11 +936,15 @@ export default function () { }); this.post('/sentinel/policy/:id', function (schema, req) { - const { Name, Description, Rules } = JSON.parse(req.requestBody); + const { Name, Description, EnforcementLevel, Policy, Scope } = JSON.parse( + req.requestBody + ); return server.create('sentinelPolicy', { name: Name, description: Description, - rules: Rules, + enforcementLevel: EnforcementLevel, + policy: Policy, + scope: Scope, }); }); @@ -948,6 +952,16 @@ export default function () { return this.serialize(sentinelPolicies.findBy({ name: req.params.id })); }); + this.delete('/sentinel/policy/:id', function (schema, req) { + const { id } = req.params; + server.db.sentinelPolicies.remove(id); + return ''; + }); + + this.put('/sentinel/policy/:id', function (schema, req) { + return new Response(200, {}, {}); + }); + this.delete('/acl/policy/:id', function (schema, request) { const { id } = request.params; diff --git a/ui/mirage/factories/sentinel-policy.js b/ui/mirage/factories/sentinel-policy.js new file mode 100644 index 000000000..e471c6500 --- /dev/null +++ b/ui/mirage/factories/sentinel-policy.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { Factory } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; +import { pickOne } from '../utils'; + +export default Factory.extend({ + id: () => + `${faker.hacker.verb().replace(/\s/g, '-')}-${faker.random.alphaNumeric( + 5 + )}`, + name() { + return this.id; + }, + description: () => + faker.random.number(10) >= 2 ? faker.lorem.sentence() : null, + + policy: `# This policy will always fail. You can temporarily halt all new job updates using this. + + main = rule { false }`, + + scope: 'submit-job', + enforcementLevel: pickOne(['advisory', 'soft-mandatory', 'hard-mandatory']), +}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 8efc3b03e..b6f7bd833 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -623,8 +623,22 @@ function variableTestCluster(server) { }); } -function policiesTestCluster(server) { - server.create('feature', { name: 'Sentinel Policies' }); +function policiesTestCluster(server, options = { sentinel: false }) { + if (options.sentinel) { + server.create('feature', { name: 'Sentinel Policies' }); + server.create('sentinel-policy', { + id: 'policy-1', + name: 'policy-1', + description: 'A sentinel policy generated by Mirage', + enforcementLevel: 'soft-mandatory', + policy: + 'import "time"\n\nis_weekday = rule { time.day not in ["friday", "saturday", "sunday"] }\nis_open_hours = rule { time.hour > 8 and time.hour < 16 }\n\nmain = rule { is_open_hours and is_weekday }', + scope: 'submit-job', + }); + + server.createList('sentinel-policy', 5); + } + faker.seed(1); createTokens(server); server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); diff --git a/ui/tests/acceptance/access-control-test.js b/ui/tests/acceptance/access-control-test.js index f75ce3f21..44399626b 100644 --- a/ui/tests/acceptance/access-control-test.js +++ b/ui/tests/acceptance/access-control-test.js @@ -4,13 +4,14 @@ */ import { module, test } from 'qunit'; -import { currentURL, triggerKeyEvent } from '@ember/test-helpers'; +import { currentURL, triggerKeyEvent, click } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import Administration from 'nomad-ui/tests/pages/administration'; import Tokens from 'nomad-ui/tests/pages/settings/tokens'; import { allScenarios } from '../../mirage/scenarios/default'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import percySnapshot from '@percy/ember'; // Several related tests within Access Control are contained in the Tokens, Roles, // and Policies acceptance tests. @@ -71,6 +72,35 @@ module('Acceptance | access control', function (hooks) { ); }); + test('Access control does not show Sentinel Policies if they are not present in license', async function (assert) { + allScenarios.policiesTestCluster(server); + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + await Administration.visit(); + assert.dom('[data-test-sentinel-policies-card]').doesNotExist(); + }); + + test('Access control shows Sentinel Policies if they are present in license', async function (assert) { + assert.expect(2); + allScenarios.policiesTestCluster(server, { sentinel: true }); + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + await Administration.visit(); + + assert.dom('[data-test-sentinel-policies-card]').exists(); + await percySnapshot(assert); + await click('[data-test-sentinel-policies-card] a'); + assert.equal(currentURL(), '/administration/sentinel-policies'); + }); + test('Access control index content', async function (assert) { await Tokens.visit(); const managementToken = server.db.tokens.findBy( diff --git a/ui/tests/acceptance/job-allocations-test.js b/ui/tests/acceptance/job-allocations-test.js index 9ec652ddf..70a7cb52f 100644 --- a/ui/tests/acceptance/job-allocations-test.js +++ b/ui/tests/acceptance/job-allocations-test.js @@ -4,7 +4,7 @@ */ /* eslint-disable qunit/require-expect */ -import { currentURL, click, find } from '@ember/test-helpers'; +import { currentURL, click } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -85,9 +85,8 @@ module('Acceptance | job allocations', function (hooks) { await Allocations.visit({ id: job.id }); - const firstAllocation = find('[data-test-allocation]'); + const firstAllocation = document.querySelector('[data-test-allocation]'); await click(firstAllocation); - const requestToAllocationEndpoint = server.pretender.handledRequests.find( (request) => request.url.includes( diff --git a/ui/tests/acceptance/sentinel-policies-test.js b/ui/tests/acceptance/sentinel-policies-test.js new file mode 100644 index 000000000..90aff92e7 --- /dev/null +++ b/ui/tests/acceptance/sentinel-policies-test.js @@ -0,0 +1,177 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { findAll, fillIn, find, click, currentURL } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import { allScenarios } from '../../mirage/scenarios/default'; +import Tokens from 'nomad-ui/tests/pages/settings/tokens'; +import Administration from 'nomad-ui/tests/pages/administration'; +import percySnapshot from '@percy/ember'; + +module('Acceptance | sentinel policies', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function () { + window.localStorage.clear(); + window.sessionStorage.clear(); + allScenarios.policiesTestCluster(server, { sentinel: true }); + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + await Administration.visitSentinelPolicies(); + }); + + hooks.afterEach(async function () { + await Tokens.visit(); + await Tokens.clear(); + }); + + test('Sentinel Policies index, general', async function (assert) { + assert.expect(3); + await a11yAudit(assert); + + assert.equal(currentURL(), '/administration/sentinel-policies'); + assert + .dom('[data-test-sentinel-policy-row]') + .exists({ count: server.db.sentinelPolicies.length }); + + await percySnapshot(assert); + }); + + test('Sentinel Policies index: deletion', async function (assert) { + // Delete every policy + assert + .dom('[data-test-empty-sentinel-policy-list-headline]') + .doesNotExist('no empty state'); + const policyRows = findAll('[data-test-sentinel-policy-row]'); + + for (const row of policyRows) { + const deleteButton = row.querySelector( + '[data-test-delete-policy] [data-test-idle-button]' + ); + await click(deleteButton); + const yesReallyDeleteButton = row.querySelector( + '[data-test-delete-policy] [data-test-confirm-button]' + ); + await click(yesReallyDeleteButton); + } + // there should be as many success messages as there were policies + assert + .dom('.flash-message.alert-success') + .exists({ count: policyRows.length }); + + assert + .dom('[data-test-empty-sentinel-policy-list-headline]') + .exists('empty state'); + }); + + test('Edit Sentinel Policy: Description and Enforcement Level', async function (assert) { + const policy = server.db.sentinelPolicies.findBy( + (sp) => sp.name === 'policy-1' + ); + await click('[data-test-sentinel-policy-name="policy-1"]'); + assert.equal( + currentURL(), + `/administration/sentinel-policies/${policy.id}` + ); + + assert.dom('[data-test-policy-description]').hasValue(policy.description); + + await fillIn('[data-test-policy-description]', 'edited description'); + await click('[data-test-enforcement-level="hard-mandatory"]'); + await click('button[data-test-save-policy]'); + assert.dom('.flash-message.alert-success').exists(); + + // Go back to the index + await Administration.visitSentinelPolicies(); + const policyRow = find( + '[data-test-sentinel-policy-name="policy-1"]' + ).closest('[data-test-sentinel-policy-row]'); + assert.dom(policyRow).exists(); + let rowDescription = policyRow.querySelector( + '[data-test-sentinel-policy-description]' + ); + assert.equal(rowDescription.textContent.trim(), 'edited description'); + assert + .dom(policyRow.querySelector('[data-test-sentinel-policy-enforcement]')) + .hasText('hard-mandatory'); + }); + + test('New Sentinel Policy from Scratch', async function (assert) { + await click('[data-test-create-sentinel-policy]'); + assert.equal(currentURL(), '/administration/sentinel-policies/new'); + await fillIn('[data-test-policy-name-input]', 'new-policy'); + await fillIn('[data-test-policy-description]', 'new description'); + await click('[data-test-enforcement-level="hard-mandatory"]'); + + await click('[data-test-save-policy]'); + assert.dom('.flash-message.alert-success').exists('success message shown'); + + // Go back to the index + await Administration.visitSentinelPolicies(); + const policyRow = find( + '[data-test-sentinel-policy-name="new-policy"]' + ).closest('[data-test-sentinel-policy-row]'); + assert.dom(policyRow).exists('new policy row exists'); + let rowDescription = policyRow.querySelector( + '[data-test-sentinel-policy-description]' + ); + assert.equal( + rowDescription.textContent.trim(), + 'new description', + 'description matches new policy input' + ); + assert + .dom(policyRow.querySelector('[data-test-sentinel-policy-enforcement]')) + .hasText('hard-mandatory', 'enforcement level matches new policy input'); + + await click('[data-test-sentinel-policy-name="new-policy"]'); + await click('[data-test-delete-policy] [data-test-idle-button]'); + await click('[data-test-delete-policy] [data-test-confirm-button]'); + assert.dom('.flash-message.alert-success').exists('success message shown'); + + await Administration.visitSentinelPolicies(); + assert + .dom('[data-test-sentinel-policy-name="new-policy"]') + .doesNotExist('new policy row is gone'); + }); + + test('New Sentinel Policy from Template', async function (assert) { + assert.expect(5); + await click('[data-test-create-sentinel-policy-from-template]'); + assert.equal(currentURL(), '/administration/sentinel-policies/gallery'); + await percySnapshot(assert); + const template = find('[data-test-template-card="no-friday-deploys"]'); + await click(template); + assert.ok( + find('[data-test-template-card="no-friday-deploys"]') + ?.closest('label') + .classList.contains( + 'hds-form-radio-card--checked', + 'template is selected on click' + ) + ); + await click('[data-test-apply]'); + assert.equal( + currentURL(), + '/administration/sentinel-policies/new?template=no-friday-deploys', + 'New Policy page has query param' + ); + + await percySnapshot(assert); + + assert.dom('[data-test-policy-name-input]').hasValue('no-friday-deploys'); + assert + .dom('[data-test-policy-description]') + .hasValue('Ensures that no deploys happen on a Friday'); + }); +}); diff --git a/ui/tests/pages/administration.js b/ui/tests/pages/administration.js index cc382561b..7926b6793 100644 --- a/ui/tests/pages/administration.js +++ b/ui/tests/pages/administration.js @@ -11,4 +11,5 @@ export default create({ visitPolicies: visitable('/administration/policies'), visitRoles: visitable('/administration/roles'), visitNamespaces: visitable('/administration/namespaces'), + visitSentinelPolicies: visitable('/administration/sentinel-policies'), });