From fcd9b9f13b6c078a12c5a750a864c51034b15e82 Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Mon, 11 Jul 2022 12:33:17 -0400 Subject: [PATCH] [bugfix, ui] Allow running jobs from a namespace-limited token (#13659) * Allow running jobs from a namespace-limited token * qpNamespace cleanup * Looks like parse can deal with a * namespace * A little diff cleanup * Defensive destructuring * Removing accidental friendly-fire on can-scale * Testfix: Job run buttons from jobs index * Testfix: activeRegion job adapter string * Testfix: unit tests for job abilities correctly reflect the any-namespace rule * Testfix: job editor test looks for requests with namespace applied on plan --- ui/app/abilities/job.js | 29 ++++- ui/app/adapters/job.js | 4 +- ui/app/components/job-editor.js | 8 +- ui/tests/acceptance/jobs-list-test.js | 105 ++++++++++++------ .../integration/components/job-editor-test.js | 48 ++++---- ui/tests/unit/abilities/job-test.js | 31 +++--- ui/tests/unit/adapters/job-test.js | 66 +++++------ 7 files changed, 174 insertions(+), 117 deletions(-) diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js index 02e175cb42b..ae2b9e3dee6 100644 --- a/ui/app/abilities/job.js +++ b/ui/app/abilities/job.js @@ -1,5 +1,5 @@ import AbstractAbility from './abstract'; -import { computed } from '@ember/object'; +import { computed, get } from '@ember/object'; import { or } from '@ember/object/computed'; export default class Job extends AbstractAbility { @@ -9,7 +9,7 @@ export default class Job extends AbstractAbility { @or( 'bypassAuthorization', 'selfTokenIsManagement', - 'policiesSupportRunning', + 'specificNamespaceSupportsRunning', 'policiesSupportScaling' ) canScale; @@ -23,8 +23,31 @@ export default class Job extends AbstractAbility { @or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportDispatching') canDispatch; - @computed('rulesForNamespace.@each.capabilities') + policyNamespacesIncludePermissions(policies = [], permissions = []) { + // For each policy record, extract all policies of all namespaces + const allNamespacePolicies = policies + .toArray() + .map((policy) => get(policy, 'rulesJSON.Namespaces')) + .flat() + .map((namespace = {}) => { + return namespace.Capabilities; + }) + .flat() + .compact(); + + // Check for requested permissions + return allNamespacePolicies.some((policy) => { + return permissions.includes(policy); + }); + } + + @computed('token.selfTokenPolicies.[]') get policiesSupportRunning() { + return this.policyNamespacesIncludePermissions(this.token.selfTokenPolicies, ['submit-job']); + } + + @computed('rulesForNamespace.@each.capabilities') + get specificNamespaceSupportsRunning() { return this.namespaceIncludesCapability('submit-job'); } diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 7ff6027ff8d..c3668116f33 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -25,7 +25,7 @@ export default class JobAdapter extends WatchableNamespaceIDs { } parse(spec) { - const url = addToPath(this.urlForFindAll('job'), '/parse'); + const url = addToPath(this.urlForFindAll('job'), '/parse?namespace=*'); return this.ajax(url, 'POST', { data: { JobHCL: spec, @@ -44,7 +44,7 @@ export default class JobAdapter extends WatchableNamespaceIDs { Job: job.get('_newDefinitionJSON'), Diff: true, }, - }).then(json => { + }).then((json) => { json.ID = jobId; store.pushPayload('job-plan', { jobPlans: [json] }); return store.peekRecord('job-plan', jobId); diff --git a/ui/app/components/job-editor.js b/ui/app/components/job-editor.js index bfa8bba3ee2..6aab15fd774 100644 --- a/ui/app/components/job-editor.js +++ b/ui/app/components/job-editor.js @@ -45,13 +45,13 @@ export default class JobEditor extends Component { return this.planOutput ? 'plan' : 'editor'; } - @(task(function*() { + @(task(function* () { this.reset(); try { yield this.job.parse(); } catch (err) { - const error = messageFromAdapterError(err) || 'Could not parse input'; + const error = messageFromAdapterError(err, 'parse jobs') || 'Could not parse input'; this.set('parseError', error); this.scrollToError(); return; @@ -61,14 +61,14 @@ export default class JobEditor extends Component { const plan = yield this.job.plan(); this.set('planOutput', plan); } catch (err) { - const error = messageFromAdapterError(err) || 'Could not plan job'; + const error = messageFromAdapterError(err, 'plan jobs') || 'Could not plan job'; this.set('planError', error); this.scrollToError(); } }).drop()) plan; - @task(function*() { + @task(function* () { try { if (this.context === 'new') { yield this.job.run(); diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 6a0038303f4..66f3485959e 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -9,11 +9,11 @@ import Layout from 'nomad-ui/tests/pages/layout'; let managementToken, clientToken; -module('Acceptance | jobs list', function(hooks) { +module('Acceptance | jobs list', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); - hooks.beforeEach(function() { + hooks.beforeEach(function () { // Required for placing allocations (a result of creating jobs) server.create('node'); @@ -24,19 +24,19 @@ module('Acceptance | jobs list', function(hooks) { window.localStorage.nomadTokenSecret = managementToken.secretId; }); - test('it passes an accessibility audit', async function(assert) { + test('it passes an accessibility audit', async function (assert) { await JobsList.visit(); await a11yAudit(assert); }); - test('visiting /jobs', async function(assert) { + test('visiting /jobs', async function (assert) { await JobsList.visit(); assert.equal(currentURL(), '/jobs'); assert.equal(document.title, 'Jobs - Nomad'); }); - test('/jobs should list the first page of jobs sorted by modify index', async function(assert) { + test('/jobs should list the first page of jobs sorted by modify index', async function (assert) { const jobsCount = JobsList.pageSize + 1; server.createList('job', jobsCount, { createAllocations: false }); @@ -49,7 +49,7 @@ module('Acceptance | jobs list', function(hooks) { }); }); - test('each job row should contain information about the job', async function(assert) { + test('each job row should contain information about the job', async function (assert) { server.createList('job', 2); const job = server.db.jobs.sortBy('modifyIndex').reverse()[0]; const taskGroups = server.db.taskGroups.where({ jobId: job.id }); @@ -67,7 +67,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(jobRow.taskGroups, taskGroups.length, '# Groups'); }); - test('each job row should link to the corresponding job', async function(assert) { + test('each job row should link to the corresponding job', async function (assert) { server.create('job'); const job = server.db.jobs[0]; @@ -77,14 +77,14 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(currentURL(), `/jobs/${job.id}`); }); - test('the new job button transitions to the new job page', async function(assert) { + test('the new job button transitions to the new job page', async function (assert) { await JobsList.visit(); await JobsList.runJobButton.click(); assert.equal(currentURL(), '/jobs/run'); }); - test('the job run button is disabled when the token lacks permission', async function(assert) { + test('the job run button is disabled when the token lacks permission', async function (assert) { window.localStorage.nomadTokenSecret = clientToken.secretId; await JobsList.visit(); @@ -94,7 +94,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(currentURL(), '/jobs'); }); - test('the anonymous policy is fetched to check whether to show the job run button', async function(assert) { + test('the anonymous policy is fetched to check whether to show the job run button', async function (assert) { window.localStorage.removeItem('nomadTokenSecret'); server.create('policy', { @@ -114,14 +114,14 @@ module('Acceptance | jobs list', function(hooks) { assert.notOk(JobsList.runJobButton.isDisabled); }); - test('when there are no jobs, there is an empty message', async function(assert) { + test('when there are no jobs, there is an empty message', async function (assert) { await JobsList.visit(); assert.ok(JobsList.isEmpty, 'There is an empty message'); assert.equal(JobsList.emptyState.headline, 'No Jobs', 'The message is appropriate'); }); - test('when there are jobs, but no matches for a search result, there is an empty message', async function(assert) { + test('when there are jobs, but no matches for a search result, there is an empty message', async function (assert) { server.create('job', { name: 'cat 1' }); server.create('job', { name: 'cat 2' }); @@ -132,7 +132,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(JobsList.emptyState.headline, 'No Matches', 'The message is appropriate'); }); - test('searching resets the current page', async function(assert) { + test('searching resets the current page', async function (assert) { server.createList('job', JobsList.pageSize + 1, { createAllocations: false }); await JobsList.visit(); @@ -145,7 +145,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(currentURL(), '/jobs?search=foobar', 'No page query param'); }); - test('when a cluster has namespaces, each job row includes the job namespace', async function(assert) { + test('when a cluster has namespaces, each job row includes the job namespace', async function (assert) { server.createList('namespace', 2); server.createList('job', 2); const job = server.db.jobs.sortBy('modifyIndex').reverse()[0]; @@ -156,7 +156,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(jobRow.namespace, job.namespaceId); }); - test('when the namespace query param is set, only matching jobs are shown', async function(assert) { + test('when the namespace query param is set, only matching jobs are shown', async function (assert) { server.createList('namespace', 2); const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id }); const job2 = server.create('job', { namespaceId: server.db.namespaces[1].id }); @@ -176,7 +176,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(JobsList.jobs.objectAt(0).name, job2.name, 'The correct job is shown'); }); - test('when accessing jobs is forbidden, show a message with a link to the tokens page', async function(assert) { + test('when accessing jobs is forbidden, show a message with a link to the tokens page', async function (assert) { server.pretender.get('/v1/jobs', () => [403, {}, null]); await JobsList.visit(); @@ -190,7 +190,7 @@ module('Acceptance | jobs list', function(hooks) { return job.periodic ? 'periodic' : job.parameterized ? 'parameterized' : job.type; } - test('the jobs list page has appropriate faceted search options', async function(assert) { + test('the jobs list page has appropriate faceted search options', async function (assert) { await JobsList.visit(); assert.ok(JobsList.facets.namespace.isHidden, 'Namespace facet not found (no namespaces)'); @@ -300,7 +300,7 @@ module('Acceptance | jobs list', function(hooks) { server.create('job', { datacenters: ['pdx'], createAllocations: false, childrenCount: 0 }); await JobsList.visit(); }, - filter: (job, selection) => job.datacenters.find(dc => selection.includes(dc)), + filter: (job, selection) => job.datacenters.find((dc) => selection.includes(dc)), }); testFacet('Prefix', { @@ -318,15 +318,15 @@ module('Acceptance | jobs list', function(hooks) { 'hashi-three', 'nmd_two', 'noprefix', - ].forEach(name => { + ].forEach((name) => { server.create('job', { name, createAllocations: false, childrenCount: 0 }); }); await JobsList.visit(); }, - filter: (job, selection) => selection.find(prefix => job.name.startsWith(prefix)), + filter: (job, selection) => selection.find((prefix) => job.name.startsWith(prefix)), }); - test('when the facet selections result in no matches, the empty state states why', async function(assert) { + test('when the facet selections result in no matches, the empty state states why', async function (assert) { server.createList('job', 2, { status: 'pending', createAllocations: false, childrenCount: 0 }); await JobsList.visit(); @@ -337,7 +337,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(JobsList.emptyState.headline, 'No Matches', 'The message is appropriate'); }); - test('the jobs list is immediately filtered based on query params', async function(assert) { + test('the jobs list is immediately filtered based on query params', async function (assert) { server.create('job', { type: 'batch', createAllocations: false }); server.create('job', { type: 'service', createAllocations: false }); @@ -346,7 +346,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(JobsList.jobs.length, 1, 'Only one job shown due to query param'); }); - test('the active namespace is carried over to the storage pages', async function(assert) { + test('the active namespace is carried over to the storage pages', async function (assert) { server.createList('namespace', 2); const namespace = server.db.namespaces[1]; @@ -359,7 +359,7 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(currentURL(), `/csi/volumes?namespace=${namespace.id}`); }); - test('when the user has a client token that has a namespace with a policy to run a job', async function(assert) { + test('when the user has a client token that has a namespace with a policy to run a job', async function (assert) { const READ_AND_WRITE_NAMESPACE = 'read-and-write-namespace'; const READ_ONLY_NAMESPACE = 'read-only-namespace'; @@ -391,6 +391,37 @@ module('Acceptance | jobs list', function(hooks) { await JobsList.visit({ namespace: READ_AND_WRITE_NAMESPACE }); assert.notOk(JobsList.runJobButton.isDisabled); + await JobsList.visit({ namespace: READ_ONLY_NAMESPACE }); + assert.notOk(JobsList.runJobButton.isDisabled); + }); + + test('when the user has no client tokens that allow them to run a job', async function (assert) { + const READ_AND_WRITE_NAMESPACE = 'read-and-write-namespace'; + const READ_ONLY_NAMESPACE = 'read-only-namespace'; + + server.create('namespace', { id: READ_ONLY_NAMESPACE }); + + const policy = server.create('policy', { + id: 'something', + name: 'something', + rulesJSON: { + Namespaces: [ + { + Name: READ_ONLY_NAMESPACE, + Capabilities: ['list-job'], + }, + ], + }, + }); + + clientToken.policyIds = [policy.id]; + clientToken.save(); + + window.localStorage.nomadTokenSecret = clientToken.secretId; + + await JobsList.visit({ namespace: READ_AND_WRITE_NAMESPACE }); + assert.ok(JobsList.runJobButton.isDisabled); + await JobsList.visit({ namespace: READ_ONLY_NAMESPACE }); assert.ok(JobsList.runJobButton.isDisabled); }); @@ -417,7 +448,7 @@ module('Acceptance | jobs list', function(hooks) { } assert.deepEqual( - facet.options.map(option => option.label.trim()), + facet.options.map((option) => option.label.trim()), expectation, 'Options for facet are as expected' ); @@ -427,11 +458,11 @@ module('Acceptance | jobs list', function(hooks) { label, { facet, paramName, beforeEach, filter, expectedOptions, optionToSelect } ) { - test(`the ${label} facet has the correct options`, async function(assert) { + test(`the ${label} facet has the correct options`, async function (assert) { await facetOptions(assert, beforeEach, facet, expectedOptions); }); - test(`the ${label} facet filters the jobs list by ${label}`, async function(assert) { + test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) { await beforeEach(); await facet.toggle(); @@ -440,7 +471,7 @@ module('Acceptance | jobs list', function(hooks) { await option.select(); const expectedJobs = server.db.jobs - .filter(job => filter(job, selection)) + .filter((job) => filter(job, selection)) .sortBy('modifyIndex') .reverse(); @@ -453,7 +484,7 @@ module('Acceptance | jobs list', function(hooks) { }); }); - test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function(assert) { + test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function (assert) { await beforeEach(); await facet.toggle(); @@ -469,11 +500,11 @@ module('Acceptance | jobs list', function(hooks) { } function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) { - test(`the ${label} facet has the correct options`, async function(assert) { + test(`the ${label} facet has the correct options`, async function (assert) { await facetOptions(assert, beforeEach, facet, expectedOptions); }); - test(`the ${label} facet filters the jobs list by ${label}`, async function(assert) { + test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) { let option; await beforeEach(); @@ -484,7 +515,7 @@ module('Acceptance | jobs list', function(hooks) { const selection = [option.key]; const expectedJobs = server.db.jobs - .filter(job => filter(job, selection)) + .filter((job) => filter(job, selection)) .sortBy('modifyIndex') .reverse(); @@ -497,7 +528,7 @@ module('Acceptance | jobs list', function(hooks) { }); }); - test(`selecting multiple options in the ${label} facet results in a broader search`, async function(assert) { + test(`selecting multiple options in the ${label} facet results in a broader search`, async function (assert) { const selection = []; await beforeEach(); @@ -511,7 +542,7 @@ module('Acceptance | jobs list', function(hooks) { selection.push(option2.key); const expectedJobs = server.db.jobs - .filter(job => filter(job, selection)) + .filter((job) => filter(job, selection)) .sortBy('modifyIndex') .reverse(); @@ -524,7 +555,7 @@ module('Acceptance | jobs list', function(hooks) { }); }); - test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function(assert) { + test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) { const selection = []; await beforeEach(); @@ -543,8 +574,8 @@ module('Acceptance | jobs list', function(hooks) { ); }); - test('the run job button works when filters are set', async function(assert) { - ['pre-one', 'pre-two', 'pre-three'].forEach(name => { + test('the run job button works when filters are set', async function (assert) { + ['pre-one', 'pre-two', 'pre-three'].forEach((name) => { server.create('job', { name, createAllocations: false, childrenCount: 0 }); }); diff --git a/ui/tests/integration/components/job-editor-test.js b/ui/tests/integration/components/job-editor-test.js index d3818ca4b9c..3daa7dd8b02 100644 --- a/ui/tests/integration/components/job-editor-test.js +++ b/ui/tests/integration/components/job-editor-test.js @@ -12,11 +12,11 @@ import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; const Editor = create(jobEditor()); -module('Integration | Component | job-editor', function(hooks) { +module('Integration | Component | job-editor', function (hooks) { setupRenderingTest(hooks); setupCodeMirror(hooks); - hooks.beforeEach(async function() { + hooks.beforeEach(async function () { window.localStorage.clear(); fragmentSerializerInitializer(this.owner); @@ -28,13 +28,13 @@ module('Integration | Component | job-editor', function(hooks) { this.server.create('node'); }); - hooks.afterEach(async function() { + hooks.afterEach(async function () { this.server.shutdown(); }); const newJobName = 'new-job'; const newJobTaskGroupName = 'redis'; - const jsonJob = overrides => { + const jsonJob = (overrides) => { return JSON.stringify( assign( {}, @@ -99,12 +99,12 @@ module('Integration | Component | job-editor', function(hooks) { await component.render(cancelableTemplate); }; - const planJob = async spec => { + const planJob = async (spec) => { await Editor.editor.fillIn(spec); await Editor.plan(); }; - test('the default state is an editor with an explanation popup', async function(assert) { + test('the default state is an editor with an explanation popup', async function (assert) { const job = await this.store.createRecord('job'); await renderNewJob(this, job); @@ -114,7 +114,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('the explanation popup can be dismissed', async function(assert) { + test('the explanation popup can be dismissed', async function (assert) { const job = await this.store.createRecord('job'); await renderNewJob(this, job); @@ -127,7 +127,7 @@ module('Integration | Component | job-editor', function(hooks) { ); }); - test('the explanation popup is not shown once the dismissal state is set in localStorage', async function(assert) { + test('the explanation popup is not shown once the dismissal state is set in localStorage', async function (assert) { window.localStorage.nomadMessageJobEditor = 'false'; const job = await this.store.createRecord('job'); @@ -136,7 +136,7 @@ module('Integration | Component | job-editor', function(hooks) { assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone'); }); - test('submitting a json job skips the parse endpoint', async function(assert) { + test('submitting a json job skips the parse endpoint', async function (assert) { const spec = jsonJob(); const job = await this.store.createRecord('job'); @@ -147,14 +147,14 @@ module('Integration | Component | job-editor', function(hooks) { assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'JSON job spec is still planned'); }); - test('submitting an hcl job requires the parse endpoint', async function(assert) { + test('submitting an hcl job requires the parse endpoint', async function (assert) { const spec = hclJob(); const job = await this.store.createRecord('job'); await renderNewJob(this, job); await planJob(spec); const requests = this.server.pretender.handledRequests.mapBy('url'); - assert.ok(requests.includes('/v1/jobs/parse'), 'HCL job spec is parsed first'); + assert.ok(requests.includes('/v1/jobs/parse?namespace=*'), 'HCL job spec is parsed first'); assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'HCL job spec is planned'); assert.ok( requests.indexOf('/v1/jobs/parse') < requests.indexOf(`/v1/job/${newJobName}/plan`), @@ -162,7 +162,7 @@ module('Integration | Component | job-editor', function(hooks) { ); }); - test('when a job is successfully parsed and planned, the plan is shown to the user', async function(assert) { + test('when a job is successfully parsed and planned, the plan is shown to the user', async function (assert) { const spec = hclJob(); const job = await this.store.createRecord('job'); @@ -175,7 +175,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('from the plan screen, the cancel button goes back to the editor with the job still in tact', async function(assert) { + test('from the plan screen, the cancel button goes back to the editor with the job still in tact', async function (assert) { const spec = hclJob(); const job = await this.store.createRecord('job'); @@ -186,7 +186,7 @@ module('Integration | Component | job-editor', function(hooks) { assert.equal(Editor.editor.contents, spec, 'The spec that was planned is still in the editor'); }); - test('when parse fails, the parse error message is shown', async function(assert) { + test('when parse fails, the parse error message is shown', async function (assert) { const spec = hclJob(); const errorMessage = 'Parse Failed!! :o'; const job = await this.store.createRecord('job'); @@ -208,7 +208,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('when plan fails, the plan error message is shown', async function(assert) { + test('when plan fails, the plan error message is shown', async function (assert) { const spec = hclJob(); const errorMessage = 'Plan Failed!! :o'; const job = await this.store.createRecord('job'); @@ -230,7 +230,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('when run fails, the run error message is shown', async function(assert) { + test('when run fails, the run error message is shown', async function (assert) { const spec = hclJob(); const errorMessage = 'Run Failed!! :o'; const job = await this.store.createRecord('job'); @@ -253,7 +253,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('when the scheduler dry-run has warnings, the warnings are shown to the user', async function(assert) { + test('when the scheduler dry-run has warnings, the warnings are shown to the user', async function (assert) { const spec = jsonJob({ Unschedulable: true }); const job = await this.store.createRecord('job'); @@ -275,7 +275,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('when the scheduler dry-run has no warnings, a success message is shown to the user', async function(assert) { + test('when the scheduler dry-run has no warnings, a success message is shown to the user', async function (assert) { const spec = hclJob(); const job = await this.store.createRecord('job'); @@ -293,7 +293,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('when a job is submitted in the edit context, a POST request is made to the update job endpoint', async function(assert) { + test('when a job is submitted in the edit context, a POST request is made to the update job endpoint', async function (assert) { const spec = hclJob(); const job = await this.store.createRecord('job'); @@ -305,7 +305,7 @@ module('Integration | Component | job-editor', function(hooks) { assert.notOk(requests.includes('/v1/jobs'), 'A request was not made to job create'); }); - test('when a job is submitted in the new context, a POST request is made to the create job endpoint', async function(assert) { + test('when a job is submitted in the new context, a POST request is made to the create job endpoint', async function (assert) { const spec = hclJob(); const job = await this.store.createRecord('job'); @@ -320,7 +320,7 @@ module('Integration | Component | job-editor', function(hooks) { ); }); - test('when a job is successfully submitted, the onSubmit hook is called', async function(assert) { + test('when a job is successfully submitted, the onSubmit hook is called', async function (assert) { const spec = hclJob(); const job = await this.store.createRecord('job'); @@ -333,14 +333,14 @@ module('Integration | Component | job-editor', function(hooks) { ); }); - test('when the job-editor cancelable flag is false, there is no cancel button in the header', async function(assert) { + test('when the job-editor cancelable flag is false, there is no cancel button in the header', async function (assert) { const job = await this.store.createRecord('job'); await renderNewJob(this, job); assert.notOk(Editor.cancelEditingIsAvailable, 'No way to cancel editing'); }); - test('when the job-editor cancelable flag is true, there is a cancel button in the header', async function(assert) { + test('when the job-editor cancelable flag is true, there is a cancel button in the header', async function (assert) { const job = await this.store.createRecord('job'); await renderEditJob(this, job); @@ -349,7 +349,7 @@ module('Integration | Component | job-editor', function(hooks) { await componentA11yAudit(this.element, assert); }); - test('when the job-editor cancel button is clicked, the onCancel hook is called', async function(assert) { + test('when the job-editor cancel button is clicked, the onCancel hook is called', async function (assert) { const job = await this.store.createRecord('job'); await renderEditJob(this, job); diff --git a/ui/tests/unit/abilities/job-test.js b/ui/tests/unit/abilities/job-test.js index 8cd2b0ac1e7..08d880322de 100644 --- a/ui/tests/unit/abilities/job-test.js +++ b/ui/tests/unit/abilities/job-test.js @@ -4,11 +4,11 @@ import { setupTest } from 'ember-qunit'; import Service from '@ember/service'; import setupAbility from 'nomad-ui/tests/helpers/setup-ability'; -module('Unit | Ability | job', function(hooks) { +module('Unit | Ability | job', function (hooks) { setupTest(hooks); setupAbility('job')(hooks); - test('it permits job run when ACLs are disabled', function(assert) { + test('it permits job run when ACLs are disabled', function (assert) { const mockToken = Service.extend({ aclEnabled: false, }); @@ -18,7 +18,7 @@ module('Unit | Ability | job', function(hooks) { assert.ok(this.ability.canRun); }); - test('it permits job run for management tokens', function(assert) { + test('it permits job run for management tokens', function (assert) { const mockToken = Service.extend({ aclEnabled: true, selfToken: { type: 'management' }, @@ -29,7 +29,7 @@ module('Unit | Ability | job', function(hooks) { assert.ok(this.ability.canRun); }); - test('it permits job run for client tokens with a policy that has namespace submit-job', function(assert) { + test('it permits job run for client tokens with a policy that has namespace submit-job', function (assert) { const mockSystem = Service.extend({ aclEnabled: true, }); @@ -57,7 +57,7 @@ module('Unit | Ability | job', function(hooks) { assert.ok(this.can.can('run job', null, { namespace: 'aNamespace' })); }); - test('it permits job run for client tokens with a policy that has default namespace submit-job and no capabilities for active namespace', function(assert) { + test('it permits job run for client tokens with a policy that has default namespace submit-job and no capabilities for active namespace', function (assert) { const mockSystem = Service.extend({ aclEnabled: true, }); @@ -89,7 +89,7 @@ module('Unit | Ability | job', function(hooks) { assert.ok(this.can.can('run job', null, { namespace: 'anotherNamespace' })); }); - test('it blocks job run for client tokens with a policy that has no submit-job capability', function(assert) { + test('it blocks job run for client tokens with a policy that has no submit-job capability', function (assert) { const mockSystem = Service.extend({ aclEnabled: true, }); @@ -117,7 +117,7 @@ module('Unit | Ability | job', function(hooks) { assert.ok(this.can.cannot('run job', null, { namespace: 'aNamespace' })); }); - test('job scale requires a client token with the submit-job or scale-job capability', function(assert) { + test('job scale requires a client token with the submit-job or scale-job capability', function (assert) { const makePolicies = (namespace, ...capabilities) => [ { rulesJSON: { @@ -157,7 +157,7 @@ module('Unit | Ability | job', function(hooks) { assert.ok(this.can.cannot('scale job', null, { namespace: 'aNamespace' })); }); - test('job dispatch requires a client token with the dispatch-job capability', function(assert) { + test('job dispatch requires a client token with the dispatch-job capability', function (assert) { const makePolicies = (namespace, ...capabilities) => [ { rulesJSON: { @@ -191,7 +191,7 @@ module('Unit | Ability | job', function(hooks) { assert.ok(this.can.can('dispatch job', null, { namespace: 'aNamespace' })); }); - test('it handles globs in namespace names', function(assert) { + test('it handles globs in namespace names', function (assert) { const mockSystem = Service.extend({ aclEnabled: true, }); @@ -236,14 +236,17 @@ module('Unit | Ability | job', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - assert.ok(this.can.cannot('run job', null, { namespace: 'production-web' })); + assert.ok( + this.can.can( + 'run job', + null, + { namespace: 'production-web' }, + 'The existence of a single namespace where a job can be run means that can run is enabled' + ) + ); assert.ok(this.can.can('run job', null, { namespace: 'production-api' })); assert.ok(this.can.can('run job', null, { namespace: 'production-other' })); assert.ok(this.can.can('run job', null, { namespace: 'something-suffixed' })); - assert.ok( - this.can.cannot('run job', null, { namespace: 'something-more-suffixed' }), - 'expected the namespace with the greatest number of matched characters to be chosen' - ); assert.ok( this.can.can('run job', null, { namespace: '000-abc-999' }), 'expected to be able to match against more than one wildcard' diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index e6d283bb9c5..657832984c3 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -8,10 +8,10 @@ import { AbortController } from 'fetch'; import { TextEncoderLite } from 'text-encoder-lite'; import base64js from 'base64-js'; -module('Unit | Adapter | Job', function(hooks) { +module('Unit | Adapter | Job', function (hooks) { setupTest(hooks); - hooks.beforeEach(async function() { + hooks.beforeEach(async function () { this.store = this.owner.lookup('service:store'); this.subject = () => this.store.adapterFor('job'); @@ -59,11 +59,11 @@ module('Unit | Adapter | Job', function(hooks) { }; }); - hooks.afterEach(function() { + hooks.afterEach(function () { this.server.shutdown(); }); - test('The job endpoint is the only required endpoint for fetching a job', async function(assert) { + test('The job endpoint is the only required endpoint for fetching a job', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -81,7 +81,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('When a namespace is set in localStorage but a job in the default namespace is requested, the namespace query param is not present', async function(assert) { + test('When a namespace is set in localStorage but a job in the default namespace is requested, the namespace query param is not present', async function (assert) { await this.initializeUI({ namespace: 'some-namespace' }); const { pretender } = this.server; @@ -99,7 +99,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('When a namespace is in localStorage and the requested job is in the default namespace, the namespace query param is left out', async function(assert) { + test('When a namespace is in localStorage and the requested job is in the default namespace, the namespace query param is left out', async function (assert) { await this.initializeUI({ namespace: 'red-herring' }); const { pretender } = this.server; @@ -117,7 +117,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('When the job has a namespace other than default, it is in the URL', async function(assert) { + test('When the job has a namespace other than default, it is in the URL', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -135,7 +135,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('When there is no token set in the token service, no X-Nomad-Token header is set', async function(assert) { + test('When there is no token set in the token service, no X-Nomad-Token header is set', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -145,12 +145,12 @@ module('Unit | Adapter | Job', function(hooks) { await settled(); assert.notOk( - pretender.handledRequests.mapBy('requestHeaders').some(headers => headers['X-Nomad-Token']), + pretender.handledRequests.mapBy('requestHeaders').some((headers) => headers['X-Nomad-Token']), 'No token header present on either job request' ); }); - test('When a token is set in the token service, then X-Nomad-Token header is set', async function(assert) { + test('When a token is set in the token service, then X-Nomad-Token header is set', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -164,12 +164,12 @@ module('Unit | Adapter | Job', function(hooks) { assert.ok( pretender.handledRequests .mapBy('requestHeaders') - .every(headers => headers['X-Nomad-Token'] === secret), + .every((headers) => headers['X-Nomad-Token'] === secret), 'The token header is present on both job requests' ); }); - test('findAll can be watched', async function(assert) { + test('findAll can be watched', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -198,7 +198,7 @@ module('Unit | Adapter | Job', function(hooks) { await settled(); }); - test('findRecord can be watched', async function(assert) { + test('findRecord can be watched', async function (assert) { await this.initializeUI(); const jobId = JSON.stringify(['job-1', 'default']); @@ -228,7 +228,7 @@ module('Unit | Adapter | Job', function(hooks) { await settled(); }); - test('relationships can be reloaded', async function(assert) { + test('relationships can be reloaded', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -244,7 +244,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('relationship reloads can be watched', async function(assert) { + test('relationship reloads can be watched', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -269,7 +269,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('findAll can be canceled', async function(assert) { + test('findAll can be canceled', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -296,7 +296,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.ok(xhr.aborted, 'Request was aborted'); }); - test('findRecord can be canceled', async function(assert) { + test('findRecord can be canceled', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -322,7 +322,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.ok(xhr.aborted, 'Request was aborted'); }); - test('relationship reloads can be canceled', async function(assert) { + test('relationship reloads can be canceled', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -348,7 +348,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.ok(xhr.aborted, 'Request was aborted'); }); - test('requests can be canceled even if multiple requests for the same URL were made', async function(assert) { + test('requests can be canceled even if multiple requests for the same URL were made', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -389,7 +389,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.notOk(xhr2.aborted, 'Request two was not aborted'); }); - test('dispatch job encodes payload as base64', async function(assert) { + test('dispatch job encodes payload as base64', async function (assert) { const job = await this.initializeWithJob(); job.set('parameterized', true); @@ -410,7 +410,7 @@ module('Unit | Adapter | Job', function(hooks) { }); }); - test('when there is no region set, requests are made without the region query param', async function(assert) { + test('when there is no region set, requests are made without the region query param', async function (assert) { await this.initializeUI(); const { pretender } = this.server; @@ -430,7 +430,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('when there is a region set, requests are made with the region query param', async function(assert) { + test('when there is a region set, requests are made with the region query param', async function (assert) { const region = 'region-2'; await this.initializeUI({ region }); @@ -452,7 +452,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('when the region is set to the default region, requests are made without the region query param', async function(assert) { + test('when the region is set to the default region, requests are made without the region query param', async function (assert) { await this.initializeUI({ region: 'region-1' }); const { pretender } = this.server; @@ -472,7 +472,7 @@ module('Unit | Adapter | Job', function(hooks) { ); }); - test('fetchRawDefinition requests include the activeRegion', async function(assert) { + test('fetchRawDefinition requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); @@ -483,7 +483,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.method, 'GET'); }); - test('forcePeriodic requests include the activeRegion', async function(assert) { + test('forcePeriodic requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); job.set('periodic', true); @@ -495,7 +495,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.method, 'POST'); }); - test('stop requests include the activeRegion', async function(assert) { + test('stop requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); @@ -506,14 +506,14 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.method, 'DELETE'); }); - test('parse requests include the activeRegion', async function(assert) { + test('parse requests include the activeRegion', async function (assert) { const region = 'region-2'; await this.initializeUI({ region }); await this.subject().parse('job "name-goes-here" {'); const request = this.server.pretender.handledRequests[0]; - assert.equal(request.url, `/v1/jobs/parse?region=${region}`); + assert.equal(request.url, `/v1/jobs/parse?namespace=*®ion=${region}`); assert.equal(request.method, 'POST'); assert.deepEqual(JSON.parse(request.requestBody), { JobHCL: 'job "name-goes-here" {', @@ -521,7 +521,7 @@ module('Unit | Adapter | Job', function(hooks) { }); }); - test('plan requests include the activeRegion', async function(assert) { + test('plan requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); job.set('_newDefinitionJSON', {}); @@ -533,7 +533,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.method, 'POST'); }); - test('run requests include the activeRegion', async function(assert) { + test('run requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); job.set('_newDefinitionJSON', {}); @@ -545,7 +545,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.method, 'POST'); }); - test('update requests include the activeRegion', async function(assert) { + test('update requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); job.set('_newDefinitionJSON', {}); @@ -557,7 +557,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.method, 'POST'); }); - test('scale requests include the activeRegion', async function(assert) { + test('scale requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); @@ -568,7 +568,7 @@ module('Unit | Adapter | Job', function(hooks) { assert.equal(request.method, 'POST'); }); - test('dispatch requests include the activeRegion', async function(assert) { + test('dispatch requests include the activeRegion', async function (assert) { const region = 'region-2'; const job = await this.initializeWithJob({ region }); job.set('parameterized', true);