mirror of
https://github.com/kemko/nomad.git
synced 2026-01-06 02:15:43 +03:00
ui: Change Run Job availability based on ACLs (#5944)
This builds on API changes in #6017 and #6021 to conditionally turn off the “Run Job” button based on the current token’s capabilities, or the capabilities of the anonymous policy if no token is present. If you try to visit the job-run route directly, it redirects to the job list.
This commit is contained in:
79
ui/app/abilities/job.js
Normal file
79
ui/app/abilities/job.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Ability } from 'ember-can';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { computed, get } from '@ember/object';
|
||||
import { equal, or } from '@ember/object/computed';
|
||||
|
||||
export default Ability.extend({
|
||||
system: service(),
|
||||
token: service(),
|
||||
|
||||
canRun: or('selfTokenIsManagement', 'policiesSupportRunning'),
|
||||
|
||||
selfTokenIsManagement: equal('token.selfToken.type', 'management'),
|
||||
|
||||
activeNamespace: computed('system.activeNamespace.name', function() {
|
||||
return this.get('system.activeNamespace.name') || 'default';
|
||||
}),
|
||||
|
||||
rulesForActiveNamespace: computed('activeNamespace', 'token.selfTokenPolicies.[]', function() {
|
||||
let activeNamespace = this.activeNamespace;
|
||||
|
||||
return (this.get('token.selfTokenPolicies') || []).toArray().reduce((rules, policy) => {
|
||||
let policyNamespaces = get(policy, 'rulesJSON.Namespaces') || [];
|
||||
|
||||
let matchingNamespace = this._findMatchingNamespace(policyNamespaces, activeNamespace);
|
||||
|
||||
if (matchingNamespace) {
|
||||
rules.push(policyNamespaces.find(namespace => namespace.Name === matchingNamespace));
|
||||
}
|
||||
|
||||
return rules;
|
||||
}, []);
|
||||
}),
|
||||
|
||||
policiesSupportRunning: computed('rulesForActiveNamespace.@each.capabilities', function() {
|
||||
return this.rulesForActiveNamespace.some(rules => {
|
||||
let capabilities = get(rules, 'Capabilities') || [];
|
||||
return capabilities.includes('submit-job');
|
||||
});
|
||||
}),
|
||||
|
||||
// Chooses the closest namespace as described at the bottom here:
|
||||
// https://www.nomadproject.io/guides/security/acl.html#namespace-rules
|
||||
_findMatchingNamespace(policyNamespaces, activeNamespace) {
|
||||
let namespaceNames = policyNamespaces.mapBy('Name');
|
||||
|
||||
if (namespaceNames.includes(activeNamespace)) {
|
||||
return activeNamespace;
|
||||
}
|
||||
|
||||
let globNamespaceNames = namespaceNames.filter(namespaceName => namespaceName.includes('*'));
|
||||
|
||||
let matchingNamespaceName = globNamespaceNames.reduce(
|
||||
(mostMatching, namespaceName) => {
|
||||
// Convert * wildcards to .* for regex matching
|
||||
let namespaceNameRegExp = new RegExp(namespaceName.replace(/\*/g, '.*'));
|
||||
let characterDifference = activeNamespace.length - namespaceName.length;
|
||||
|
||||
if (
|
||||
characterDifference < mostMatching.mostMatchingCharacterDifference &&
|
||||
activeNamespace.match(namespaceNameRegExp)
|
||||
) {
|
||||
return {
|
||||
mostMatchingNamespaceName: namespaceName,
|
||||
mostMatchingCharacterDifference: characterDifference,
|
||||
};
|
||||
} else {
|
||||
return mostMatching;
|
||||
}
|
||||
},
|
||||
{ mostMatchingNamespaceName: null, mostMatchingCharacterDifference: Number.MAX_SAFE_INTEGER }
|
||||
).mostMatchingNamespaceName;
|
||||
|
||||
if (matchingNamespaceName) {
|
||||
return matchingNamespaceName;
|
||||
} else if (namespaceNames.includes('default')) {
|
||||
return 'default';
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -5,4 +5,5 @@ export default Model.extend({
|
||||
name: attr('string'),
|
||||
description: attr('string'),
|
||||
rules: attr('string'),
|
||||
rulesJSON: attr(),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export default Route.extend({
|
||||
config: service(),
|
||||
system: service(),
|
||||
store: service(),
|
||||
token: service(),
|
||||
|
||||
queryParams: {
|
||||
region: {
|
||||
@@ -22,28 +23,34 @@ export default Route.extend({
|
||||
},
|
||||
|
||||
beforeModel(transition) {
|
||||
return RSVP.all([this.get('system.regions'), this.get('system.defaultRegion')]).then(
|
||||
promises => {
|
||||
if (!this.get('system.shouldShowRegions')) return promises;
|
||||
const fetchSelfTokenAndPolicies = this.get('token.fetchSelfTokenAndPolicies')
|
||||
.perform()
|
||||
.catch();
|
||||
|
||||
const queryParam = transition.to.queryParams.region;
|
||||
const defaultRegion = this.get('system.defaultRegion.region');
|
||||
const currentRegion = this.get('system.activeRegion') || defaultRegion;
|
||||
return RSVP.all([
|
||||
this.get('system.regions'),
|
||||
this.get('system.defaultRegion'),
|
||||
fetchSelfTokenAndPolicies,
|
||||
]).then(promises => {
|
||||
if (!this.get('system.shouldShowRegions')) return promises;
|
||||
|
||||
// Only reset the store if the region actually changed
|
||||
if (
|
||||
(queryParam && queryParam !== currentRegion) ||
|
||||
(!queryParam && currentRegion !== defaultRegion)
|
||||
) {
|
||||
this.system.reset();
|
||||
this.store.unloadAll();
|
||||
}
|
||||
const queryParam = transition.to.queryParams.region;
|
||||
const defaultRegion = this.get('system.defaultRegion.region');
|
||||
const currentRegion = this.get('system.activeRegion') || defaultRegion;
|
||||
|
||||
this.set('system.activeRegion', queryParam || defaultRegion);
|
||||
|
||||
return promises;
|
||||
// Only reset the store if the region actually changed
|
||||
if (
|
||||
(queryParam && queryParam !== currentRegion) ||
|
||||
(!queryParam && currentRegion !== defaultRegion)
|
||||
) {
|
||||
this.system.reset();
|
||||
this.store.unloadAll();
|
||||
}
|
||||
);
|
||||
|
||||
this.set('system.activeRegion', queryParam || defaultRegion);
|
||||
|
||||
return promises;
|
||||
});
|
||||
},
|
||||
|
||||
// Model is being used as a way to transfer the provided region
|
||||
|
||||
@@ -2,6 +2,7 @@ import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default Route.extend({
|
||||
can: service(),
|
||||
store: service(),
|
||||
system: service(),
|
||||
|
||||
@@ -12,6 +13,12 @@ export default Route.extend({
|
||||
},
|
||||
],
|
||||
|
||||
beforeModel() {
|
||||
if (this.can.cannot('run job')) {
|
||||
this.transitionTo('jobs');
|
||||
}
|
||||
},
|
||||
|
||||
model() {
|
||||
return this.store.createRecord('job', {
|
||||
namespace: this.get('system.activeNamespace'),
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import { computed } from '@ember/object';
|
||||
import { alias } from '@ember/object/computed';
|
||||
import { getOwner } from '@ember/application';
|
||||
import { assign } from '@ember/polyfills';
|
||||
import { task } from 'ember-concurrency';
|
||||
import queryString from 'query-string';
|
||||
import fetch from 'nomad-ui/utils/fetch';
|
||||
|
||||
export default Service.extend({
|
||||
store: service(),
|
||||
system: service(),
|
||||
|
||||
secret: computed({
|
||||
@@ -22,6 +26,37 @@ export default Service.extend({
|
||||
},
|
||||
}),
|
||||
|
||||
fetchSelfToken: task(function*() {
|
||||
const TokenAdapter = getOwner(this).lookup('adapter:token');
|
||||
try {
|
||||
return yield TokenAdapter.findSelf();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
|
||||
selfToken: alias('fetchSelfToken.lastSuccessful.value'),
|
||||
|
||||
fetchSelfTokenPolicies: task(function*() {
|
||||
try {
|
||||
if (this.selfToken) {
|
||||
return yield this.selfToken.get('policies');
|
||||
} else {
|
||||
let policy = yield this.store.findRecord('policy', 'anonymous');
|
||||
return [policy];
|
||||
}
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
selfTokenPolicies: alias('fetchSelfTokenPolicies.lastSuccessful.value'),
|
||||
|
||||
fetchSelfTokenAndPolicies: task(function*() {
|
||||
yield this.fetchSelfToken.perform();
|
||||
yield this.fetchSelfTokenPolicies.perform();
|
||||
}),
|
||||
|
||||
// All non Ember Data requests should go through authorizedRequest.
|
||||
// However, the request that gets regions falls into that category.
|
||||
// This authorizedRawRequest is necessary in order to fetch data
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
transition: top 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.tooltip.is-right-aligned::after {
|
||||
transform: translateX(-75%);
|
||||
}
|
||||
|
||||
.tooltip:hover::after,
|
||||
.tooltip.always-active::after {
|
||||
opacity: 1;
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
</div>
|
||||
{{#if (media "isMobile")}}
|
||||
<div class="toolbar-item is-right-aligned">
|
||||
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
|
||||
{{#if (can "run job")}}
|
||||
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
|
||||
{{else}}
|
||||
<button data-test-run-job class="button tooltip is-right-aligned" aria-label="You don’t have permission to run jobs" disabled>Run Job</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
@@ -48,7 +52,11 @@
|
||||
</div>
|
||||
{{#if (not (media "isMobile"))}}
|
||||
<div class="toolbar-item is-right-aligned">
|
||||
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
|
||||
{{#if (can "run job")}}
|
||||
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
|
||||
{{else}}
|
||||
<button data-test-run-job class="button tooltip is-right-aligned" aria-label="You don’t have permission to run jobs" disabled>Run Job</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
@@ -278,6 +278,14 @@ export default function() {
|
||||
const secret = req.requestHeaders['X-Nomad-Token'];
|
||||
const tokenForSecret = tokens.findBy({ secretId: secret });
|
||||
|
||||
if (req.params.id === 'anonymous') {
|
||||
if (policy) {
|
||||
return this.serialize(policy);
|
||||
} else {
|
||||
return new Response(404, {}, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the policy only if the token that matches the request header
|
||||
// includes the policy or if the token that matches the request header
|
||||
// is of type management
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"d3-transition": "^1.1.0",
|
||||
"ember-ajax": "^5.0.0",
|
||||
"ember-auto-import": "^1.2.21",
|
||||
"ember-can": "^2.0.0",
|
||||
"ember-cli": "~3.12.0",
|
||||
"ember-cli-babel": "^7.7.3",
|
||||
"ember-cli-clipboard": "^0.13.0",
|
||||
|
||||
@@ -219,7 +219,9 @@ module('Acceptance | allocation detail', function(hooks) {
|
||||
await Allocation.visit({ id: 'not-a-real-allocation' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/allocation/not-a-real-allocation',
|
||||
'A request to the nonexistent allocation is made'
|
||||
);
|
||||
|
||||
@@ -323,7 +323,9 @@ module('Acceptance | client detail', function(hooks) {
|
||||
await ClientDetail.visit({ id: 'not-a-real-node' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/node/not-a-real-node',
|
||||
'A request to the nonexistent node is made'
|
||||
);
|
||||
|
||||
@@ -105,7 +105,9 @@ module('Acceptance | job allocations', function(hooks) {
|
||||
await Allocations.visit({ id: 'not-a-real-job' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/job/not-a-real-job',
|
||||
'A request to the nonexistent job is made'
|
||||
);
|
||||
|
||||
@@ -79,7 +79,9 @@ module('Acceptance | job definition', function(hooks) {
|
||||
await Definition.visit({ id: 'not-a-real-job' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/job/not-a-real-job',
|
||||
'A request to the nonexistent job is made'
|
||||
);
|
||||
|
||||
@@ -220,7 +220,9 @@ module('Acceptance | job deployments', function(hooks) {
|
||||
await Deployments.visit({ id: 'not-a-real-job' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/job/not-a-real-job',
|
||||
'A request to the nonexistent job is made'
|
||||
);
|
||||
|
||||
@@ -47,7 +47,9 @@ moduleForJob(
|
||||
await JobDetail.visit({ id: 'not-a-real-job' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/job/not-a-real-job',
|
||||
'A request to the nonexistent job is made'
|
||||
);
|
||||
|
||||
@@ -54,7 +54,9 @@ module('Acceptance | job evaluations', function(hooks) {
|
||||
await Evaluations.visit({ id: 'not-a-real-job' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/job/not-a-real-job',
|
||||
'A request to the nonexistent job is made'
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import JobRun from 'nomad-ui/tests/pages/jobs/run';
|
||||
const newJobName = 'new-job';
|
||||
const newJobTaskGroupName = 'redis';
|
||||
|
||||
let managementToken, clientToken;
|
||||
|
||||
const jsonJob = overrides => {
|
||||
return JSON.stringify(
|
||||
assign(
|
||||
@@ -45,6 +47,11 @@ module('Acceptance | job run', function(hooks) {
|
||||
hooks.beforeEach(function() {
|
||||
// Required for placing allocations (a result of creating jobs)
|
||||
server.create('node');
|
||||
|
||||
managementToken = server.create('token');
|
||||
clientToken = server.create('token');
|
||||
|
||||
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||
});
|
||||
|
||||
test('visiting /jobs/run', async function(assert) {
|
||||
@@ -86,4 +93,11 @@ module('Acceptance | job run', function(hooks) {
|
||||
`Redirected to the job overview page for ${newJobName} and switched the namespace to ${newNamespace}`
|
||||
);
|
||||
});
|
||||
|
||||
test('when the user doesn’t have permission to run a job, redirects to the job overview page', async function(assert) {
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
|
||||
await JobRun.visit();
|
||||
assert.equal(currentURL(), '/jobs');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@ module('Acceptance | job versions', function(hooks) {
|
||||
await Versions.visit({ id: 'not-a-real-job' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/job/not-a-real-job',
|
||||
'A request to the nonexistent job is made'
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import JobsList from 'nomad-ui/tests/pages/jobs/list';
|
||||
|
||||
let managementToken, clientToken;
|
||||
|
||||
module('Acceptance | jobs list', function(hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
@@ -11,6 +13,11 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
hooks.beforeEach(function() {
|
||||
// Required for placing allocations (a result of creating jobs)
|
||||
server.create('node');
|
||||
|
||||
managementToken = server.create('token');
|
||||
clientToken = server.create('token');
|
||||
|
||||
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||
});
|
||||
|
||||
test('visiting /jobs', async function(assert) {
|
||||
@@ -62,11 +69,76 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
|
||||
test('the new job button transitions to the new job page', async function(assert) {
|
||||
await JobsList.visit();
|
||||
await JobsList.runJob();
|
||||
await JobsList.runJobButton.click();
|
||||
|
||||
assert.equal(currentURL(), '/jobs/run');
|
||||
});
|
||||
|
||||
test('the job run button is disabled when the token lacks permission', async function(assert) {
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
await JobsList.visit();
|
||||
|
||||
assert.ok(JobsList.runJobButton.isDisabled);
|
||||
|
||||
await JobsList.runJobButton.click();
|
||||
assert.equal(currentURL(), '/jobs');
|
||||
});
|
||||
|
||||
test('the job run button state can change between namespaces', 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 });
|
||||
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
|
||||
const policy = server.create('policy', {
|
||||
id: 'something',
|
||||
name: 'something',
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: job1.namespaceId,
|
||||
Capabilities: ['list-jobs', 'submit-job'],
|
||||
},
|
||||
{
|
||||
Name: job2.namespaceId,
|
||||
Capabilities: ['list-jobs'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
clientToken.policyIds = [policy.id];
|
||||
clientToken.save();
|
||||
|
||||
await JobsList.visit();
|
||||
assert.notOk(JobsList.runJobButton.isDisabled);
|
||||
|
||||
const secondNamespace = server.db.namespaces[1];
|
||||
await JobsList.visit({ namespace: secondNamespace.id });
|
||||
assert.ok(JobsList.runJobButton.isDisabled);
|
||||
});
|
||||
|
||||
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', {
|
||||
id: 'anonymous',
|
||||
name: 'anonymous',
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'default',
|
||||
Capabilities: ['list-jobs', 'submit-job'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await JobsList.visit();
|
||||
assert.notOk(JobsList.runJobButton.isDisabled);
|
||||
});
|
||||
|
||||
test('when there are no jobs, there is an empty message', async function(assert) {
|
||||
await JobsList.visit();
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ module('Acceptance | regions (many)', function(hooks) {
|
||||
});
|
||||
|
||||
test('when the region is not the default region, all api requests include the region query param', async function(assert) {
|
||||
window.localStorage.removeItem('nomadTokenSecret');
|
||||
const region = server.db.regions[1].id;
|
||||
|
||||
await JobsList.visit({ region });
|
||||
@@ -154,7 +155,12 @@ module('Acceptance | regions (many)', function(hooks) {
|
||||
await JobsList.jobs.objectAt(0).clickRow();
|
||||
await PageLayout.gutter.visitClients();
|
||||
await PageLayout.gutter.visitServers();
|
||||
const [regionsRequest, defaultRegionRequest, ...appRequests] = server.pretender.handledRequests;
|
||||
const [
|
||||
,
|
||||
regionsRequest,
|
||||
defaultRegionRequest,
|
||||
...appRequests
|
||||
] = server.pretender.handledRequests;
|
||||
|
||||
assert.notOk(
|
||||
regionsRequest.url.includes('region='),
|
||||
|
||||
@@ -145,7 +145,9 @@ module('Acceptance | task detail', function(hooks) {
|
||||
await Task.visit({ id: 'not-a-real-allocation', name: task.name });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/allocation/not-a-real-allocation',
|
||||
'A request to the nonexistent allocation is made'
|
||||
);
|
||||
|
||||
@@ -225,7 +225,9 @@ module('Acceptance | task group detail', function(hooks) {
|
||||
await TaskGroup.visit({ id: 'not-a-real-job', name: 'not-a-real-task-group' });
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.findBy('status', 404).url,
|
||||
server.pretender.handledRequests
|
||||
.filter(request => !request.url.includes('policy'))
|
||||
.findBy('status', 404).url,
|
||||
'/v1/job/not-a-real-job',
|
||||
'A request to the nonexistent job is made'
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
collection,
|
||||
clickable,
|
||||
fillable,
|
||||
is,
|
||||
isPresent,
|
||||
text,
|
||||
visitable,
|
||||
@@ -18,7 +19,10 @@ export default create({
|
||||
|
||||
search: fillable('[data-test-jobs-search] input'),
|
||||
|
||||
runJob: clickable('[data-test-run-job]'),
|
||||
runJobButton: {
|
||||
scope: '[data-test-run-job]',
|
||||
isDisabled: is('[disabled]'),
|
||||
},
|
||||
|
||||
jobs: collection('[data-test-job-row]', {
|
||||
id: attribute('data-test-job-row'),
|
||||
|
||||
183
ui/tests/unit/abilities/job-test.js
Normal file
183
ui/tests/unit/abilities/job-test.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import Service from '@ember/service';
|
||||
|
||||
module('Unit | Ability | job', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it permits job run for management tokens', function(assert) {
|
||||
const mockToken = Service.extend({
|
||||
selfToken: { type: 'management' },
|
||||
});
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
const jobAbility = this.owner.lookup('ability:job');
|
||||
assert.ok(jobAbility.canRun);
|
||||
});
|
||||
|
||||
test('it permits job run for client tokens with a policy that has namespace submit-job', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'aNamespace',
|
||||
Capabilities: ['submit-job'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
const jobAbility = this.owner.lookup('ability:job');
|
||||
assert.ok(jobAbility.canRun);
|
||||
});
|
||||
|
||||
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({
|
||||
activeNamespace: {
|
||||
name: 'anotherNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'aNamespace',
|
||||
Capabilities: [],
|
||||
},
|
||||
{
|
||||
Name: 'default',
|
||||
Capabilities: ['submit-job'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
const jobAbility = this.owner.lookup('ability:job');
|
||||
assert.ok(jobAbility.canRun);
|
||||
});
|
||||
|
||||
test('it blocks job run for client tokens with a policy that has no submit-job capability', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'aNamespace',
|
||||
Capabilities: ['list-jobs'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
const jobAbility = this.owner.lookup('ability:job');
|
||||
assert.notOk(jobAbility.canRun);
|
||||
});
|
||||
|
||||
test('it handles globs in namespace names', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'production-*',
|
||||
Capabilities: ['submit-job'],
|
||||
},
|
||||
{
|
||||
Name: 'production-api',
|
||||
Capabilities: ['submit-job'],
|
||||
},
|
||||
{
|
||||
Name: 'production-web',
|
||||
Capabilities: [],
|
||||
},
|
||||
{
|
||||
Name: '*-suffixed',
|
||||
Capabilities: ['submit-job'],
|
||||
},
|
||||
{
|
||||
Name: '*-more-suffixed',
|
||||
Capabilities: [],
|
||||
},
|
||||
{
|
||||
Name: '*-abc-*',
|
||||
Capabilities: ['submit-job'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
const jobAbility = this.owner.lookup('ability:job');
|
||||
const systemService = this.owner.lookup('service:system');
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-web');
|
||||
assert.notOk(jobAbility.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-api');
|
||||
assert.ok(jobAbility.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-other');
|
||||
assert.ok(jobAbility.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'something-suffixed');
|
||||
assert.ok(jobAbility.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'something-more-suffixed');
|
||||
assert.notOk(
|
||||
jobAbility.canRun,
|
||||
'expected the namespace with the greatest number of matched characters to be chosen'
|
||||
);
|
||||
|
||||
systemService.set('activeNamespace.name', '000-abc-999');
|
||||
assert.ok(jobAbility.canRun, 'expected to be able to match against more than one wildcard');
|
||||
});
|
||||
});
|
||||
50
ui/yarn.lock
50
ui/yarn.lock
@@ -4868,31 +4868,20 @@ ember-basic-dropdown@^2.0.5:
|
||||
ember-maybe-in-element "^0.4.0"
|
||||
ember-truth-helpers "2.1.0"
|
||||
|
||||
ember-can@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-can/-/ember-can-2.0.0.tgz#b01400380b42aedd9570b89521997a34677feab6"
|
||||
integrity sha512-4c0HcXUC1HiNwGmW7Gp72Ojhlr/uULQTdJp85up4G3MjonCdV0ZdvPLsMIQITBgWqWY/H5HezMjrdaIDFuEDBA==
|
||||
dependencies:
|
||||
ember-cli-babel "7.8.0"
|
||||
ember-inflector "3.0.1"
|
||||
|
||||
ember-cli-babel-plugin-helpers@^1.0.0, ember-cli-babel-plugin-helpers@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-babel-plugin-helpers/-/ember-cli-babel-plugin-helpers-1.1.0.tgz#de3baedd093163b6c2461f95964888c1676325ac"
|
||||
integrity sha512-Zr4my8Xn+CzO0gIuFNXji0eTRml5AxZUTDQz/wsNJ5AJAtyFWCY4QtKdoELNNbiCVGt1lq5yLiwTm4scGKu6xA==
|
||||
|
||||
ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.0.0-beta.7, ember-cli-babel@^6.11.0, ember-cli-babel@^6.16.0, ember-cli-babel@^6.18.0, ember-cli-babel@^6.3.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.7.2, ember-cli-babel@^6.8.0, ember-cli-babel@^6.8.1, ember-cli-babel@^6.8.2, ember-cli-babel@^6.9.0:
|
||||
version "6.18.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-6.18.0.tgz#3f6435fd275172edeff2b634ee7b29ce74318957"
|
||||
integrity sha512-7ceC8joNYxY2wES16iIBlbPSxwKDBhYwC8drU3ZEvuPDMwVv1KzxCNu1fvxyFEBWhwaRNTUxSCsEVoTd9nosGA==
|
||||
dependencies:
|
||||
amd-name-resolver "1.2.0"
|
||||
babel-plugin-debug-macros "^0.2.0-beta.6"
|
||||
babel-plugin-ember-modules-api-polyfill "^2.6.0"
|
||||
babel-plugin-transform-es2015-modules-amd "^6.24.0"
|
||||
babel-polyfill "^6.26.0"
|
||||
babel-preset-env "^1.7.0"
|
||||
broccoli-babel-transpiler "^6.5.0"
|
||||
broccoli-debug "^0.6.4"
|
||||
broccoli-funnel "^2.0.0"
|
||||
broccoli-source "^1.1.0"
|
||||
clone "^2.0.0"
|
||||
ember-cli-version-checker "^2.1.2"
|
||||
semver "^5.5.0"
|
||||
|
||||
ember-cli-babel@^7.1.0, ember-cli-babel@^7.7.3:
|
||||
ember-cli-babel@7.8.0, ember-cli-babel@^7.1.0, ember-cli-babel@^7.7.3:
|
||||
version "7.8.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.8.0.tgz#e596500eca0f5a7c9aaee755f803d1542f578acf"
|
||||
integrity sha512-xUBgJQ81fqd7k/KIiGU+pjpoXhrmmRf9pUrqLenNSU5N+yeNFT5a1+w0b+p1F7oBphfXVwuxApdZxrmAHOdA3Q==
|
||||
@@ -4919,6 +4908,25 @@ ember-cli-babel@^7.1.0, ember-cli-babel@^7.7.3:
|
||||
ensure-posix-path "^1.0.2"
|
||||
semver "^5.5.0"
|
||||
|
||||
ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.0.0-beta.7, ember-cli-babel@^6.11.0, ember-cli-babel@^6.16.0, ember-cli-babel@^6.18.0, ember-cli-babel@^6.3.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.7.2, ember-cli-babel@^6.8.0, ember-cli-babel@^6.8.1, ember-cli-babel@^6.8.2, ember-cli-babel@^6.9.0:
|
||||
version "6.18.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-6.18.0.tgz#3f6435fd275172edeff2b634ee7b29ce74318957"
|
||||
integrity sha512-7ceC8joNYxY2wES16iIBlbPSxwKDBhYwC8drU3ZEvuPDMwVv1KzxCNu1fvxyFEBWhwaRNTUxSCsEVoTd9nosGA==
|
||||
dependencies:
|
||||
amd-name-resolver "1.2.0"
|
||||
babel-plugin-debug-macros "^0.2.0-beta.6"
|
||||
babel-plugin-ember-modules-api-polyfill "^2.6.0"
|
||||
babel-plugin-transform-es2015-modules-amd "^6.24.0"
|
||||
babel-polyfill "^6.26.0"
|
||||
babel-preset-env "^1.7.0"
|
||||
broccoli-babel-transpiler "^6.5.0"
|
||||
broccoli-debug "^0.6.4"
|
||||
broccoli-funnel "^2.0.0"
|
||||
broccoli-source "^1.1.0"
|
||||
clone "^2.0.0"
|
||||
ember-cli-version-checker "^2.1.2"
|
||||
semver "^5.5.0"
|
||||
|
||||
ember-cli-babel@^7.1.2, ember-cli-babel@^7.5.0:
|
||||
version "7.7.3"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.7.3.tgz#f94709f6727583d18685ca6773a995877b87b8a0"
|
||||
@@ -5556,7 +5564,7 @@ ember-getowner-polyfill@^2.0.1, ember-getowner-polyfill@^2.2.0:
|
||||
ember-cli-version-checker "^2.1.0"
|
||||
ember-factory-for-polyfill "^1.3.1"
|
||||
|
||||
"ember-inflector@^2.0.0 || ^3.0.0", ember-inflector@^3.0.1:
|
||||
ember-inflector@3.0.1, "ember-inflector@^2.0.0 || ^3.0.0", ember-inflector@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-inflector/-/ember-inflector-3.0.1.tgz#04be6df4d7e4000f6d6bd70787cdc995f77be4ab"
|
||||
integrity sha512-fngrwMsnhkBt51KZgwNwQYxgURwV4lxtoHdjxf7RueGZ5zM7frJLevhHw7pbQNGqXZ3N+MRkhfNOLkdDK9kFdA==
|
||||
|
||||
Reference in New Issue
Block a user