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:
Phil Renaud
2022-05-30 13:10:44 -04:00
committed by Tim Gross
parent ba74aadb90
commit ca5969efdd
36 changed files with 874 additions and 134 deletions

View File

@@ -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[]
}
}

View 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;
}
}

View 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>

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
import Controller from '@ember/controller';
export default class VariablesNewController extends Controller {}

View 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],
};
}
}

View File

@@ -0,0 +1,3 @@
import Controller from '@ember/controller';
export default class VariablesVariableIndexController extends Controller {}

30
ui/app/models/variable.js Normal file
View 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;
}

View 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();
});

View File

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

View File

@@ -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));
}
}

View File

@@ -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 {}

View 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();
}
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class VariablesVariableEditRoute extends Route {}

View 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;
}
}

View File

@@ -46,3 +46,4 @@
@import './components/tooltip';
@import './components/two-step-button';
@import './components/evaluations';
@import './components/secure-variables';

View 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;
// }
}

View File

@@ -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 dont 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>

View 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>

View File

@@ -0,0 +1,6 @@
{{page-title "Secure Variables: " this.model.path}}
<Breadcrumb @crumb={{this.breadcrumb}} />
<section class="section">
{{outlet}}
</section>

View File

@@ -0,0 +1,5 @@
{{page-title "Edit Secure Variable"}}
<section class="section">
<SecureVariableForm @model={{this.model}} />
</section>

View 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>

View File

@@ -1,5 +1,7 @@
{
"compilerOptions": {
"experimentalDecorators": true
}
"experimentalDecorators": true,
"target": "es5",
},
}

View File

@@ -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) {

View 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(),
}
);
},
});

View File

@@ -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

View File

@@ -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",

View File

@@ -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);
});
});

View 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
);
});
});
});

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

View File

@@ -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);
});
});

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

View 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);
});
});

View File

@@ -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"