UI console (#4631)

* adding columnify and ember-cli-cjs-transform

* add yargs-parser

* remove vendored yargs-parser tokenizer and use cjs transform to import it from actual yargs-parser

* add clear command that clears the log, but maintains history

* make codemirror have no gutter and be auto-height when rendered in the console output log

* add fullscreen command and hook up fullscreen toggle button

* hook up copy button
This commit is contained in:
madalynrose
2018-05-25 16:33:22 -04:00
committed by GitHub
parent 3bdfa4ae0a
commit b41b07a373
63 changed files with 2433 additions and 153 deletions

View File

@@ -0,0 +1,8 @@
import ApplicationAdapter from './application';
export default ApplicationAdapter.extend({
namespace: 'v1',
pathForType(modelName) {
return modelName;
},
});

View File

@@ -0,0 +1,36 @@
import Ember from 'ember';
import keys from 'vault/lib/keycodes';
export default Ember.Component.extend({
'data-test-component': 'console/command-input',
classNames: 'console-ui-input',
onExecuteCommand() {},
onFullscreen() {},
onValueUpdate() {},
onShiftCommand() {},
value: null,
isFullscreen: null,
didRender() {
this.element.scrollIntoView();
},
actions: {
handleKeyUp(event) {
const keyCode = event.keyCode;
switch (keyCode) {
case keys.ENTER:
this.get('onExecuteCommand')(event.target.value);
break;
case keys.UP:
case keys.DOWN:
this.get('onShiftCommand')(keyCode);
break;
default:
this.get('onValueUpdate')(event.target.value);
}
},
fullscreen() {
this.get('onFullscreen')();
}
},
});

View File

@@ -0,0 +1,3 @@
import Ember from 'ember';
export default Ember.Component.extend({});

View File

@@ -0,0 +1,3 @@
import Ember from 'ember';
export default Ember.Component.extend({});

View File

@@ -0,0 +1,3 @@
import Ember from 'ember';
export default Ember.Component.extend({});

View File

@@ -0,0 +1,3 @@
import Ember from 'ember';
export default Ember.Component.extend({});

View File

@@ -0,0 +1,9 @@
import Ember from 'ember';
const { computed } = Ember;
export default Ember.Component.extend({
content: null,
list: computed('content', function() {
return this.get('content').keys;
}),
});

View File

@@ -0,0 +1,28 @@
import Ember from 'ember';
import columnify from 'columnify';
const { computed } = Ember;
export function stringifyObjectValues(data) {
Object.keys(data).forEach(item => {
let val = data[item];
if (typeof val !== 'string') {
val = JSON.stringify(val);
}
data[item] = val;
});
}
export default Ember.Component.extend({
content: null,
columns: computed('content', function() {
let data = this.get('content');
stringifyObjectValues(data);
return columnify(data, {
preserveNewLines: true,
headingTransform: function(heading) {
return Ember.String.capitalize(heading);
},
});
}),
});

View File

@@ -0,0 +1,3 @@
import Ember from 'ember';
export default Ember.Component.extend({});

View File

@@ -0,0 +1,3 @@
import Ember from 'ember';
export default Ember.Component.extend({});

View File

@@ -0,0 +1,6 @@
import Ember from 'ember';
export default Ember.Component.extend({
'data-test-component': 'console/output-log',
log: null,
});

View File

@@ -0,0 +1,84 @@
import Ember from 'ember';
import {
parseCommand,
extractDataAndFlags,
logFromResponse,
logFromError,
logErrorFromInput,
executeUICommand,
} from 'vault/lib/console-helpers';
const { inject, computed } = Ember;
export default Ember.Component.extend({
classNames: 'console-ui-panel-scroller',
classNameBindings: ['isFullscreen:fullscreen'],
isFullscreen: false,
console: inject.service(),
inputValue: null,
log: computed.alias('console.log'),
logAndOutput(command, logContent) {
this.set('inputValue', '');
this.get('console').logAndOutput(command, logContent);
},
executeCommand(command, shouldThrow = false) {
let service = this.get('console');
let serviceArgs;
if(executeUICommand(command, (args) => this.logAndOutput(args), (args) => service.clearLog(args), () => this.toggleProperty('isFullscreen'))){
return;
}
// parse to verify it's valid
try {
serviceArgs = parseCommand(command, shouldThrow);
} catch (e) {
this.logAndOutput(command, { type: 'help' });
return;
}
// we have a invalid command but don't want to throw
if (serviceArgs === false) {
return;
}
let [method, flagArray, path, dataArray] = serviceArgs;
if (dataArray || flagArray) {
var { data, flags } = extractDataAndFlags(dataArray, flagArray);
}
let inputError = logErrorFromInput(path, method, flags, dataArray);
if (inputError) {
this.logAndOutput(command, inputError);
return;
}
let serviceFn = service[method];
serviceFn.call(service, path, data, flags.wrapTTL)
.then(resp => {
this.logAndOutput(command, logFromResponse(resp, path, method, flags));
})
.catch(error => {
this.logAndOutput(command, logFromError(error, path, method));
});
},
shiftCommandIndex(keyCode) {
this.get('console').shiftCommandIndex(keyCode, (val) => {
this.set('inputValue', val);
});
},
actions: {
toggleFullscreen() {
this.toggleProperty('isFullscreen');
},
executeCommand(val) {
this.executeCommand(val, true);
},
shiftCommandIndex(direction) {
this.shiftCommandIndex(direction);
},
},
});

View File

@@ -18,6 +18,10 @@ export default IvyCodemirrorComponent.extend({
'data-test-component': 'json-editor',
updateCodeMirrorOptions() {
const options = assign({}, JSON_EDITOR_DEFAULTS, this.get('options'));
if (options.autoHeight) {
options.viewportMargin = Infinity;
delete options.autoHeight;
}
if (options) {
Object.keys(options).forEach(function(option) {

View File

@@ -1,18 +1,21 @@
import Ember from 'ember';
import config from '../config/environment';
const { computed, inject } = Ember;
export default Ember.Controller.extend({
env: config.environment,
auth: Ember.inject.service(),
vaultVersion: Ember.inject.service('version'),
activeCluster: Ember.computed('auth.activeCluster', function() {
auth: inject.service(),
vaultVersion: inject.service('version'),
console: inject.service(),
consoleOpen: computed.alias('console.isOpen'),
activeCluster: computed('auth.activeCluster', function() {
return this.store.peekRecord('cluster', this.get('auth.activeCluster'));
}),
activeClusterName: Ember.computed('auth.activeCluster', function() {
activeClusterName: computed('auth.activeCluster', function() {
const activeCluster = this.store.peekRecord('cluster', this.get('auth.activeCluster'));
return activeCluster ? activeCluster.get('name') : null;
}),
showNav: Ember.computed(
showNav: computed(
'activeClusterName',
'auth.currentToken',
'activeCluster.dr.isSecondary',
@@ -30,4 +33,9 @@ export default Ember.Controller.extend({
}
}
),
actions: {
toggleConsole() {
this.toggleProperty('consoleOpen');
},
},
});

View File

@@ -0,0 +1,7 @@
import Ember from 'ember';
export function multiLineJoin([arr]) {
return arr.join('\n');
}
export default Ember.Helper.helper(multiLineJoin);

View File

@@ -0,0 +1,183 @@
import keys from 'vault/lib/keycodes';
import argTokenizer from 'yargs-parser-tokenizer';
const supportedCommands = ['read', 'write', 'list', 'delete'];
const uiCommands = ['clearall', 'clear', 'fullscreen'];
export function extractDataAndFlags(data, flags) {
return data.concat(flags).reduce((accumulator, val) => {
// will be "key=value" or "-flag=value" or "foo=bar=baz"
// split on the first =
let [item, value] = val.split(/=(.+)/);
if (item.startsWith('-')) {
let flagName = item.replace(/^-/, '');
if (flagName === 'wrap-ttl') {
flagName = 'wrapTTL';
}
accumulator.flags[flagName] = value || true;
return accumulator;
}
// if it exists in data already, then we have multiple
// foo=bar in the list and need to make it an array
if (accumulator.data[item]) {
accumulator.data[item] = [].concat(accumulator.data[item], value);
return accumulator;
}
accumulator.data[item] = value;
return accumulator;
}, { data: {}, flags: {} });
}
export function executeUICommand(command, logAndOutput, clearLog, toggleFullscreen){
const isUICommand = uiCommands.includes(command);
if(isUICommand){
logAndOutput(command);
}
switch(command){
case 'clearall':
clearLog(true);
break;
case 'clear':
clearLog();
break;
case 'fullscreen':
toggleFullscreen();
break;
}
return isUICommand;
}
export function parseCommand(command, shouldThrow) {
let args = argTokenizer(command);
if (args[0] === 'vault') {
args.shift();
}
let [method, ...rest] = args;
let path;
let flags = [];
let data = [];
rest.forEach(arg => {
if (arg.startsWith('-')) {
flags.push(arg);
} else {
if (path) {
data.push(arg);
} else {
path = arg;
}
}
});
if (!supportedCommands.includes(method)) {
if (shouldThrow) {
throw new Error('invalid command');
}
return false;
}
return [method, flags, path, data];
}
export function logFromResponse(response, path, method, flags) {
if (!response) {
let message =
method === 'write'
? `Success! Data written to: ${path}`
: `Success! Data deleted (if it existed) at: ${path}`;
return { type: 'success', content: message };
}
let { format, field } = flags;
let secret = response.auth || response.data || response.wrap_info;
if (field) {
let fieldValue = secret[field];
let response;
if (fieldValue) {
if (format && format === 'json') {
return { type: 'json', content: fieldValue };
}
switch (typeof fieldValue) {
case 'string':
response = { type: 'text', content: fieldValue };
break;
default:
response = { type: 'object', content: fieldValue };
break;
}
} else {
response = { type: 'error', content: `Field "${field}" not present in secret` };
}
return response;
}
if (format && format === 'json') {
// just print whole response
return { type: 'json', content: response };
}
if (method === 'list') {
return { type: 'list', content: secret };
}
return { type: 'object', content: secret };
}
export function logFromError(error, vaultPath, method) {
let content;
let { httpStatus, path } = error;
let verbClause = {
read: 'reading from',
write: 'writing to',
list: 'listing',
delete: 'deleting at',
}[method];
content = `Error ${verbClause}: ${vaultPath}.\nURL: ${path}\nCode: ${httpStatus}`;
if (typeof error.errors[0] === 'string') {
content = `${content}\nErrors:\n ${error.errors.join('\n ')}`;
}
return { type: 'error', content };
}
export function shiftCommandIndex(keyCode, history, index) {
let newInputValue;
let commandHistoryLength = history.length;
if (!commandHistoryLength) { return []; }
if (keyCode === keys.UP) {
index -= 1;
if (index < 0) {
index = commandHistoryLength - 1;
}
} else {
index += 1;
if (index === commandHistoryLength) {
newInputValue = '';
}
if (index > commandHistoryLength) {
index -= 1;
}
}
if (newInputValue !== '') {
newInputValue = history.objectAt(index).content;
}
return [index, newInputValue];
}
export function logErrorFromInput(path, method, flags, dataArray) {
if (path === undefined) {
return { type: 'error', content: 'A path is required to make a request.' };
}
if (method === 'write' && !flags.force && dataArray.length === 0) {
return { type: 'error', content: 'Must supply data or use -force' };
}
}

View File

@@ -1,14 +1,18 @@
import Ember from 'ember';
import ModelBoundaryRoute from 'vault/mixins/model-boundary-route';
const { inject } = Ember;
export default Ember.Route.extend(ModelBoundaryRoute, {
auth: Ember.inject.service(),
flashMessages: Ember.inject.service(),
auth: inject.service(),
flashMessages: inject.service(),
console: inject.service(),
modelTypes: ['secret', 'secret-engine'],
beforeModel() {
this.get('auth').deleteCurrentToken();
this.get('console').set('isOpen', false);
this.get('console').clearLog(true);
this.clearModelCache();
this.replaceWith('vault.cluster');
this.get('flashMessages').clearMessages();

View File

@@ -67,7 +67,7 @@ export default Ember.Route.extend({
} else {
throw err;
}
})
}),
});
},

105
ui/app/services/console.js Normal file
View File

@@ -0,0 +1,105 @@
// Low level service that allows users to input paths to make requests to vault
// this service provides the UI synecdote to the cli commands read, write, delete, and list
import Ember from 'ember';
import {
shiftCommandIndex,
} from 'vault/lib/console-helpers';
const { Service, getOwner, computed } = Ember;
export function sanitizePath(path) {
//remove whitespace + remove trailing and leading slashes
return path.trim().replace(/^\/+|\/+$/g, '');
}
export function ensureTrailingSlash(path) {
return path.replace(/(\w+[^/]$)/g, '$1/');
}
const VERBS = {
read: 'GET',
list: 'GET',
write: 'POST',
delete: 'DELETE',
};
export default Service.extend({
isOpen: false,
adapter() {
return getOwner(this).lookup('adapter:console');
},
commandHistory: computed('log.[]', function() {
return this.get('log').filterBy('type', 'command');
}),
log: computed(function() {
return [];
}),
commandIndex: null,
shiftCommandIndex(keyCode, setCommandFn = () => {}) {
let [newIndex, newCommand] = shiftCommandIndex(
keyCode,
this.get('commandHistory'),
this.get('commandIndex')
);
if (newCommand !== undefined && newIndex !== undefined) {
this.set('commandIndex', newIndex);
setCommandFn(newCommand);
}
},
clearLog(clearAll=false) {
let log = this.get('log');
let history;
if (!clearAll) {
history = this.get('commandHistory').slice();
history.setEach('hidden', true);
}
log.clear();
if (history) {
log.addObjects(history);
}
},
logAndOutput(command, logContent) {
let log = this.get('log');
log.pushObject({ type: 'command', content: command });
this.set('commandIndex', null);
if (logContent) {
log.pushObject(logContent);
}
},
ajax(operation, path, options = {}) {
let verb = VERBS[operation];
let adapter = this.adapter();
let url = adapter.buildURL(path);
let { data, wrapTTL } = options;
return adapter.ajax(url, verb, {
data,
wrapTTL,
});
},
read(path, data, wrapTTL) {
return this.ajax('read', sanitizePath(path), { wrapTTL });
},
write(path, data, wrapTTL) {
return this.ajax('write', sanitizePath(path), { data, wrapTTL });
},
delete(path) {
return this.ajax('delete', sanitizePath(path));
},
list(path, data, wrapTTL) {
let listPath = ensureTrailingSlash(sanitizePath(path));
return this.ajax('list', listPath, {
data: {
list: true,
},
wrapTTL,
});
},
});

View File

@@ -171,3 +171,7 @@ $gutter-grey: #2a2f36;
}
}
}
.cm-s-auto-height.CodeMirror {
height: auto;
}

View File

@@ -0,0 +1,149 @@
.console-ui-panel-scroller {
background: linear-gradient(to right, #191A1C, #1B212D);
height: 0;
left: 0;
min-height: 400px;
overflow: auto;
position: fixed;
right: 0;
transform: translate3d(0, -400px, 0);
transition: min-height $speed ease-out, transform $speed ease-in;
will-change: transform, min-height;
z-index: 199;
}
.console-ui-panel {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: $size-8 $size-8 $size-4;
min-height: 100%;
color: $white;
font-size: $body-size;
font-weight: $font-weight-semibold;
transition: justify-content $speed ease-in;
pre, p {
background: none;
color: inherit;
font-size: $body-size;
&:not(.console-ui-command):not(.CodeMirror-line) {
padding-left: $console-spacing;
}
}
.cm-s-hashi.CodeMirror {
background-color: rgba($black, 0.5) !important;
font-weight: $font-weight-normal;
margin-left: $console-spacing;
padding: $size-8 $size-4;
}
.button,
{
background: transparent;
border: none;
color: $grey-dark;
min-width: 0;
padding: 0 $size-8;
&.active,
&:hover {
background: $blue;
color: $white;
}
}
}
.console-ui-input {
align-items: center;
display: flex;
input {
background-color: rgba($black, 0.5);
border: 0;
caret-color: $white;
color: $white;
flex: 1;
font-family: $family-monospace;
font-size: $body-size;
font-weight: $font-weight-bold;
margin-left: -$size-10;
outline: none;
padding: $size-10;
transition: background-color $speed;
}
}
.console-ui-command {
line-height: 2;
}
.console-ui-output {
transition: background-color $speed;
padding-right: $size-2;
position: relative;
.console-ui-output-actions {
opacity: 0;
position: absolute;
right: 0;
top: 0;
transition: opacity $speed;
will-change: opacity;
}
&:hover {
background: rgba($black, 0.25);
.console-ui-output-actions {
opacity: 1;
}
}
}
.console-ui-alert {
margin-left: calc(#{$console-spacing} - 0.33rem);
position: relative;
.icon {
position: absolute;
left: 0;
top: 0;
}
}
.panel-open .console-ui-panel-scroller {
transform: translate3d(0, 0, 0);
}
.panel-open .console-ui-panel-scroller.fullscreen {
bottom: 0;
top: 0;
min-height: 100%;
}
.panel-open {
.navbar, .navbar-sections{
transition: transform $speed ease-in;
}
}
.panel-open.panel-fullscreen {
.navbar, .navbar-sections{
transform: translate3d(0, -100px, 0);
}
}
.page-container > header {
background: linear-gradient(to right, #191A1C, #1B212D);
}
header .navbar,
header .navbar-sections {
z-index: 200;
transform: translate3d(0, 0, 0);
will-change: transform;
}

View File

@@ -0,0 +1,10 @@
.env-banner {
&,
&:not(:last-child):not(:last-child) {
margin: 0;
}
.level-item {
padding: $size-10 $size-8;
}
}

View File

@@ -59,7 +59,7 @@
.is-status-chevron {
line-height: 0;
padding: 0.25em 0 0.25em 0.25em;
padding: 0.3em 0 0 $size-11;
}
.status-menu-user-trigger {

View File

@@ -5,7 +5,7 @@
.box {
position: relative;
color: $white;
width: 200px;
max-width: 200px;
background: $grey;
padding: 0.5rem;
line-height: 1.4;
@@ -28,6 +28,16 @@
.ember-basic-dropdown-content--left.tool-tip {
margin: 8px 0 0 -11px;
}
.ember-basic-dropdown-content--below.ember-basic-dropdown-content--right.tool-tip {
@include css-top-arrow(8px, $grey, 1px, $grey-dark, calc(100% - 20px));
}
.ember-basic-dropdown-content--above.ember-basic-dropdown-content--right.tool-tip {
@include css-bottom-arrow(8px, $grey, 1px, $grey-dark, calc(100% - 20px));
}
.ember-basic-dropdown-content--above.tool-tip {
margin-top: -2px;
}
.tool-tip-trigger {
border: none;
border-radius: 20px;

View File

@@ -10,7 +10,8 @@
}
.modal-background {
background-image: url("/ui/vault-hex.svg"), linear-gradient(90deg, #191A1C, #1B212D);
background-image: url("/ui/vault-hex.svg"),
linear-gradient(90deg, #191a1c, #1b212d);
opacity: 0.97;
}

View File

@@ -1,4 +1,4 @@
@keyframes vault-loading-animation {
@keyframes vault-loading-animation {
0%,
70%,
100% {
@@ -8,13 +8,13 @@
35% {
transform: scale3D(0, 0, 1);
}
}
}
#vault-loading {
#vault-loading {
polygon {
animation: vault-loading-animation 1.3s infinite ease-in-out;
transform-origin: 50% 50%;
fill: #DCE2E9;
fill: #dce2e9;
}
.vault-loading-order-1 {
@@ -32,16 +32,16 @@
.vault-loading-order-4 {
animation-delay: .4s;
}
}
}
#vault-loading-animated {
#vault-loading-animated {
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
// For IE11
display: none;
}
}
}
#vault-loading-static {
#vault-loading-static {
display: none;
font-size: 9px;
@@ -49,4 +49,4 @@
// For IE11
display: block;
}
}
}

View File

@@ -46,6 +46,8 @@
@import "./components/box-label";
@import "./components/codemirror";
@import "./components/confirm";
@import "./components/console-ui-panel";
@import "./components/env-banner";
@import "./components/form-section";
@import "./components/global-flash";
@import "./components/init-illustration";

View File

@@ -12,7 +12,8 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
min-width: 6rem;
padding: $size-10 $size-8;
text-decoration: none;
transition: background-color $speed, border-color $speed, box-shadow $speed, color $speed;
transition: background-color $speed, border-color $speed, box-shadow $speed,
color $speed;
vertical-align: middle;
&.is-icon {

View File

@@ -41,5 +41,8 @@ input::-webkit-inner-spin-button {
margin: 0;
padding: 0;
text-decoration: underline;
-moz-user-select: text;
-webkit-user-select: text; /* Chrome all / Safari all */
-moz-user-select: text; /* Firefox all */
-ms-user-select: text; /* IE 10+ */
user-select: text;
}

View File

@@ -37,7 +37,9 @@ $border: $grey-light;
$hr-margin: 1rem 0;
//typography
$family-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
$family-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
$family-primary: $family-sans;
$body-size: 14px;
$size-3: (24/14) + 0rem;
@@ -46,6 +48,7 @@ $size-8: (12/14) + 0rem;
$size-9: 0.75rem;
$size-10: 0.5rem;
$size-11: 0.25rem;
$console-spacing: 1.5rem;
$size-small: $size-8;
$font-weight-normal: 400;
$font-weight-semibold: 600;

View File

@@ -1,11 +1,15 @@
@mixin css-top-arrow($size, $color, $border-width, $border-color, $left: 50%, $left-offset: 0px) {
@mixin css-arrow($vertical-direction, $size, $color, $border-width, $border-color, $left: 50%, $left-offset: 0px) {
& {
border: 1px solid $border-color;
}
&:after,
&:before {
@if ($vertical-direction == 'top') {
bottom: 100%;
} @else {
top: 100%;
}
border: solid transparent;
content: " ";
height: 0;
@@ -28,6 +32,12 @@
left: calc(#{$left} + #{$left-offset});
margin-left: -($size + round(1.41421356 * $border-width));
}
&:before,
&:after {
@if ($vertical-direction == 'bottom') {
transform: rotate(180deg);
}
}
@at-root .ember-basic-dropdown-content--left#{&} {
&:after,
@@ -38,6 +48,13 @@
}
}
@mixin css-top-arrow($size, $color, $border-width, $border-color, $left: 50%, $left-offset: 0px) {
@include css-arrow('top', $size, $color, $border-width, $border-color, $left, $left-offset);
}
@mixin css-bottom-arrow($size, $color, $border-width, $border-color, $left: 50%, $left-offset: 0px) {
@include css-arrow('bottom', $size, $color, $border-width, $border-color, $left, $left-offset);
}
@mixin vault-block {
&:not(:last-child) {
margin-bottom: (5/14) + 0rem;

View File

@@ -1,6 +1,6 @@
<div class="page-container">
{{#if showNav}}
<header data-test-header-with-nav>
<header data-test-header-with-nav class="{{if consoleOpen 'panel-open'}} {{if consoleFullscreen ' panel-fullscreen'}}">
<nav class="navbar has-dark-grey-gradient is-grouped-split">
<div class="navbar-brand">
{{#home-link class="navbar-item has-text-white has-current-color-fill"}}
@@ -8,6 +8,17 @@
{{/home-link}}
</div>
<div class="navbar-end is-divider-list is-flex">
<div class="navbar-item">
<button type="button" class="button is-transparent" {{action 'toggleConsole'}}>
{{#if consoleOpen}}
{{i-con glyph="console-active" size=24}}
{{i-con glyph="chevron-up" aria-hidden="true" size=8 class="has-text-white auto-width is-status-chevron"}}
{{else}}
{{i-con glyph="console" size=24}}
{{i-con glyph="chevron-down" aria-hidden="true" size=8 class="has-text-white auto-width is-status-chevron"}}
{{/if}}
</button>
</div>
<div class="navbar-item">
{{status-menu}}
</div>
@@ -55,6 +66,7 @@
</a>
</li>
</ul>
{{console/ui-panel isFullscreen=consoleFullscreen}}
</header>
{{/if}}
<div class="global-flash">
@@ -129,7 +141,7 @@
</div>
</footer>
{{#if (eq env "development") }}
<div class="level development">
<div class="env-banner level development">
<div class="level-item notification has-background-dark has-text-white">
{{i-con glyph="wand" class="type-icon"}}Local Development
</div>

View File

@@ -0,0 +1,16 @@
{{i-con glyph="chevron-right" size=12}}
<input onkeyup={{action 'handleKeyUp'}} value={{value}} />
{{#tool-tip horizontalPosition="auto-right" verticalPosition=(if isFullscreen "above" "below") as |d|}}
{{#d.trigger tagName="button" type="button" class=(concat "button is-compact" (if isFullscreen " active")) click=(action "fullscreen") data-test-tool-tip-trigger=true}}
{{i-con glyph=(if isFullscreen "fullscreen-close" "fullscreen-open") aria-hidden="true" size=16}}
{{/d.trigger}}
{{#d.content class="tool-tip"}}
<div class="box">
{{#if isFullscreen}}
Minimize
{{else}}
Maximize
{{/if}}
</div>
{{/d.content}}
{{/tool-tip}}

View File

@@ -0,0 +1 @@
<pre class="console-ui-command">{{i-con glyph="chevron-right" size=12}}{{content}}</pre>

View File

@@ -0,0 +1,4 @@
<div class="console-ui-alert has-text-danger">
{{i-con glyph="close-circled" aria-hidden="true" size=12}}
<pre>{{content}}</pre>
</div>

View File

@@ -0,0 +1,16 @@
<div class="console-ui-alert has-text-grey">
{{i-con glyph="information-circled" aria-hidden="true" size=12}}
<pre>Usage: vault &lt;command&gt; [args]
Commands:
read Read data and retrieves secrets
write Write data, configuration, and secrets
delete Delete secrets and configuration
list List data or secrets
Web CLI Commands:
fullscreen Toggle fullscreen display
clear Clear output from the log
clearall Clear output and command history
</pre>
</div>

View File

@@ -0,0 +1,10 @@
{{json-editor
value=(stringify content)
options=(hash
readOnly=true
lineNumbers=false
autoHeight=true
gutters=false
theme='hashi auto-height'
)
}}

View File

@@ -0,0 +1,21 @@
<div class="console-ui-output">
<pre>Keys
{{#each list as |item|}}
{{item}}
{{/each}}
</pre>
<div class="console-ui-output-actions">
{{#tool-tip renderInPlace=true as |d|}}
{{#d.trigger data-test-tool-tip-trigger=true}}
{{#copy-button clipboardText=(multi-line-join list) class="button is-compact"}}
{{i-con glyph="copy" aria-hidden="true" size=16}}
{{/copy-button}}
{{/d.trigger}}
{{#d.content class="tool-tip"}}
<div class="box">
Copy
</div>
{{/d.content}}
{{/tool-tip}}
</div>
</div>

View File

@@ -0,0 +1,18 @@
<div class="console-ui-output">
<pre>{{columns}}</pre>
<div class="console-ui-output-actions">
{{#tool-tip renderInPlace=true as |d|}}
{{#d.trigger data-test-tool-tip-trigger=true}}
{{#copy-button clipboardText=columns class="button is-compact"}}
{{i-con glyph="copy" aria-hidden="true" size=16}}
{{/copy-button}}
{{/d.trigger}}
{{#d.content class="tool-tip"}}
<div class="box">
Copy
</div>
{{/d.content}}
{{/tool-tip}}
</div>
</div>

View File

@@ -0,0 +1,4 @@
<div class="console-ui-alert has-text-success">
{{i-con glyph="checkmark-circled" aria-hidden="true" size=12}}
<pre>{{content}}</pre>
</div>

View File

@@ -0,0 +1 @@
<pre>{{content}}</pre>

View File

@@ -0,0 +1,5 @@
{{#each log as |message|}}
{{#unless message.hidden}}
{{component (concat 'console/log-' message.type) content=message.content}}
{{/unless}}
{{/each}}

View File

@@ -0,0 +1,16 @@
<div class="console-ui-panel">
<div class="content">
<p class="has-text-grey is-font-mono">
The Vault Browser CLI provides an easy way to execute the most common CLI commands, such as write, read, delete, and list.
</p>
</div>
{{console/output-log log=log}}
{{console/command-input
isFullscreen=isFullscreen
value=inputValue
onValueUpdate=(action (mut inputValue))
onFullscreen=(action 'toggleFullscreen')
onExecuteCommand=(action 'executeCommand')
onShiftCommand=(action 'shiftCommandIndex')
}}
</div>

View File

@@ -0,0 +1,21 @@
<g transform="translate(458 28)">
<path
d="M2.8,39h-409.6c-17,0-30.7,13.9-30.7,30.9V400c0,17.1,13.8,30.9,30.7,30.9H2.8c17,0,30.7-13.9,30.7-30.9 V70C33.5,52.9,19.8,39,2.8,39z"
fill="#0068FF"
/>
<path
d="M2.8,18.4h-409.6c-28.3,0-51.2,23.1-51.2,51.6V400c0,28.5,22.9,51.6,51.2,51.6H2.8 c28.3,0,51.2-23.1,51.2-51.6V70C54,41.5,31.1,18.4,2.8,18.4z M33.5,400c0,17.1-13.8,30.9-30.7,30.9h-409.6 c-17,0-30.7-13.9-30.7-30.9V70c0-17.1,13.8-30.9,30.7-30.9H2.8c17,0,30.7,13.9,30.7,30.9V400z"
fill="#8AB1FF"
/>
<polygon
points="-241.9,235.9 -241.7,235.7 -262.8,214.6 -263,214.8 -319.1,158.7 -340.2,179.8 -284.1,235.9 -340.2,292 -319.1,313.1 -263,257 -262.8,257.2 -241.7,236.1"
fill="#fff"
/>
<rect
x="-241.7"
y="283.5"
width="157.5"
height="29.5"
fill="#fff"
/>
</g>

View File

@@ -0,0 +1,17 @@
<g transform="translate(458 28)">
<path
d="M2.8,18.4h-409.6c-28.3,0-51.2,23.1-51.2,51.6V400c0,28.5,22.9,51.6,51.2,51.6H2.8 c28.3,0,51.2-23.1,51.2-51.6V70C54,41.5,31.1,18.4,2.8,18.4z M33.5,400c0,17.1-13.8,30.9-30.7,30.9h-409.6 c-17,0-30.7-13.9-30.7-30.9V70c0-17.1,13.8-30.9,30.7-30.9H2.8c17,0,30.7,13.9,30.7,30.9V400z"
fill="#B3B9C0"
/>
<polygon
points="-241.9,235.9 -241.7,235.7 -262.8,214.6 -263,214.8 -319.1,158.7 -340.2,179.8 -284.1,235.9 -340.2,292 -319.1,313.1 -263,257 -262.8,257.2 -241.7,236.1"
fill="#fff"
/>
<rect
x="-241.7"
y="283.5"
width="157.5"
height="29.5"
fill="#fff"
/>
</g>

View File

@@ -0,0 +1 @@
<path d="M272.769063,297.207977 L173.82716,297.207977 L173.82716,465.646091 C173.82716,464.098951 174.365679,464.592593 176.987654,464.592593 L455.111111,464.592593 C457.733087,464.592593 458.271605,464.098951 458.271605,465.646091 L458.271605,141.168724 C458.271605,142.715864 457.733087,142.222222 455.111111,142.222222 L176.987654,142.222222 C174.365679,142.222222 173.82716,142.715864 173.82716,141.168724 L173.82716,260.740741 L280.463115,260.740741 L230.986572,211.264198 L256.515702,185.735068 L345.867656,275.087023 L345.827346,275.127333 L345.867656,275.167643 L256.515702,364.519598 L230.986572,338.990468 L272.769063,297.207977 Z M126.419753,260.740741 L126.419753,141.168724 C126.419753,115.568167 149.059774,94.8148148 176.987654,94.8148148 L300.246914,94.8148148 L300.246914,47.4074074 L47.4074074,47.4074074 L47.4074074,335.012346 L126.419753,335.012346 L126.419753,297.207977 L79.0123457,297.207977 L79.0123457,260.740741 L126.419753,260.740741 Z M126.419753,382.419753 L46.3539095,382.419753 C20.7533522,382.419753 0,363.395847 0,339.928669 L0,42.4910837 C0,19.0239062 20.7533522,0 46.3539095,0 L301.300412,0 C326.900969,0 347.654321,19.0239062 347.654321,42.4910837 L347.654321,94.8148148 L455.111111,94.8148148 C483.038992,94.8148148 505.679012,115.568167 505.679012,141.168724 L505.679012,465.646091 C505.679012,491.246648 483.038992,512 455.111111,512 L176.987654,512 C149.059774,512 126.419753,491.246648 126.419753,465.646091 L126.419753,382.419753 Z"/>

View File

@@ -0,0 +1 @@
<path d="M186.527813,356.143158 L47.8085404,494.86243 L21.6999823,468.753872 L165.927777,324.526077 L53.6999823,324.526077 L53.6999823,287.97114 L223.02503,287.97114 L223.02503,288.02886 L223.08275,288.02886 L223.08275,461.722531 L186.527813,461.722531 L186.527813,356.143158 Z M332.526077,154.910732 L471.245349,16.1914596 L497.353908,42.3000177 L353.126113,186.527813 L465.308859,186.527813 L465.308859,223.08275 L296.02886,223.08275 L296.02886,223.02503 L295.97114,223.02503 L295.97114,53.6999823 L332.526077,53.6999823 L332.526077,154.910732 Z"/>

View File

@@ -0,0 +1 @@
<path d="M36.5260773,442.910732 L175.245349,304.19146 L201.353908,330.300018 L57.1261127,474.527813 L159.08275,474.527813 L159.08275,511.08275 L0.0288599258,511.08275 L0.0288599258,511.02503 L-0.0288599258,511.02503 L-0.0288599258,351.97114 L36.5260773,351.97114 L36.5260773,442.910732 Z M474.527813,68.143158 L335.80854,206.86243 L309.699982,180.753872 L453.927777,36.5260773 L351.97114,36.5260773 L351.97114,-0.0288599258 L511.02503,-0.0288599258 L511.02503,0.0288599258 L511.08275,0.0288599258 L511.08275,159.08275 L474.527813,159.08275 L474.527813,68.143158 Z"/>

View File

@@ -55,6 +55,18 @@ module.exports = function(defaults) {
app.import('node_modules/text-encoder-lite/index.js');
app.import('node_modules/Duration.js/duration.js');
app.import('node_modules/columnify/columnify.js', {
using: [
{ transformation: 'cjs', as: 'columnify' }
]
});
app.import('node_modules/yargs-parser/lib/tokenize-arg-string.js', {
using: [
{ transformation: 'cjs', as: 'yargs-parser-tokenizer' }
]
});
// Use `app.import` to add additional libraries to the generated
// output files.
//

View File

@@ -14,7 +14,7 @@
"start2": "ember server --proxy=http://localhost:8202 --port=4202",
"test": "node scripts/start-vault.js & ember test",
"test-oss": "yarn run test -f='!enterprise'",
"fmt-js": "prettier-eslint --single-quote --trailing-comma es5 --print-width=110 --write {app,tests,config,lib,mirage}/**/*.js",
"fmt-js": "prettier-eslint --single-quote --no-use-tabs --trailing-comma es5 --print-width=110 --write '{app,tests,config,lib,mirage}/**/*.js'",
"fmt-styles": "prettier --write app/styles/**/*.*",
"fmt": "yarn run fmt-js && yarn run fmt-styles",
"precommit": "lint-staged"
@@ -33,19 +33,24 @@
}
},
"devDependencies": {
"Duration.js": "icholy/Duration.js#golang_compatible",
"autosize": "3.0.17",
"babel-plugin-transform-object-rest-spread": "^6.23.0",
"base64-js": "1.2.1",
"broccoli-asset-rev": "^2.4.5",
"broccoli-sri-hash": "meirish/broccoli-sri-hash#rooturl",
"bulma": "^0.5.2",
"bulma-switch": "^0.0.1",
"codemirror": "5.15.2",
"cool-checkboxes-for-bulma.io": "^1.1.0",
"ember-ajax": "^3.0.0",
"ember-api-actions": "^0.1.8",
"ember-basic-dropdown": "^0.33.5",
"ember-basic-dropdown-hover": "^0.2.0",
"ember-cli": "~2.15.0",
"ember-cli": "~2.16.0",
"ember-cli-autoprefixer": "^0.8.1",
"ember-cli-babel": "^6.3.0",
"ember-cli-cjs-transform": "^1.2.0",
"ember-cli-clipboard": "^0.8.0",
"ember-cli-content-security-policy": "^1.0.0",
"ember-cli-dependency-checker": "^1.3.0",
@@ -83,18 +88,16 @@
"ember-test-selectors": "^0.3.6",
"ember-truth-helpers": "1.2.0",
"ivy-codemirror": "2.1.0",
"jsonlint": "1.6.0",
"loader.js": "^4.2.3",
"normalize.css": "4.1.1",
"prettier": "^1.5.3",
"prettier-eslint-cli": "^4.2.1",
"qunit-dom": "^0.6.2",
"string.prototype.startswith": "mathiasbynens/String.prototype.startsWith",
"text-encoder-lite": "1.0.0",
"base64-js": "1.2.1",
"autosize": "3.0.17",
"jsonlint": "1.6.0",
"codemirror": "5.15.2",
"Duration.js": "icholy/Duration.js#golang_compatible",
"string.prototype.startswith": "mathiasbynens/String.prototype.startsWith"
"columnify": "^1.5.4",
"yargs-parser": "^10.0.0"
},
"engines": {
"node": "^4.5 || 6.* || >= 7.*"

View File

@@ -0,0 +1,15 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('console/log-command', 'Integration | Component | console/log command', {
integration: true,
});
test('it renders', function(assert) {
const commandText = 'list this/path';
this.set('content', commandText);
this.render(hbs`{{console/log-command content=content}}`);
assert.dom('pre').includesText(commandText);
});

View File

@@ -0,0 +1,13 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('console/log-error', 'Integration | Component | console/log error', {
integration: true,
});
test('it renders', function(assert) {
const errorText = 'Error deleting at: sys/foo.\nURL: v1/sys/foo\nCode: 404';
this.set('content', errorText);
this.render(hbs`{{console/log-error content=content}}`);
assert.dom('pre').includesText(errorText);
});

View File

@@ -0,0 +1,24 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('console/log-json', 'Integration | Component | console/log json', {
integration: true,
beforeEach() {
this.inject.service('code-mirror', { as: 'codeMirror' });
},
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
const objectContent = { one: 'two', three: 'four', seven: { five: 'six' }, eight: [5, 6] };
const expectedText = JSON.stringify(objectContent, null, 2);
this.set('content', objectContent);
this.render(hbs`{{console/log-json content=content}}`);
const instance = this.codeMirror.instanceFor(this.$('[data-test-component=json-editor]').attr('id'));
assert.equal(instance.getValue(), expectedText);
});

View File

@@ -0,0 +1,19 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('console/log-list', 'Integration | Component | console/log list', {
integration: true,
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
const listContent = { keys: ['one', 'two'] };
const expectedText = 'Keys\none\ntwo';
this.set('content', listContent);
this.render(hbs`{{console/log-list content=content}}`);
assert.dom('pre').includesText(`${expectedText}`);
});

View File

@@ -0,0 +1,27 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import columnify from 'columnify';
import { capitalize } from 'vault/helpers/capitalize';
import { stringifyObjectValues } from 'vault/components/console/log-object';
moduleForComponent('console/log-object', 'Integration | Component | console/log object', {
integration: true,
});
test('it renders', function(assert) {
const objectContent = { one: 'two', three: 'four', seven: { five: 'six' }, eight: [5, 6] };
const data = { one: 'two', three: 'four', seven: { five: 'six' }, eight: [5, 6] };
stringifyObjectValues(data);
const expectedText = columnify(data, {
preserveNewLines: true,
headingTransform: function(heading) {
return capitalize([heading]);
},
});
this.set('content', objectContent);
this.render(hbs`{{console/log-object content=content}}`);
assert.dom('pre').includesText(`${expectedText}`);
});

View File

@@ -0,0 +1,17 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('console/log-text', 'Integration | Component | console/log text', {
integration: true,
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
const text = 'Success! You did a thing!';
this.set('content', text);
this.render(hbs`{{console/log-text content=content}}`);
assert.dom('pre').includesText(text);
});

View File

@@ -0,0 +1,118 @@
import { moduleForComponent, test } from 'ember-qunit';
import { create } from 'ember-cli-page-object';
import wait from 'ember-test-helpers/wait';
import uiPanel from 'vault/tests/pages/components/console/ui-panel';
import hbs from 'htmlbars-inline-precompile';
const component = create(uiPanel);
moduleForComponent('console/ui-panel', 'Integration | Component | console/ui panel', {
integration: true,
beforeEach() {
component.setContext(this);
},
afterEach() {
component.removeContext();
},
});
test('it renders', function(assert) {
this.render(hbs`{{console/ui-panel}}`);
assert.ok(component.hasInput);
});
test('it clears console input on enter', function(assert) {
this.render(hbs`{{console/ui-panel}}`);
component.consoleInput('list this/thing/here').enter();
return wait().then(() => {
assert.equal(component.consoleInputValue, '', 'empties input field on enter');
});
});
test('it clears the log when using clear command', function(assert) {
this.render(hbs`{{console/ui-panel}}`);
component.consoleInput('list this/thing/here').enter();
component.consoleInput('list this/other/thing').enter();
component.consoleInput('read another/thing').enter();
wait().then(() => {
assert.notEqual(component.logOutput, '', 'there is output in the log');
component.consoleInput('clear').enter();
});
wait().then(() => component.up());
return wait().then(() => {
assert.equal(component.logOutput, '', 'clears the output log');
assert.equal(
component.consoleInputValue,
'clear',
'populates console input with previous command on up after enter'
);
});
});
test('it adds command to history on enter', function(assert) {
this.render(hbs`{{console/ui-panel}}`);
component.consoleInput('list this/thing/here').enter();
wait().then(() => component.up());
wait().then(() => {
assert.equal(
component.consoleInputValue,
'list this/thing/here',
'populates console input with previous command on up after enter'
);
});
wait().then(() => component.down());
return wait().then(() => {
assert.equal(component.consoleInputValue, '', 'populates console input with next command on down');
});
});
test('it cycles through history with more than one command', function(assert) {
this.render(hbs`{{console/ui-panel}}`);
component.consoleInput('list this/thing/here').enter();
wait().then(() => component.consoleInput('read that/thing/there').enter());
wait().then(() => component.consoleInput('qwerty').enter());
wait().then(() => component.up());
wait().then(() => {
assert.equal(
component.consoleInputValue,
'qwerty',
'populates console input with previous command on up after enter'
);
});
wait().then(() => component.up());
wait().then(() => {
assert.equal(
component.consoleInputValue,
'read that/thing/there',
'populates console input with previous command on up'
);
});
wait().then(() => component.up());
wait().then(() => {
assert.equal(
component.consoleInputValue,
'list this/thing/here',
'populates console input with previous command on up'
);
});
wait().then(() => component.up());
wait().then(() => {
assert.equal(
component.consoleInputValue,
'qwerty',
'populates console input with initial command if cycled through all previous commands'
);
});
wait().then(() => component.down());
return wait().then(() => {
assert.equal(
component.consoleInputValue,
'',
'clears console input if down pressed after history is on most recent command'
);
});
});

View File

@@ -0,0 +1,18 @@
import { text, triggerable, fillable, value, isPresent } from 'ember-cli-page-object';
import keys from 'vault/lib/keycodes';
export default {
consoleInput: fillable('[data-test-component="console/command-input"] input'),
consoleInputValue: value('[data-test-component="console/command-input"] input'),
logOutput: text('[data-test-component="console/output-log"]'),
up: triggerable('keyup', '[data-test-component="console/command-input"] input', {
eventProperties: { keyCode: keys.UP },
}),
down: triggerable('keyup', '[data-test-component="console/command-input"] input', {
eventProperties: { keyCode: keys.DOWN },
}),
enter: triggerable('keyup', '[data-test-component="console/command-input"] input', {
eventProperties: { keyCode: keys.ENTER },
}),
hasInput: isPresent('[data-test-component="console/command-input"] input'),
};

View File

@@ -0,0 +1,13 @@
import { moduleFor, test } from 'ember-qunit';
moduleFor('adapter:console', 'Unit | Adapter | console', {
needs: ['service:auth', 'service:flash-messages', 'service:version'],
});
test('it builds the correct URL', function(assert) {
let adapter = this.subject();
let sysPath = 'sys/health';
let awsPath = 'aws/roles/my-other-role';
assert.equal(adapter.buildURL(sysPath), '/v1/sys/health');
assert.equal(adapter.buildURL(awsPath), '/v1/aws/roles/my-other-role');
});

View File

@@ -0,0 +1,328 @@
import { module, test } from 'qunit';
import {
parseCommand,
extractDataAndFlags,
logFromResponse,
logFromError,
logErrorFromInput,
} from 'vault/lib/console-helpers';
module('lib/console-helpers', 'Unit | Lib | console helpers');
const testCommands = [
{
name: 'write with data',
command: `vault write aws/config/root \
access_key=AKIAJWVN5Z4FOFT7NLNA \
secret_key=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 \
region=us-east-1`,
expected: [
'write',
[],
'aws/config/root',
[
'access_key=AKIAJWVN5Z4FOFT7NLNA',
'secret_key=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',
'region=us-east-1',
],
],
},
{
name: 'read with field',
command: `vault read -field=access_key aws/creds/my-role`,
expected: ['read', ['-field=access_key'], 'aws/creds/my-role', []],
},
];
testCommands.forEach(function(testCase) {
test(`#parseCommand: ${testCase.name}`, function(assert) {
let result = parseCommand(testCase.command);
assert.deepEqual(result, testCase.expected);
});
});
test('#parseCommand: invalid commands', function(assert) {
let command = 'vault kv get foo';
let result = parseCommand(command);
assert.equal(result, false, 'parseCommand returns false by default');
assert.throws(
() => {
parseCommand(command, true);
},
/invalid command/,
'throws on invalid command when `shouldThrow` is true'
);
});
const testExtractCases = [
{
name: 'data fields',
input: [
[
'access_key=AKIAJWVN5Z4FOFT7NLNA',
'secret_key=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',
'region=us-east-1',
],
[],
],
expected: {
data: {
access_key: 'AKIAJWVN5Z4FOFT7NLNA',
secret_key: 'R4nm063hgMVo4BTT5xOs5nHLeLXA6lar7ZJ3Nt0i',
region: 'us-east-1',
},
flags: {},
},
},
{
name: 'repeated data and a flag',
input: [['allowed_domains=example.com', 'allowed_domains=foo.example.com'], ['-wrap-ttl=2h']],
expected: {
data: {
allowed_domains: ['example.com', 'foo.example.com'],
},
flags: {
wrapTTL: '2h',
},
},
},
{
name: 'data with more than one equals sign',
input: [['foo=bar=baz', 'foo=baz=bop', 'some=value=val'], []],
expected: {
data: {
foo: ['bar=baz', 'baz=bop'],
some: 'value=val',
},
flags: {},
},
},
];
testExtractCases.forEach(function(testCase) {
test(`#extractDataAndFlags: ${testCase.name}`, function(assert) {
let { data, flags } = extractDataAndFlags(...testCase.input);
assert.deepEqual(data, testCase.expected.data, 'has expected data');
assert.deepEqual(flags, testCase.expected.flags, 'has expected flags');
});
});
let testResponseCases = [
{
name: 'write response, no content',
args: [null, 'foo/bar', 'write', {}],
expectedData: {
type: 'success',
content: 'Success! Data written to: foo/bar',
},
},
{
name: 'delete response, no content',
args: [null, 'foo/bar', 'delete', {}],
expectedData: {
type: 'success',
content: 'Success! Data deleted (if it existed) at: foo/bar',
},
},
{
name: 'write, with content',
args: [{ data: { one: 'two' } }, 'foo/bar', 'write', {}],
expectedData: {
type: 'object',
content: { one: 'two' },
},
},
{
name: 'with wrap-ttl flag',
args: [{ wrap_info: { one: 'two' } }, 'foo/bar', 'read', { wrapTTL: '1h' }],
expectedData: {
type: 'object',
content: { one: 'two' },
},
},
{
name: 'with -format=json flag and wrap-ttl flag',
args: [{ foo: 'bar', wrap_info: { one: 'two' } }, 'foo/bar', 'read', { format: 'json', wrapTTL: '1h' }],
expectedData: {
type: 'json',
content: { foo: 'bar', wrap_info: { one: 'two' } },
},
},
{
name: 'with -format=json and -field flags',
args: [{ foo: 'bar', data: { one: 'two' } }, 'foo/bar', 'read', { format: 'json', field: 'one' }],
expectedData: {
type: 'json',
content: 'two',
},
},
{
name: 'with -format=json and -field, and -wrap-ttl flags',
args: [
{ foo: 'bar', wrap_info: { one: 'two' } },
'foo/bar',
'read',
{ format: 'json', wrapTTL: '1h', field: 'one' },
],
expectedData: {
type: 'json',
content: 'two',
},
},
{
name: 'with string field flag and wrap-ttl flag',
args: [{ foo: 'bar', wrap_info: { one: 'two' } }, 'foo/bar', 'read', { field: 'one', wrapTTL: '1h' }],
expectedData: {
type: 'text',
content: 'two',
},
},
{
name: 'with object field flag and wrap-ttl flag',
args: [
{ foo: 'bar', wrap_info: { one: { two: 'three' } } },
'foo/bar',
'read',
{ field: 'one', wrapTTL: '1h' },
],
expectedData: {
type: 'object',
content: { two: 'three' },
},
},
{
name: 'with response data and string field flag',
args: [{ foo: 'bar', data: { one: 'two' } }, 'foo/bar', 'read', { field: 'one', wrapTTL: '1h' }],
expectedData: {
type: 'text',
content: 'two',
},
},
{
name: 'with response data and object field flag ',
args: [
{ foo: 'bar', data: { one: { two: 'three' } } },
'foo/bar',
'read',
{ field: 'one', wrapTTL: '1h' },
],
expectedData: {
type: 'object',
content: { two: 'three' },
},
},
{
name: 'response with data',
args: [{ foo: 'bar', data: { one: 'two' } }, 'foo/bar', 'read', {}],
expectedData: {
type: 'object',
content: { one: 'two' },
},
},
{
name: 'with response data, field flag, and field missing',
args: [{ foo: 'bar', data: { one: 'two' } }, 'foo/bar', 'read', { field: 'foo' }],
expectedData: {
type: 'error',
content: 'Field "foo" not present in secret',
},
},
{
name: 'with response data and auth block',
args: [{ data: { one: 'two' }, auth: { three: 'four' } }, 'auth/token/create', 'write', {}],
expectedData: {
type: 'object',
content: { three: 'four' },
},
},
{
name: 'with -field and -format with an object field',
args: [{ data: { one: { three: 'two' } } }, 'sys/mounts', 'read', { field: 'one', format: 'json' }],
expectedData: {
type: 'json',
content: { three: 'two' },
},
},
{
name: 'with -field and -format with a string field',
args: [{ data: { one: 'two' } }, 'sys/mounts', 'read', { field: 'one', format: 'json' }],
expectedData: {
type: 'json',
content: 'two',
},
},
];
testResponseCases.forEach(function(testCase) {
test(`#logFromResponse: ${testCase.name}`, function(assert) {
let data = logFromResponse(...testCase.args);
assert.deepEqual(data, testCase.expectedData);
});
});
let testErrorCases = [
{
name: 'AdapterError write',
args: [{ httpStatus: 404, path: 'v1/sys/foo', errors: [{}] }, 'sys/foo', 'write'],
expectedContent: 'Error writing to: sys/foo.\nURL: v1/sys/foo\nCode: 404',
},
{
name: 'AdapterError read',
args: [{ httpStatus: 404, path: 'v1/sys/foo', errors: [{}] }, 'sys/foo', 'read'],
expectedContent: 'Error reading from: sys/foo.\nURL: v1/sys/foo\nCode: 404',
},
{
name: 'AdapterError list',
args: [{ httpStatus: 404, path: 'v1/sys/foo', errors: [{}] }, 'sys/foo', 'list'],
expectedContent: 'Error listing: sys/foo.\nURL: v1/sys/foo\nCode: 404',
},
{
name: 'AdapterError delete',
args: [{ httpStatus: 404, path: 'v1/sys/foo', errors: [{}] }, 'sys/foo', 'delete'],
expectedContent: 'Error deleting at: sys/foo.\nURL: v1/sys/foo\nCode: 404',
},
{
name: 'VaultError single error',
args: [{ httpStatus: 404, path: 'v1/sys/foo', errors: ['no client token'] }, 'sys/foo', 'delete'],
expectedContent: 'Error deleting at: sys/foo.\nURL: v1/sys/foo\nCode: 404\nErrors:\n no client token',
},
{
name: 'VaultErrors multiple errors',
args: [
{ httpStatus: 404, path: 'v1/sys/foo', errors: ['no client token', 'this is an error'] },
'sys/foo',
'delete',
],
expectedContent:
'Error deleting at: sys/foo.\nURL: v1/sys/foo\nCode: 404\nErrors:\n no client token\n this is an error',
},
];
testErrorCases.forEach(function(testCase) {
test(`#logFromError: ${testCase.name}`, function(assert) {
let data = logFromError(...testCase.args);
assert.deepEqual(data, { type: 'error', content: testCase.expectedContent }, 'returns the expected data');
});
});
const testCommandCases = [
{
name: 'errors when command does not include a path',
args: [],
expectedContent: 'A path is required to make a request.',
},
{
name: 'errors when write command does not include data and does not have force tag',
args: ['foo/bar', 'write', {}, []],
expectedContent: 'Must supply data or use -force',
},
];
testCommandCases.forEach(function(testCase) {
test(`#logErrorFromInput: ${testCase.name}`, function(assert) {
let data = logErrorFromInput(...testCase.args);
assert.deepEqual(data, { type: 'error', content: testCase.expectedContent }, 'returns the pcorrect data');
});
});

View File

@@ -0,0 +1,94 @@
import { moduleFor, test } from 'ember-qunit';
import { sanitizePath, ensureTrailingSlash } from 'vault/services/console';
import sinon from 'sinon';
moduleFor('service:console', 'Unit | Service | console', {
needs: ['service:auth'],
beforeEach() {},
afterEach() {},
});
test('#sanitizePath', function(assert) {
assert.equal(sanitizePath(' /foo/bar/baz/ '), 'foo/bar/baz', 'removes spaces and slashs on either side');
assert.equal(sanitizePath('//foo/bar/baz/'), 'foo/bar/baz', 'removes more than one slash');
});
test('#ensureTrailingSlash', function(assert) {
assert.equal(ensureTrailingSlash('foo/bar'), 'foo/bar/', 'adds trailing slash');
assert.equal(ensureTrailingSlash('baz/'), 'baz/', 'keeps trailing slash if there is one');
});
let testCases = [
{
method: 'read',
args: ['/sys/health', {}],
expectedURL: 'sys/health',
expectedVerb: 'GET',
expectedOptions: { data: undefined, wrapTTL: undefined },
},
{
method: 'read',
args: ['/secrets/foo/bar', {}, '30m'],
expectedURL: 'secrets/foo/bar',
expectedVerb: 'GET',
expectedOptions: { data: undefined, wrapTTL: '30m' },
},
{
method: 'write',
args: ['aws/roles/my-other-role', { arn: 'arn=arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess' }],
expectedURL: 'aws/roles/my-other-role',
expectedVerb: 'POST',
expectedOptions: {
data: { arn: 'arn=arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess' },
wrapTTL: undefined,
},
},
{
method: 'list',
args: ['secret/mounts', {}],
expectedURL: 'secret/mounts/',
expectedVerb: 'GET',
expectedOptions: { data: { list: true }, wrapTTL: undefined },
},
{
method: 'list',
args: ['secret/mounts', {}, '1h'],
expectedURL: 'secret/mounts/',
expectedVerb: 'GET',
expectedOptions: { data: { list: true }, wrapTTL: '1h' },
},
{
method: 'delete',
args: ['secret/secrets/kv'],
expectedURL: 'secret/secrets/kv',
expectedVerb: 'DELETE',
expectedOptions: { data: undefined, wrapTTL: undefined },
},
];
test('it reads, writes, lists, deletes', function(assert) {
let ajax = sinon.stub();
let uiConsole = this.subject({
adapter() {
return {
buildURL(url) {
return url;
},
ajax,
};
},
});
testCases.forEach(testCase => {
uiConsole[testCase.method](...testCase.args);
let [url, verb, options] = ajax.lastCall.args;
assert.equal(url, testCase.expectedURL, `${testCase.method}: uses trimmed passed url`);
assert.equal(verb, testCase.expectedVerb, `${testCase.method}: uses the correct verb`);
assert.deepEqual(options, testCase.expectedOptions, `${testCase.method}: uses the correct options`);
});
});

File diff suppressed because it is too large Load Diff