+
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'),
});