mirror of
https://github.com/kemko/nomad.git
synced 2026-01-05 09:55:44 +03:00
Secure Variables UI: /variables/new and /variables/*path (#13069)
* variables.new initialized * Hacky but savey * Variable wildcard route and multiple creatable at a time * multiple KVs per variable * PR Prep cleanup and lintfix * Delog * Data mocking in mirage for variables * Linting fixes * Re-implement absent params * Adapter and model tests * Moves the path-as-id logic to a serializer instead of adapter * Classic to serializer and lint cleanup * Pluralized save button (#13140) * Autofocus modifier and better Add More button UX (#13145) * Secure Variables: show/hide functionality when adding new values (#13137) * Flight Icons added and show hide functionality * PR cleanup * Linting cleanup * Position of icon moved to the right of input * PR feedback addressed * Delete button and stylistic changes to show hide * Hmm, eslint doesnt like jsdoc-usage as only reason for import * More closely match the button styles and delete test * Simplified new.js model * Secure Variables: /variables/*path/edit route and functionality (#13170) * Variable edit page init * Significant change to where we house model methods * Lintfix * Edit a variable tests * Remove redundant tests * Asserts expected * Mirage factory updated to reflect model state
This commit is contained in:
@@ -10,10 +10,22 @@ export default class extends AbstractAbility {
|
||||
)
|
||||
canList;
|
||||
|
||||
@or(
|
||||
'bypassAuthorization',
|
||||
'selfTokenIsManagement',
|
||||
'policiesSupportVariableCreation'
|
||||
)
|
||||
canCreate;
|
||||
|
||||
@computed('rulesForNamespace.@each.capabilities')
|
||||
get policiesSupportVariableView() {
|
||||
return this.rulesForNamespace.some((rules) => {
|
||||
return get(rules, 'SecureVariables');
|
||||
});
|
||||
}
|
||||
|
||||
@computed('rulesForNamespace.@each.capabilities')
|
||||
get policiesSupportVariableCreation() {
|
||||
return true; // TODO: check SecureVariables.<path>.capabilities[]
|
||||
}
|
||||
}
|
||||
|
||||
28
ui/app/adapters/variable.js
Normal file
28
ui/app/adapters/variable.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import ApplicationAdapter from './application';
|
||||
import { pluralize } from 'ember-inflector';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
export default class VariableAdapter extends ApplicationAdapter {
|
||||
pathForType = () => 'var';
|
||||
|
||||
// PUT instead of POST on create;
|
||||
// /v1/var instead of /v1/vars on create (urlForFindRecord)
|
||||
createRecord(_store, _type, snapshot) {
|
||||
let data = this.serialize(snapshot);
|
||||
return this.ajax(
|
||||
this.urlForFindRecord(snapshot.id, snapshot.modelName),
|
||||
'PUT',
|
||||
{ data }
|
||||
);
|
||||
}
|
||||
|
||||
urlForFindAll(modelName) {
|
||||
let baseUrl = this.buildURL(modelName);
|
||||
return pluralize(baseUrl);
|
||||
}
|
||||
urlForFindRecord(id, modelName, snapshot) {
|
||||
let baseUrl = this.buildURL(modelName, id, snapshot);
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
71
ui/app/components/secure-variable-form.hbs
Normal file
71
ui/app/components/secure-variable-form.hbs
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
<form
|
||||
class="new-secure-variables"
|
||||
{{on "submit" this.save}}
|
||||
autocomplete="off"
|
||||
>
|
||||
{{!-- TODO: {{if this.parseError 'is-danger'}} on inputs --}}
|
||||
<div>
|
||||
<label>
|
||||
<span>Path</span>
|
||||
<Input
|
||||
@type="text"
|
||||
@value={{@model.path}}
|
||||
placeholder="/path/to/variable"
|
||||
class="input"
|
||||
{{autofocus}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{{#each @model.keyValues as |entry iter|}}
|
||||
<div class="key-value">
|
||||
|
||||
<label>
|
||||
<span>Key</span>
|
||||
<Input
|
||||
@type="text"
|
||||
@value={{entry.key}}
|
||||
class="input"
|
||||
{{autofocus ignore=(eq iter 0)}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="value-label">
|
||||
<span>Value
|
||||
</span>
|
||||
<Input
|
||||
@type={{this.valueFieldType}}
|
||||
@value={{entry.value}}
|
||||
class="input value-input"
|
||||
autocomplete="new-password" {{!-- prevent auto-fill --}}
|
||||
/>
|
||||
<button
|
||||
class="show-hide-values button is-light"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
{{on "click" this.toggleShowHide}}
|
||||
>
|
||||
<FlightIcon
|
||||
@name={{if this.shouldHideValues "eye-off" "eye"}}
|
||||
@title={{if this.shouldHideValues "Show Values" "Hide Values"}}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{{#if (eq entry @model.keyValues.lastObject)}}
|
||||
<button
|
||||
{{on "click" this.appendRow}}
|
||||
class="add-more button is-info is-inverted" type="button">Add More</button>
|
||||
{{else}}
|
||||
<button
|
||||
{{on "click" (action this.deleteRow entry)}}
|
||||
class="delete-row button is-danger is-inverted" type="button">Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
<footer>
|
||||
<button
|
||||
disabled={{this.shouldDisableSave}}
|
||||
class="button is-primary" type="submit">Save {{pluralize 'Variable' @model.keyValues.length}}</button>
|
||||
</footer>
|
||||
</form>
|
||||
48
ui/app/components/secure-variable-form.js
Normal file
48
ui/app/components/secure-variable-form.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
export default class SecureVariableFormComponent extends Component {
|
||||
@service router;
|
||||
|
||||
@tracked
|
||||
shouldHideValues = true;
|
||||
|
||||
get valueFieldType() {
|
||||
return this.shouldHideValues ? 'password' : 'text';
|
||||
}
|
||||
|
||||
get shouldDisableSave() {
|
||||
return !this.args.model?.path;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleShowHide() {
|
||||
this.shouldHideValues = !this.shouldHideValues;
|
||||
}
|
||||
|
||||
@action appendRow() {
|
||||
this.args.model.keyValues.pushObject({
|
||||
key: '',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
|
||||
@action deleteRow(row) {
|
||||
this.args.model.keyValues.removeObject(row);
|
||||
}
|
||||
|
||||
@action
|
||||
async save(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.args.model.id = this.args.model.path;
|
||||
|
||||
const transitionTarget = this.args.model.isNew
|
||||
? 'variables'
|
||||
: 'variables.variable';
|
||||
|
||||
await this.args.model.save();
|
||||
this.router.transitionTo(transitionTarget);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
export default class VariablesIndexController extends Controller {
|
||||
@service router;
|
||||
|
||||
isForbidden = false;
|
||||
|
||||
@action
|
||||
goToVariable(variable) {
|
||||
this.router.transitionTo('variables.variable', variable.path);
|
||||
}
|
||||
}
|
||||
|
||||
2
ui/app/controllers/variables/new.js
Normal file
2
ui/app/controllers/variables/new.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Controller from '@ember/controller';
|
||||
export default class VariablesNewController extends Controller {}
|
||||
10
ui/app/controllers/variables/variable.js
Normal file
10
ui/app/controllers/variables/variable.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class VariablesVariableController extends Controller {
|
||||
get breadcrumb() {
|
||||
return {
|
||||
label: this.model.path,
|
||||
args: [`variables.variable`, this.model.path],
|
||||
};
|
||||
}
|
||||
}
|
||||
3
ui/app/controllers/variables/variable/index.js
Normal file
3
ui/app/controllers/variables/variable/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class VariablesVariableIndexController extends Controller {}
|
||||
30
ui/app/models/variable.js
Normal file
30
ui/app/models/variable.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// @ts-check
|
||||
|
||||
import Model from '@ember-data/model';
|
||||
import { attr } from '@ember-data/model';
|
||||
import classic from 'ember-classic-decorator';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import MutableArray from '@ember/array/mutable';
|
||||
|
||||
/**
|
||||
* @typedef SecureVariable
|
||||
* @type {object}
|
||||
* @property {string} key
|
||||
* @property {string} value
|
||||
*/
|
||||
|
||||
@classic
|
||||
export default class VariableModel extends Model {
|
||||
@attr('string') path;
|
||||
@attr('string') namespace;
|
||||
|
||||
/**
|
||||
* @type {MutableArray<SecureVariable>}
|
||||
*/
|
||||
@attr({
|
||||
defaultValue() {
|
||||
return [{ key: '', value: '' }];
|
||||
},
|
||||
})
|
||||
keyValues;
|
||||
}
|
||||
7
ui/app/modifiers/autofocus.js
Normal file
7
ui/app/modifiers/autofocus.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier(function autofocus(element, _positional, named) {
|
||||
const { ignore } = named;
|
||||
if (ignore) return;
|
||||
element.focus();
|
||||
});
|
||||
@@ -78,5 +78,17 @@ Router.map(function () {
|
||||
this.route('evaluations', function () {});
|
||||
|
||||
this.route('not-found', { path: '/*' });
|
||||
this.route('variables', function () {});
|
||||
this.route('variables', function () {
|
||||
this.route('new');
|
||||
|
||||
this.route(
|
||||
'variable',
|
||||
{
|
||||
path: '/*path',
|
||||
},
|
||||
function () {
|
||||
this.route('edit');
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
|
||||
import { inject as service } from '@ember/service';
|
||||
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
|
||||
import RSVP from 'rsvp';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
|
||||
export default class VariablesRoute extends Route.extend(withForbiddenState) {
|
||||
export default class VariablesRoute extends Route.extend(WithForbiddenState) {
|
||||
@service can;
|
||||
@service router;
|
||||
|
||||
@@ -12,7 +14,8 @@ export default class VariablesRoute extends Route.extend(withForbiddenState) {
|
||||
}
|
||||
}
|
||||
model() {
|
||||
// TODO: Populate model from /variables
|
||||
return {};
|
||||
return RSVP.hash({
|
||||
variables: this.store.findAll('variable'),
|
||||
}).catch(notifyForbidden(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import withForbiddenState from '../../mixins/with-forbidden-state';
|
||||
|
||||
export default class VariablesIndexRoute extends Route.extend(
|
||||
withForbiddenState
|
||||
) {
|
||||
model() {
|
||||
// TODO: Fill in model with format from API
|
||||
return {};
|
||||
// return RSVP.hash({
|
||||
// variables: this.store
|
||||
// .query('variable', { namespace: params.qpNamespace })
|
||||
// .catch(notifyForbidden(this)),
|
||||
// namespaces: this.store.findAll('namespace'),
|
||||
// });
|
||||
}
|
||||
}
|
||||
export default class VariablesIndexRoute extends Route {}
|
||||
|
||||
15
ui/app/routes/variables/new.js
Normal file
15
ui/app/routes/variables/new.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class VariablesNewRoute extends Route {
|
||||
model() {
|
||||
return this.store.createRecord('variable');
|
||||
}
|
||||
resetController(controller, isExiting) {
|
||||
if (isExiting) {
|
||||
// If user didn't save, delete the freshly created model
|
||||
if (controller.model.isNew) {
|
||||
controller.model.destroyRecord();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
ui/app/routes/variables/variable.js
Normal file
12
ui/app/routes/variables/variable.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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';
|
||||
|
||||
export default class VariablesVariableRoute extends Route.extend(
|
||||
withForbiddenState,
|
||||
WithModelErrorHandling
|
||||
) {
|
||||
model(params) {
|
||||
return this.store.findRecord('variable', params.path);
|
||||
}
|
||||
}
|
||||
3
ui/app/routes/variables/variable/edit.js
Normal file
3
ui/app/routes/variables/variable/edit.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class VariablesVariableEditRoute extends Route {}
|
||||
40
ui/app/serializers/variable.js
Normal file
40
ui/app/serializers/variable.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import classic from 'ember-classic-decorator';
|
||||
import ApplicationSerializer from './application';
|
||||
|
||||
@classic
|
||||
export default class VariableSerializer extends ApplicationSerializer {
|
||||
primaryKey = 'Path';
|
||||
|
||||
// Transform API's Items object into an array of a KeyValue objects
|
||||
normalizeFindRecordResponse(store, typeClass, hash, id, ...args) {
|
||||
// TODO: prevent items-less saving at API layer
|
||||
if (!hash.Items) {
|
||||
hash.Items = { '': '' };
|
||||
}
|
||||
hash.KeyValues = Object.entries(hash.Items).map(([key, value]) => {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
};
|
||||
});
|
||||
delete hash.Items;
|
||||
return super.normalizeFindRecordResponse(
|
||||
store,
|
||||
typeClass,
|
||||
hash,
|
||||
id,
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
// Transform our KeyValues array into an Items object
|
||||
serialize(snapshot, options) {
|
||||
const json = super.serialize(snapshot, options);
|
||||
json.Items = json.KeyValues.reduce((acc, { key, value }) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
delete json.KeyValues;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -46,3 +46,4 @@
|
||||
@import './components/tooltip';
|
||||
@import './components/two-step-button';
|
||||
@import './components/evaluations';
|
||||
@import './components/secure-variables';
|
||||
|
||||
30
ui/app/styles/components/secure-variables.scss
Normal file
30
ui/app/styles/components/secure-variables.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
.new-secure-variables {
|
||||
& > div {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.key-value {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 4fr 130px;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
|
||||
.value-label {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
& > span {
|
||||
grid-column: -1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
button.show-hide-values {
|
||||
height: 100%;
|
||||
box-shadow: none;
|
||||
margin-left: -2px;
|
||||
border-color: $grey-blue;
|
||||
}
|
||||
}
|
||||
|
||||
// .add-more:focus {
|
||||
// background-color: $grey-lighter;
|
||||
// }
|
||||
}
|
||||
@@ -3,13 +3,72 @@
|
||||
{{#if this.isForbidden}}
|
||||
<ForbiddenMessage />
|
||||
{{else}}
|
||||
<div class="empty-message">
|
||||
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">
|
||||
No Secure Variables
|
||||
</h3>
|
||||
<p class="empty-message-body">
|
||||
Get started by <LinkTo @route="variables">creating a new secure variable</LinkTo>
|
||||
</p>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
{{#if this.variables.length}}
|
||||
<SearchBox
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@onChange={{action this.resetPagination}}
|
||||
@placeholder="Search variables..."
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
<div class="button-bar">
|
||||
{{#if (can "create variable" namespace=this.qpNamespace)}}
|
||||
<LinkTo
|
||||
@route="variables.new"
|
||||
@query={{hash namespace=this.qpNamespace}}
|
||||
data-test-run-job
|
||||
class="button is-primary"
|
||||
>
|
||||
Create Secure Variable
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<button
|
||||
data-test-run-job
|
||||
class="button is-primary is-disabled tooltip is-right-aligned"
|
||||
aria-label="You don’t have sufficient permissions"
|
||||
disabled
|
||||
type="button"
|
||||
>
|
||||
Create Secure Variable
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if @model.variables.length}}
|
||||
<ListTable data-test-eval-table @source={{@model.variables}} as |t|>
|
||||
<t.head>
|
||||
<th>
|
||||
Path
|
||||
</th>
|
||||
<th>
|
||||
Namespace
|
||||
</th>
|
||||
</t.head>
|
||||
<t.body as |row|>
|
||||
<tr {{on "click" (fn this.goToVariable row.model)}}>
|
||||
<td>
|
||||
{{row.model.path}}
|
||||
</td>
|
||||
<td>
|
||||
{{row.model.namespace}}
|
||||
</td>
|
||||
</tr>
|
||||
</t.body>
|
||||
</ListTable>
|
||||
{{else}}
|
||||
<div class="empty-message">
|
||||
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">
|
||||
No Secure Variables
|
||||
</h3>
|
||||
<p class="empty-message-body">
|
||||
Get started by <LinkTo @route="variables.new">creating a new secure variable</LinkTo>
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</section>
|
||||
|
||||
5
ui/app/templates/variables/new.hbs
Normal file
5
ui/app/templates/variables/new.hbs
Normal file
@@ -0,0 +1,5 @@
|
||||
{{page-title "New Secure Variable"}}
|
||||
<Breadcrumb @crumb={{hash label="New" args=(array "variables.new")}} />
|
||||
<section class="section">
|
||||
<SecureVariableForm @model={{this.model}} />
|
||||
</section>
|
||||
6
ui/app/templates/variables/variable.hbs
Normal file
6
ui/app/templates/variables/variable.hbs
Normal file
@@ -0,0 +1,6 @@
|
||||
{{page-title "Secure Variables: " this.model.path}}
|
||||
<Breadcrumb @crumb={{this.breadcrumb}} />
|
||||
<section class="section">
|
||||
{{outlet}}
|
||||
</section>
|
||||
|
||||
5
ui/app/templates/variables/variable/edit.hbs
Normal file
5
ui/app/templates/variables/variable/edit.hbs
Normal file
@@ -0,0 +1,5 @@
|
||||
{{page-title "Edit Secure Variable"}}
|
||||
|
||||
<section class="section">
|
||||
<SecureVariableForm @model={{this.model}} />
|
||||
</section>
|
||||
15
ui/app/templates/variables/variable/index.hbs
Normal file
15
ui/app/templates/variables/variable/index.hbs
Normal file
@@ -0,0 +1,15 @@
|
||||
<LinkTo
|
||||
class="button is-primary"
|
||||
@model={{this.model}}
|
||||
@route="variables.variable.edit"
|
||||
>
|
||||
Edit
|
||||
</LinkTo>
|
||||
<ListTable data-test-eval-table @source={{this.model.keyValues}} as |t|>
|
||||
<t.body as |row|>
|
||||
<tr>
|
||||
<td>{{row.model.key}}</td>
|
||||
<td>{{row.model.value}}</td>
|
||||
</tr>
|
||||
</t.body>
|
||||
</ListTable>
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true
|
||||
}
|
||||
"experimentalDecorators": true,
|
||||
"target": "es5",
|
||||
},
|
||||
|
||||
}
|
||||
@@ -835,6 +835,27 @@ export default function () {
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
//#region Secure Variables
|
||||
this.get('/vars', function (schema) {
|
||||
return schema.variables.all();
|
||||
});
|
||||
this.get('/var/:id', function ({ variables }, { params }) {
|
||||
return variables.find(params.id);
|
||||
});
|
||||
this.put('/var/:id', function (schema, request) {
|
||||
const { Path, Namespace, Items } = JSON.parse(request.requestBody);
|
||||
server.create('variable', {
|
||||
Path,
|
||||
Namespace,
|
||||
Items,
|
||||
id: Path,
|
||||
});
|
||||
|
||||
return okEmpty(); // TODO: consider returning the created object.
|
||||
});
|
||||
|
||||
//#endregion Secure Variables
|
||||
}
|
||||
|
||||
function filterKeys(object, ...keys) {
|
||||
|
||||
21
ui/mirage/factories/variable.js
Normal file
21
ui/mirage/factories/variable.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Factory } from 'ember-cli-mirage';
|
||||
import faker from 'nomad-ui/mirage/faker';
|
||||
|
||||
export default Factory.extend({
|
||||
id: () => faker.random.words(3).split(' ').join('/').toLowerCase(),
|
||||
path() {
|
||||
return this.id;
|
||||
},
|
||||
namespace: 'default',
|
||||
items() {
|
||||
return (
|
||||
this.Items || {
|
||||
[faker.database.column()]: faker.database.collation(),
|
||||
[faker.database.column()]: faker.database.collation(),
|
||||
[faker.database.column()]: faker.database.collation(),
|
||||
[faker.database.column()]: faker.database.collation(),
|
||||
[faker.database.column()]: faker.database.collation(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -50,6 +50,7 @@ function smallCluster(server) {
|
||||
server.createList('allocFile', 5);
|
||||
server.create('allocFile', 'dir', { depth: 2 });
|
||||
server.createList('csi-plugin', 2);
|
||||
server.createList('variable', 3);
|
||||
|
||||
// #region evaluations
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@hashicorp/ember-flight-icons": "^2.0.5",
|
||||
"@percy/cli": "^1.1.0",
|
||||
"@percy/ember": "^3.0.0",
|
||||
"codemirror": "^5.56.0",
|
||||
|
||||
@@ -13,41 +13,41 @@ const SECURE_TOKEN_ID = '53cur3-v4r14bl35';
|
||||
module('Acceptance | secure variables', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
hooks.beforeEach(async function () {
|
||||
server.createList('variable', 3);
|
||||
});
|
||||
|
||||
module('Guarding page access', function () {
|
||||
test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function (assert) {
|
||||
await Variables.visit();
|
||||
assert.equal(currentURL(), '/jobs');
|
||||
assert.ok(Layout.gutter.variables.isHidden);
|
||||
});
|
||||
test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function (assert) {
|
||||
await Variables.visit();
|
||||
assert.equal(currentURL(), '/jobs');
|
||||
assert.ok(Layout.gutter.variables.isHidden);
|
||||
});
|
||||
|
||||
test('it allows access for management level tokens', async function (assert) {
|
||||
defaultScenario(server);
|
||||
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
|
||||
test('it allows access for management level tokens', async function (assert) {
|
||||
defaultScenario(server);
|
||||
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
|
||||
await Variables.visit();
|
||||
assert.equal(currentURL(), '/variables');
|
||||
assert.ok(Layout.gutter.variables.isVisible, 'Menu section is visible');
|
||||
});
|
||||
|
||||
await Variables.visit();
|
||||
assert.equal(currentURL(), '/variables');
|
||||
assert.ok(Layout.gutter.variables.isVisible);
|
||||
});
|
||||
test('it allows access for list-variables allowed ACL rules', async function (assert) {
|
||||
assert.expect(2);
|
||||
defaultScenario(server);
|
||||
const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID);
|
||||
window.localStorage.nomadTokenSecret = variablesToken.secretId;
|
||||
|
||||
test('it allows access for list-variables allowed ACL rules', async function (assert) {
|
||||
assert.expect(2);
|
||||
defaultScenario(server);
|
||||
const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID);
|
||||
window.localStorage.nomadTokenSecret = variablesToken.secretId;
|
||||
await Variables.visit();
|
||||
assert.equal(currentURL(), '/variables');
|
||||
assert.ok(Layout.gutter.variables.isVisible);
|
||||
});
|
||||
|
||||
await Variables.visit();
|
||||
assert.equal(currentURL(), '/variables');
|
||||
assert.ok(Layout.gutter.variables.isVisible);
|
||||
});
|
||||
|
||||
test('it passes an accessibility audit', async function (assert) {
|
||||
assert.expect(1);
|
||||
defaultScenario(server);
|
||||
const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID);
|
||||
window.localStorage.nomadTokenSecret = variablesToken.secretId;
|
||||
await Variables.visit();
|
||||
await a11yAudit(assert);
|
||||
});
|
||||
test('it passes an accessibility audit', async function (assert) {
|
||||
assert.expect(1);
|
||||
defaultScenario(server);
|
||||
const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID);
|
||||
window.localStorage.nomadTokenSecret = variablesToken.secretId;
|
||||
await Variables.visit();
|
||||
await a11yAudit(assert);
|
||||
});
|
||||
});
|
||||
|
||||
147
ui/tests/integration/components/secure-variable-form-test.js
Normal file
147
ui/tests/integration/components/secure-variable-form-test.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
import { click, findAll, render } from '@ember/test-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
||||
module('Integration | Component | secure-variable-form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
test('passes an accessibility audit', async function (assert) {
|
||||
assert.expect(1);
|
||||
await render(hbs`<SecureVariableForm @model={{this.mockedModel}} />`);
|
||||
await componentA11yAudit(this.element, assert);
|
||||
});
|
||||
|
||||
test('shows a single row by default and modifies on "Add More" and "Delete"', async function (assert) {
|
||||
this.set(
|
||||
'mockedModel',
|
||||
server.create('variable', {
|
||||
keyValues: [{ key: '', value: '' }],
|
||||
})
|
||||
);
|
||||
assert.expect(4);
|
||||
|
||||
await render(hbs`<SecureVariableForm @model={{this.mockedModel}} />`);
|
||||
assert.equal(
|
||||
findAll('div.key-value').length,
|
||||
1,
|
||||
'A single KV row exists by default'
|
||||
);
|
||||
|
||||
await click('.key-value button.add-more');
|
||||
|
||||
assert.equal(
|
||||
findAll('div.key-value').length,
|
||||
2,
|
||||
'A second KV row exists after adding a new one'
|
||||
);
|
||||
|
||||
await click('.key-value button.add-more');
|
||||
|
||||
assert.equal(
|
||||
findAll('div.key-value').length,
|
||||
3,
|
||||
'A third KV row exists after adding a new one'
|
||||
);
|
||||
|
||||
await click('.key-value button.delete-row');
|
||||
|
||||
assert.equal(
|
||||
findAll('div.key-value').length,
|
||||
2,
|
||||
'Back down to two rows after hitting delete'
|
||||
);
|
||||
});
|
||||
|
||||
test('Values can be toggled to show/hide', async function (assert) {
|
||||
this.set(
|
||||
'mockedModel',
|
||||
server.create('variable', {
|
||||
keyValues: [{ key: '', value: '' }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.expect(6);
|
||||
|
||||
await render(hbs`<SecureVariableForm @model={{this.mockedModel}} />`);
|
||||
await click('.key-value button.add-more'); // add a second variable
|
||||
|
||||
findAll('input.value-input').forEach((input, iter) => {
|
||||
assert.equal(
|
||||
input.getAttribute('type'),
|
||||
'password',
|
||||
`Value ${iter + 1} is hidden by default`
|
||||
);
|
||||
});
|
||||
|
||||
await click('.key-value button.show-hide-values');
|
||||
findAll('input.value-input').forEach((input, iter) => {
|
||||
assert.equal(
|
||||
input.getAttribute('type'),
|
||||
'text',
|
||||
`Value ${iter + 1} is shown when toggled`
|
||||
);
|
||||
});
|
||||
|
||||
await click('.key-value button.show-hide-values');
|
||||
findAll('input.value-input').forEach((input, iter) => {
|
||||
assert.equal(
|
||||
input.getAttribute('type'),
|
||||
'password',
|
||||
`Value ${iter + 1} is hidden when toggled again`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Existing variable shows properties by default', async function (assert) {
|
||||
assert.expect(13);
|
||||
const keyValues = [
|
||||
{ key: 'my-completely-normal-key', value: 'never' },
|
||||
{ key: 'another key, but with spaces', value: 'gonna' },
|
||||
{ key: 'once/more/with/slashes', value: 'give' },
|
||||
{ key: 'and_some_underscores', value: 'you' },
|
||||
{ key: 'and\\now/for-something_completely@different', value: 'up' },
|
||||
];
|
||||
|
||||
this.set(
|
||||
'mockedModel',
|
||||
server.create('variable', {
|
||||
path: 'my/path/to',
|
||||
keyValues,
|
||||
})
|
||||
);
|
||||
await render(hbs`<SecureVariableForm @model={{this.mockedModel}} />`);
|
||||
assert.equal(
|
||||
findAll('div.key-value').length,
|
||||
5,
|
||||
'Shows 5 existing key values'
|
||||
);
|
||||
assert.equal(
|
||||
findAll('button.delete-row').length,
|
||||
4,
|
||||
'Shows "delete" for the first four rows'
|
||||
);
|
||||
assert.equal(
|
||||
findAll('button.add-more').length,
|
||||
1,
|
||||
'Shows "add more" only on the last row'
|
||||
);
|
||||
|
||||
findAll('div.key-value').forEach((row, idx) => {
|
||||
assert.equal(
|
||||
row.querySelector(`label:nth-child(1) input`).value,
|
||||
keyValues[idx].key,
|
||||
`Key ${idx + 1} is correct`
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
row.querySelector(`label:nth-child(2) input`).value,
|
||||
keyValues[idx].value,
|
||||
keyValues[idx].value
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
ui/tests/integration/modifiers/autofocus-test.js
Normal file
68
ui/tests/integration/modifiers/autofocus-test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Modifier | autofocus', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('Basic Usage', async function (assert) {
|
||||
await render(hbs`
|
||||
<form>
|
||||
<label>
|
||||
<input data-test-input-1 {{autofocus}} />
|
||||
</label>
|
||||
</form>`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-input-1]')
|
||||
.isFocused('Autofocus on an element works');
|
||||
});
|
||||
|
||||
test('Multiple foci', async function (assert) {
|
||||
await render(hbs`
|
||||
<form>
|
||||
<label>
|
||||
<input data-test-input-1 {{autofocus}} />
|
||||
</label>
|
||||
<label>
|
||||
<input data-test-input-2 {{autofocus}} />
|
||||
</label>
|
||||
</form>`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-input-1]')
|
||||
.isNotFocused('With multiple autofocus elements, priors are unfocused');
|
||||
assert
|
||||
.dom('[data-test-input-2]')
|
||||
.isFocused('With multiple autofocus elements, posteriors are focused');
|
||||
});
|
||||
|
||||
test('Ignore parameter', async function (assert) {
|
||||
await render(hbs`
|
||||
<form>
|
||||
<label>
|
||||
<input data-test-input-1 {{autofocus}} />
|
||||
</label>
|
||||
<label>
|
||||
<input data-test-input-2 {{autofocus}} />
|
||||
</label>
|
||||
<label>
|
||||
<input data-test-input-3 {{autofocus ignore=true}} />
|
||||
</label>
|
||||
<label>
|
||||
<input data-test-input-4 {{autofocus ignore=true}} />
|
||||
</label>
|
||||
</form>`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-input-2]')
|
||||
.isFocused('The last autofocus element without ignore is focused');
|
||||
assert
|
||||
.dom('[data-test-input-3]')
|
||||
.isNotFocused('Ignore parameter is observed, prior');
|
||||
assert
|
||||
.dom('[data-test-input-4]')
|
||||
.isNotFocused('Ignore parameter is observed, posterior');
|
||||
});
|
||||
});
|
||||
@@ -7,98 +7,95 @@ import setupAbility from 'nomad-ui/tests/helpers/setup-ability';
|
||||
module('Unit | Ability | variable', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupAbility('variable')(hooks);
|
||||
|
||||
module('when the Variables feature is not present', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
const mockSystem = Service.extend({
|
||||
features: [],
|
||||
});
|
||||
|
||||
this.owner.register('service:system', mockSystem);
|
||||
hooks.beforeEach(function () {
|
||||
const mockSystem = Service.extend({
|
||||
features: [],
|
||||
});
|
||||
|
||||
test('it does not permit listing variables by default', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
});
|
||||
this.owner.register('service:system', mockSystem);
|
||||
});
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.notOk(this.ability.canList);
|
||||
test('it does not permit listing variables by default', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
});
|
||||
|
||||
test('it does not permit listing variables when token type is client', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
assert.notOk(this.ability.canList);
|
||||
});
|
||||
|
||||
assert.notOk(this.ability.canList);
|
||||
test('it does not permit listing variables when token type is client', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
});
|
||||
|
||||
test('it permits listing variables when token type is management', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'management' },
|
||||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
assert.notOk(this.ability.canList);
|
||||
});
|
||||
|
||||
assert.ok(this.ability.canList);
|
||||
test('it permits listing variables when token type is management', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'management' },
|
||||
});
|
||||
|
||||
test('it permits listing variables when token has SecureVariables with list capabilities in its rules', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'default',
|
||||
Capabilities: [],
|
||||
SecureVariables: {
|
||||
'Path "*"': {
|
||||
Capabilities: ['list'],
|
||||
},
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canList);
|
||||
});
|
||||
|
||||
test('it permits listing variables when token has SecureVariables with list capabilities in its rules', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'default',
|
||||
Capabilities: [],
|
||||
SecureVariables: {
|
||||
'Path "*"': {
|
||||
Capabilities: ['list'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canList);
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test('it permits listing variables when token has SecureVariables alone in its rules', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'default',
|
||||
Capabilities: [],
|
||||
SecureVariables: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canList);
|
||||
});
|
||||
|
||||
test('it permits listing variables when token has SecureVariables alone in its rules', function (assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: 'default',
|
||||
Capabilities: [],
|
||||
SecureVariables: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canList);
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canList);
|
||||
});
|
||||
});
|
||||
|
||||
24
ui/tests/unit/adapters/variable-test.js
Normal file
24
ui/tests/unit/adapters/variable-test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Adapter | Variable', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('Correctly pluralizes lookups with shortened path', async function (assert) {
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.subject = () => this.store.adapterFor('variable');
|
||||
|
||||
let newVariable = await this.store.createRecord('variable');
|
||||
|
||||
assert.equal(
|
||||
this.subject().urlForFindAll('variable'),
|
||||
'/v1/vars',
|
||||
'pluralizes findAll lookup'
|
||||
);
|
||||
assert.equal(
|
||||
this.subject().urlForFindRecord('foo/bar', 'variable', newVariable),
|
||||
`/v1/var/${encodeURIComponent('foo/bar')}`,
|
||||
'singularizes findRecord lookup'
|
||||
);
|
||||
});
|
||||
});
|
||||
33
ui/tests/unit/models/variable-test.js
Normal file
33
ui/tests/unit/models/variable-test.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Model | variable', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it has basic fetchable properties', function (assert) {
|
||||
let store = this.owner.lookup('service:store');
|
||||
|
||||
let model = store.createRecord('variable');
|
||||
model.setProperties({
|
||||
path: 'my/fun/path',
|
||||
namespace: 'default',
|
||||
keyValues: [
|
||||
{ key: 'foo', value: 'bar' },
|
||||
{ key: 'myVar', value: 'myValue' },
|
||||
],
|
||||
});
|
||||
assert.ok(model.path);
|
||||
assert.equal(model.keyValues.length, 2);
|
||||
});
|
||||
|
||||
test('it has a single keyValue by default', function (assert) {
|
||||
let store = this.owner.lookup('service:store');
|
||||
|
||||
let model = store.createRecord('variable');
|
||||
model.setProperties({
|
||||
path: 'my/fun/path',
|
||||
namespace: 'default',
|
||||
});
|
||||
assert.equal(model.keyValues.length, 1);
|
||||
});
|
||||
});
|
||||
14
ui/yarn.lock
14
ui/yarn.lock
@@ -3085,6 +3085,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-1.1.0.tgz#d6dbc7574774b238114582410e8fee0dc3532bdf"
|
||||
integrity sha512-rR7tJoSwJ2eooOpYGxGGW95sLq6GXUaS1UtWvN7pei6n2/okYvCGld9vsUTvkl2migxbkszsycwtMf/GEc1k1A==
|
||||
|
||||
"@hashicorp/ember-flight-icons@^2.0.5":
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-2.0.5.tgz#a1edfdd24475ecd0cf07cd1f944e2e5bdb5e97cc"
|
||||
integrity sha512-PXNk1aRBjYSGeoB4e2ovOBm6RhGKE554XjW8leYYK+y9yorHhJNNwWRkwjhDRLYWikLhNmfwp6nAYOJWl/IOgw==
|
||||
dependencies:
|
||||
"@hashicorp/flight-icons" "^2.3.1"
|
||||
ember-cli-babel "^7.26.11"
|
||||
ember-cli-htmlbars "^6.0.1"
|
||||
|
||||
"@hashicorp/flight-icons@^2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.3.1.tgz#0b0dc259c0a4255c5613174db7192ab48523ca6f"
|
||||
integrity sha512-WGCMMixkmYCP5Dyz4QW7XjW4zDhIc7njkVVucoj7Iv7abtfgQDWwm05Ja2aBJTxFHiP4jat9w9cbGNgC6QHmZQ==
|
||||
|
||||
"@hashicorp/structure-icons@^1.3.0":
|
||||
version "1.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/structure-icons/-/structure-icons-1.9.2.tgz#c75f955b2eec414ecb92f3926c79b4ca01731d3c"
|
||||
|
||||
Reference in New Issue
Block a user