From ce0ffdd0770e3b09e58361b3d3d1df74cad61ad0 Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Tue, 6 Dec 2022 12:45:36 -0500 Subject: [PATCH] [ui] Policies UI (#13976) Co-authored-by: Mike Nomitch --- .changelog/13976.txt | 3 + ui/app/abilities/policy.js | 12 ++ ui/app/adapters/policy.js | 8 ++ ui/app/components/policy-editor.hbs | 61 ++++++++++ ui/app/components/policy-editor.js | 62 ++++++++++ ui/app/controllers/policies/index.js | 19 +++ ui/app/controllers/policies/policy.js | 46 ++++++++ ui/app/modifiers/code-mirror.js | 14 +++ ui/app/router.js | 8 ++ ui/app/routes/policies.js | 27 +++++ ui/app/routes/policies/new.js | 109 ++++++++++++++++++ ui/app/routes/policies/policy.js | 16 +++ ui/app/serializers/policy.js | 10 +- ui/app/styles/components.scss | 1 + ui/app/styles/components/policies.scss | 27 +++++ ui/app/styles/core/notifications.scss | 1 + ui/app/styles/core/table.scss | 8 +- ui/app/templates/components/gutter-menu.hbs | 19 ++- ui/app/templates/policies.hbs | 4 + ui/app/templates/policies/index.hbs | 67 +++++++++++ ui/app/templates/policies/new.hbs | 10 ++ ui/app/templates/policies/policy.hbs | 29 +++++ ui/ember-cli-build.js | 2 +- ui/mirage/config.js | 32 ++++- ui/mirage/factories/policy.js | 3 +- ui/mirage/scenarios/default.js | 8 ++ ui/tests/acceptance/policies-test.js | 102 ++++++++++++++++ .../components/policy-editor-test.js | 35 ++++++ 28 files changed, 732 insertions(+), 11 deletions(-) create mode 100644 .changelog/13976.txt create mode 100644 ui/app/abilities/policy.js create mode 100644 ui/app/components/policy-editor.hbs create mode 100644 ui/app/components/policy-editor.js create mode 100644 ui/app/controllers/policies/index.js create mode 100644 ui/app/controllers/policies/policy.js create mode 100644 ui/app/routes/policies.js create mode 100644 ui/app/routes/policies/new.js create mode 100644 ui/app/routes/policies/policy.js create mode 100644 ui/app/styles/components/policies.scss create mode 100644 ui/app/templates/policies.hbs create mode 100644 ui/app/templates/policies/index.hbs create mode 100644 ui/app/templates/policies/new.hbs create mode 100644 ui/app/templates/policies/policy.hbs create mode 100644 ui/tests/acceptance/policies-test.js create mode 100644 ui/tests/integration/components/policy-editor-test.js diff --git a/.changelog/13976.txt b/.changelog/13976.txt new file mode 100644 index 000000000..17a4cbb0b --- /dev/null +++ b/.changelog/13976.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Added a Policy Editor interface for management tokens +``` diff --git a/ui/app/abilities/policy.js b/ui/app/abilities/policy.js new file mode 100644 index 000000000..6ab8144eb --- /dev/null +++ b/ui/app/abilities/policy.js @@ -0,0 +1,12 @@ +import AbstractAbility from './abstract'; +import { alias } from '@ember/object/computed'; +import classic from 'ember-classic-decorator'; + +@classic +export default class Policy extends AbstractAbility { + @alias('selfTokenIsManagement') canRead; + @alias('selfTokenIsManagement') canList; + @alias('selfTokenIsManagement') canWrite; + @alias('selfTokenIsManagement') canUpdate; + @alias('selfTokenIsManagement') canDestroy; +} diff --git a/ui/app/adapters/policy.js b/ui/app/adapters/policy.js index 85be4fbad..cef408be3 100644 --- a/ui/app/adapters/policy.js +++ b/ui/app/adapters/policy.js @@ -4,4 +4,12 @@ import classic from 'ember-classic-decorator'; @classic export default class PolicyAdapter extends ApplicationAdapter { namespace = namespace + '/acl'; + + urlForCreateRecord(_modelName, model) { + return this.urlForUpdateRecord(model.attr('name'), 'policy'); + } + + urlForDeleteRecord(id) { + return this.urlForUpdateRecord(id, 'policy'); + } } diff --git a/ui/app/components/policy-editor.hbs b/ui/app/components/policy-editor.hbs new file mode 100644 index 000000000..353748b18 --- /dev/null +++ b/ui/app/components/policy-editor.hbs @@ -0,0 +1,61 @@ +
+ {{#if @policy.isNew }} + + {{/if}} + +
+
+ Policy Definition +
+
+ +
+
+
+ +
+ +
+ +
+ {{#if (can "update policy")}} + + {{/if}} +
+ \ No newline at end of file diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js new file mode 100644 index 000000000..0005909f5 --- /dev/null +++ b/ui/app/components/policy-editor.js @@ -0,0 +1,62 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import messageForError from 'nomad-ui/utils/message-from-adapter-error'; + +export default class PolicyEditorComponent extends Component { + @service flashMessages; + @service router; + @service store; + + @alias('args.policy') policy; + + @action updatePolicyRules(value) { + this.policy.set('rules', value); + } + + @action async save(e) { + if (e instanceof Event) { + e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault() + } + try { + const nameRegex = '^[a-zA-Z0-9-]{1,128}$'; + if (!this.policy.name?.match(nameRegex)) { + throw new Error( + 'Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.' + ); + } + + if ( + this.policy.isNew && + this.store.peekRecord('policy', this.policy.name) + ) { + throw new Error( + `A policy with name ${this.policy.name} already exists.` + ); + } + + this.policy.id = this.policy.name; + + await this.policy.save(); + + this.flashMessages.add({ + title: 'Policy Saved', + type: 'success', + destroyOnClick: false, + timeout: 5000, + }); + + this.router.transitionTo('policies'); + } catch (error) { + console.log('error and its', error); + this.flashMessages.add({ + title: `Error creating Policy ${this.policy.name}`, + message: messageForError(error), + type: 'error', + destroyOnClick: false, + sticky: true, + }); + } + } +} diff --git a/ui/app/controllers/policies/index.js b/ui/app/controllers/policies/index.js new file mode 100644 index 000000000..faaa78dd7 --- /dev/null +++ b/ui/app/controllers/policies/index.js @@ -0,0 +1,19 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class PoliciesIndexController extends Controller { + @service router; + get policies() { + return this.model.policies.map((policy) => { + policy.tokens = this.model.tokens.filter((token) => { + return token.policies.includes(policy); + }); + return policy; + }); + } + + @action openPolicy(policy) { + this.router.transitionTo('policies.policy', policy.name); + } +} diff --git a/ui/app/controllers/policies/policy.js b/ui/app/controllers/policies/policy.js new file mode 100644 index 000000000..f54663dd4 --- /dev/null +++ b/ui/app/controllers/policies/policy.js @@ -0,0 +1,46 @@ +// @ts-check +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +export default class PoliciesPolicyController extends Controller { + @service flashMessages; + @service router; + + @tracked isDeleting = false; + + @action + onDeletePrompt() { + this.isDeleting = true; + } + + @action + onDeleteCancel() { + this.isDeleting = false; + } + + @task(function* () { + try { + yield this.model.deleteRecord(); + yield this.model.save(); + this.flashMessages.add({ + title: 'Policy Deleted', + type: 'success', + destroyOnClick: false, + timeout: 5000, + }); + this.router.transitionTo('policies'); + } catch (err) { + this.flashMessages.add({ + title: `Error deleting Policy ${this.model.name}`, + message: err, + type: 'error', + destroyOnClick: false, + sticky: true, + }); + } + }) + deletePolicy; +} diff --git a/ui/app/modifiers/code-mirror.js b/ui/app/modifiers/code-mirror.js index 3e3b4f3c7..b7305b720 100644 --- a/ui/app/modifiers/code-mirror.js +++ b/ui/app/modifiers/code-mirror.js @@ -8,8 +8,18 @@ import 'codemirror/addon/selection/active-line'; import 'codemirror/addon/lint/lint.js'; import 'codemirror/addon/lint/json-lint.js'; import 'codemirror/mode/javascript/javascript'; +import 'codemirror/mode/ruby/ruby'; export default class CodeMirrorModifier extends Modifier { + get autofocus() { + if (Object.hasOwn({ ...this.args.named }, 'autofocus')) { + // spread (...) because proxy, and because Ember over-eagerly prevents named prop lookups for modifier args. + return this.args.named.autofocus; + } else { + return !this.args.named.readOnly; + } + } + didInstall() { this._setup(); } @@ -49,6 +59,10 @@ export default class CodeMirrorModifier extends Modifier { screenReaderLabel: this.args.named.screenReaderLabel || '', }); + if (this.autofocus) { + editor.focus(); + } + editor.on('change', bind(this, this._onChange)); this._editor = editor; diff --git a/ui/app/router.js b/ui/app/router.js index 78236609d..8f47c32af 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -98,6 +98,14 @@ Router.map(function () { path: '/path/*absolutePath', }); }); + + this.route('policies', function () { + this.route('new'); + + this.route('policy', { + path: '/:name', + }); + }); // Mirage-only route for testing OIDC flow if (config['ember-cli-mirage']) { this.route('oidc-mock'); diff --git a/ui/app/routes/policies.js b/ui/app/routes/policies.js new file mode 100644 index 000000000..bcb385e05 --- /dev/null +++ b/ui/app/routes/policies.js @@ -0,0 +1,27 @@ +import Route from '@ember/routing/route'; +import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; +import { inject as service } from '@ember/service'; +import { hash } from 'rsvp'; + +export default class PoliciesRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service can; + @service store; + @service router; + + beforeModel() { + if (this.can.cannot('list policies')) { + this.router.transitionTo('/jobs'); + } + } + + async model() { + return await hash({ + policies: this.store.query('policy', { reload: true }), + tokens: this.store.query('token', { reload: true }), + }); + } +} diff --git a/ui/app/routes/policies/new.js b/ui/app/routes/policies/new.js new file mode 100644 index 000000000..391e1e110 --- /dev/null +++ b/ui/app/routes/policies/new.js @@ -0,0 +1,109 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +const INITIAL_POLICY_RULES = `# See https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-policies for ACL Policy details + +# Example policy structure: + +namespace "default" { + policy = "deny" + capabilities = [] +} + +namespace "example-ns" { + policy = "deny" + capabilities = ["list-jobs", "read-job"] + variables { + # list access to variables in all paths, full access in nested/variables/* + path "*" { + capabilities = ["list"] + } + path "nested/variables/*" { + capabilities = ["write", "read", "destroy", "list"] + } + } +} + +host_volume "example-volume" { + policy = "deny" +} + +agent { + policy = "deny" +} + +node { + policy = "deny" +} + +quota { + policy = "deny" +} + +operator { + policy = "deny" +} + +# Possible Namespace Policies: +# * deny +# * read +# * write +# * scale + +# Possible Namespace Capabilities: +# * list-jobs +# * parse-job +# * read-job +# * submit-job +# * dispatch-job +# * read-logs +# * read-fs +# * alloc-exec +# * alloc-lifecycle +# * csi-write-volume +# * csi-mount-volume +# * list-scaling-policies +# * read-scaling-policy +# * read-job-scaling +# * scale-job + +# Possible Variables capabilities +# * write +# * read +# * destroy +# * list + +# Possible Policies for "agent", "node", "quota", "operator", and "host_volume": +# * deny +# * read +# * write +`; + +export default class PoliciesNewRoute extends Route { + @service can; + @service router; + + beforeModel() { + if (this.can.cannot('write policy')) { + this.router.transitionTo('/policies'); + } + } + + model() { + return this.store.createRecord('policy', { + name: '', + rules: INITIAL_POLICY_RULES, + }); + } + + resetController(controller, isExiting) { + // If the user navigates away from /new, clear the path + controller.set('path', null); + if (isExiting) { + // If user didn't save, delete the freshly created model + if (controller.model.isNew) { + controller.model.destroyRecord(); + } + } + } +} diff --git a/ui/app/routes/policies/policy.js b/ui/app/routes/policies/policy.js new file mode 100644 index 000000000..acd3a2156 --- /dev/null +++ b/ui/app/routes/policies/policy.js @@ -0,0 +1,16 @@ +import Route from '@ember/routing/route'; +import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; +import { inject as service } from '@ember/service'; + +export default class PoliciesPolicyRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service store; + model(params) { + return this.store.findRecord('policy', decodeURIComponent(params.name), { + reload: true, + }); + } +} diff --git a/ui/app/serializers/policy.js b/ui/app/serializers/policy.js index 913dff7f1..e5dab938b 100644 --- a/ui/app/serializers/policy.js +++ b/ui/app/serializers/policy.js @@ -2,9 +2,17 @@ import ApplicationSerializer from './application'; import classic from 'ember-classic-decorator'; @classic -export default class Policy extends ApplicationSerializer { +export default class PolicySerializer extends ApplicationSerializer { + primaryKey = 'Name'; + normalize(typeHash, hash) { hash.ID = hash.Name; return super.normalize(typeHash, hash); } + + serialize(snapshot, options) { + const hash = super.serialize(snapshot, options); + hash.ID = hash.Name; + return hash; + } } diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index b4784bc1c..f6b97c4f6 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -51,3 +51,4 @@ @import './components/services'; @import './components/task-sub-row'; @import './components/authorization'; +@import './components/policies'; diff --git a/ui/app/styles/components/policies.scss b/ui/app/styles/components/policies.scss new file mode 100644 index 000000000..30919b766 --- /dev/null +++ b/ui/app/styles/components/policies.scss @@ -0,0 +1,27 @@ +table.policies { + tr { + cursor: pointer; + + &:hover td { + background-color: #f5f5f5; + } + a { + color: black; + text-decoration: none; + } + .number-expired { + color: $red; + } + } +} + +.edit-policy { + .policy-editor { + max-height: 600px; + overflow: auto; + } + + .input { + margin-bottom: 1rem; + } +} diff --git a/ui/app/styles/core/notifications.scss b/ui/app/styles/core/notifications.scss index d73a8258b..dffd0f106 100644 --- a/ui/app/styles/core/notifications.scss +++ b/ui/app/styles/core/notifications.scss @@ -4,6 +4,7 @@ section.notifications { position: fixed; bottom: 10px; right: 10px; + z-index: 100; .flash-message { width: 300px; diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss index f1a472c6b..3df4982b5 100644 --- a/ui/app/styles/core/table.scss +++ b/ui/app/styles/core/table.scss @@ -7,9 +7,11 @@ border-collapse: separate; width: 100%; - @media #{$mq-table-overflow} { - display: block; - overflow-x: auto; + &:not(.no-mobile-condense) { + @media #{$mq-table-overflow} { + display: block; + overflow-x: auto; + } } &.is-fixed { diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index 097912f64..aa44dd5b4 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -114,7 +114,7 @@ {{#if this.system.agent.version}} diff --git a/ui/app/templates/policies.hbs b/ui/app/templates/policies.hbs new file mode 100644 index 000000000..60bf3d28b --- /dev/null +++ b/ui/app/templates/policies.hbs @@ -0,0 +1,4 @@ + + + {{outlet}} + \ No newline at end of file diff --git a/ui/app/templates/policies/index.hbs b/ui/app/templates/policies/index.hbs new file mode 100644 index 000000000..116dde74f --- /dev/null +++ b/ui/app/templates/policies/index.hbs @@ -0,0 +1,67 @@ +{{page-title "Policies"}} +
+
+
+
+ {{#if (can "write policy")}} + + Create Policy + + {{else}} + + {{/if}} + +
+
+
+ {{#if this.policies.length}} + + + Policy Name + Tokens + + + + + {{row.model.name}} + + + + {{row.model.tokens.length}} + {{#if (filter-by "isExpired" row.model.tokens)}} + ({{get (filter-by "isExpired" row.model.tokens) "length"}} expired) + {{/if}} + + + + + + {{else}} +
+

+ No Policies +

+

+ Get started by creating a new policy +

+
+ {{/if}} +
diff --git a/ui/app/templates/policies/new.hbs b/ui/app/templates/policies/new.hbs new file mode 100644 index 000000000..7de586acb --- /dev/null +++ b/ui/app/templates/policies/new.hbs @@ -0,0 +1,10 @@ + +{{page-title "Create Policy"}} +
+

+ Create Policy +

+ +
diff --git a/ui/app/templates/policies/policy.hbs b/ui/app/templates/policies/policy.hbs new file mode 100644 index 000000000..ea81ca92a --- /dev/null +++ b/ui/app/templates/policies/policy.hbs @@ -0,0 +1,29 @@ + + +{{page-title "Policy"}} +
+

+
+ {{this.model.name}} +
+ {{#if (can "destroy policy")}} +
+ +
+ {{/if}} +

+ +
+{{outlet}} \ No newline at end of file diff --git a/ui/ember-cli-build.js b/ui/ember-cli-build.js index 5f2be0d7d..430d80cfd 100644 --- a/ui/ember-cli-build.js +++ b/ui/ember-cli-build.js @@ -17,7 +17,7 @@ module.exports = function (defaults) { }, }, codemirror: { - modes: ['javascript'], + modes: ['javascript', 'ruby'], }, babel: { include: ['proposal-optional-chaining'], diff --git a/ui/mirage/config.js b/ui/mirage/config.js index dc844835b..a1d3def58 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -443,8 +443,7 @@ export default function () { return JSON.stringify(findLeader(schema)); }); - // Note: Mirage-only route, for UI testing and not part of the Nomad API - this.get('/acl/tokens', function ({ tokens }, req) { + this.get('/acl/tokens', function ({tokens}, req) { return this.serialize(tokens.all()); }); @@ -499,7 +498,7 @@ export default function () { ); this.get('/acl/policy/:id', function ({ policies, tokens }, req) { - const policy = policies.find(req.params.id); + const policy = policies.findBy({ name: req.params.id }); const secret = req.requestHeaders['X-Nomad-Token']; const tokenForSecret = tokens.findBy({ secretId: secret }); @@ -526,6 +525,33 @@ export default function () { return new Response(403, {}, null); }); + this.get('/acl/policies', function ({ policies }, req) { + return this.serialize(policies.all()); + }); + + this.delete('/acl/policy/:id', function (schema, request) { + const { id } = request.params; + schema.tokens.all().models.filter(token => token.policyIds.includes(id)).forEach(token => { + token.update({ policyIds: token.policyIds.filter(pid => pid !== id) }); + }); + server.db.policies.remove(id); + return ''; + }); + + this.put('/acl/policy/:id', function (schema, request) { + return new Response(200, {}, {}); + }); + + this.post('/acl/policy/:id', function (schema, request) { + const { Name, Description, Rules } = JSON.parse(request.requestBody); + return server.create('policy', { + name: Name, + description: Description, + rules: Rules, + }); + + }); + this.get('/regions', function ({ regions }) { return this.serialize(regions.all()); }); diff --git a/ui/mirage/factories/policy.js b/ui/mirage/factories/policy.js index c01062a3b..dd62779c6 100644 --- a/ui/mirage/factories/policy.js +++ b/ui/mirage/factories/policy.js @@ -7,8 +7,7 @@ export default Factory.extend({ return this.id; }, description: () => (faker.random.number(10) >= 2 ? faker.lorem.sentence() : null), - rules: ` -# Allow read only access to the default namespace + rules: `# Allow read only access to the default namespace namespace "default" { policy = "read" } diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 5243bdc76..75b8e6cf7 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -19,6 +19,7 @@ export const allScenarios = { emptyCluster, variableTestCluster, servicesTestCluster, + policiesTestCluster, ...topoScenarios, ...sysbatchScenarios, }; @@ -259,6 +260,13 @@ function variableTestCluster(server) { }); } +function policiesTestCluster(server) { + faker.seed(1); + createTokens(server); + server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); +} + + function servicesTestCluster(server) { faker.seed(1); server.create('feature', { name: 'Dynamic Application Sizing' }); diff --git a/ui/tests/acceptance/policies-test.js b/ui/tests/acceptance/policies-test.js new file mode 100644 index 000000000..ed09d5645 --- /dev/null +++ b/ui/tests/acceptance/policies-test.js @@ -0,0 +1,102 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, click, typeIn, findAll } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import { allScenarios } from '../../mirage/scenarios/default'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import percySnapshot from '@percy/ember'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; + +module('Acceptance | policies', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + test('Policies index route looks good', async function (assert) { + assert.expect(4); + allScenarios.policiesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + assert.dom('[data-test-gutter-link="policies"]').exists(); + assert.equal(currentURL(), '/policies'); + assert + .dom('[data-test-policy-row]') + .exists({ count: server.db.policies.length }); + await a11yAudit(assert); + await percySnapshot(assert); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Prevents policies access if you lack a management token', async function (assert) { + allScenarios.policiesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[1].secretId; + await visit('/policies'); + assert.equal(currentURL(), '/jobs'); + assert.dom('[data-test-gutter-link="policies"]').doesNotExist(); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Modifying an existing policy', async function (assert) { + allScenarios.policiesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + await click('[data-test-policy-row]:first-child'); + assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`); + assert.dom('[data-test-policy-editor]').exists(); + assert.dom('[data-test-title]').includesText(server.db.policies[0].name); + await click('button[type="submit"]'); + assert.dom('.flash-message.alert-success').exists(); + assert.equal(currentURL(), '/policies'); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Creating a new policy', async function (assert) { + assert.expect(7); + allScenarios.policiesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + await click('[data-test-create-policy]'); + assert.equal(currentURL(), '/policies/new'); + await typeIn('[data-test-policy-name-input]', 'My Fun Policy'); + await click('button[type="submit"]'); + assert + .dom('.flash-message.alert-error') + .exists('Doesnt let you save a bad name'); + assert.equal(currentURL(), '/policies/new'); + document.querySelector('[data-test-policy-name-input]').value = ''; // clear + await typeIn('[data-test-policy-name-input]', 'My-Fun-Policy'); + await click('button[type="submit"]'); + assert.dom('.flash-message.alert-success').exists(); + assert.equal(currentURL(), '/policies'); + const newPolicy = [...findAll('[data-test-policy-name]')].filter((a) => + a.textContent.includes('My-Fun-Policy') + )[0]; + assert.ok(newPolicy, 'Policy is in the list'); + await click(newPolicy); + assert.equal(currentURL(), '/policies/My-Fun-Policy'); + await percySnapshot(assert); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Deleting a policy', async function (assert) { + allScenarios.policiesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + const firstPolicyName = server.db.policies[0].name; + const firstPolicyRow = [...findAll('[data-test-policy-name]')].filter( + (row) => row.textContent.includes(firstPolicyName) + )[0]; + await click(firstPolicyRow); + assert.equal(currentURL(), `/policies/${firstPolicyName}`); + await click('[data-test-delete-button] button'); + assert.dom('[data-test-confirm-button]').exists(); + await click('[data-test-confirm-button]'); + assert.dom('.flash-message.alert-success').exists(); + assert.equal(currentURL(), '/policies'); + assert.dom(`[data-test-policy-name="${firstPolicyName}"]`).doesNotExist(); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); +}); diff --git a/ui/tests/integration/components/policy-editor-test.js b/ui/tests/integration/components/policy-editor-test.js new file mode 100644 index 000000000..6e35bc4a7 --- /dev/null +++ b/ui/tests/integration/components/policy-editor-test.js @@ -0,0 +1,35 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; + +module('Integration | Component | policy-editor', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + assert.expect(1); + await render(hbs``); + await componentA11yAudit(this.element, assert); + }); + + test('Only has editable name if new', async function (assert) { + const newMockPolicy = { + isNew: true, + name: 'New Policy', + }; + + const oldMockPolicy = { + isNew: false, + name: 'Old Policy', + }; + + this.set('newMockPolicy', newMockPolicy); + this.set('oldMockPolicy', oldMockPolicy); + + await render(hbs``); + assert.dom('[data-test-policy-name-input]').exists(); + await render(hbs``); + assert.dom('[data-test-policy-name-input]').doesNotExist(); + }); +});