UI: OpenAPI test coverage (#23583)

This commit is contained in:
Chelsea Shaw
2023-10-19 09:59:12 -05:00
committed by GitHub
parent 146653dfef
commit 07d72c842e
14 changed files with 3161 additions and 179 deletions

View File

@@ -17,13 +17,17 @@ import { expandOpenApiProps, combineAttributes } from 'vault/utils/openapi-to-at
import fieldToAttrs from 'vault/utils/field-to-attrs';
import { resolve, reject } from 'rsvp';
import { debug } from '@ember/debug';
import { dasherize, capitalize } from '@ember/string';
import { capitalize } from '@ember/string';
import { computed } from '@ember/object'; // eslint-disable-line
import { singularize } from 'ember-inflector';
import { withModelValidations } from 'vault/decorators/model-validations';
import generatedItemAdapter from 'vault/adapters/generated-item-list';
import { sanitizePath } from 'core/utils/sanitize-path';
import {
filterPathsByItemType,
pathToHelpUrlSegment,
reducePathsByPathName,
} from 'vault/utils/openapi-helpers';
export default Service.extend({
attrs: null,
@@ -36,6 +40,14 @@ export default Service.extend({
});
},
/**
* getNewModel instantiates models which use OpenAPI fully or partially
* @param {string} modelType
* @param {string} backend
* @param {string} apiPath (optional) if passed, this method will call getPaths and build submodels for item types
* @param {*} itemType (optional) used in getPaths for additional models
* @returns void - as side effect, registers model via registerNewModelWithProps
*/
getNewModel(modelType, backend, apiPath, itemType) {
const owner = getOwner(this);
const modelName = `model:${modelType}`;
@@ -76,12 +88,10 @@ export default Service.extend({
const adapter = this.getNewAdapter(pathInfo, itemType);
owner.register(`adapter:${modelType}`, adapter);
}
let path;
// if we have an item we want the create info for that itemType
const paths = itemType ? this.filterPathsByItemType(pathInfo, itemType) : pathInfo.paths;
const paths = itemType ? filterPathsByItemType(pathInfo, itemType) : pathInfo.paths;
const createPath = paths.find((path) => path.operations.includes('post') && path.action !== 'Delete');
path = createPath.path;
path = path.includes('{') ? path.slice(0, path.indexOf('{') - 1) + '/example' : path;
const path = pathToHelpUrlSegment(createPath.path);
if (!path) {
// TODO: we don't know if path will ever be falsey
// if it is never falsey we can remove this.
@@ -99,64 +109,15 @@ export default Service.extend({
});
},
reducePathsByPathName(pathInfo, currentPath) {
const pathName = currentPath[0];
const pathDetails = currentPath[1];
const displayAttrs = pathDetails['x-vault-displayAttrs'];
if (!displayAttrs) {
return pathInfo;
}
let itemType, itemName;
if (displayAttrs.itemType) {
itemType = displayAttrs.itemType;
let items = itemType.split(':');
itemName = items[items.length - 1];
items = items.map((item) => dasherize(singularize(item.toLowerCase())));
itemType = items.join('~*');
}
if (itemType && !pathInfo.itemTypes.includes(itemType)) {
pathInfo.itemTypes.push(itemType);
}
const operations = [];
if (pathDetails.get) {
operations.push('get');
}
if (pathDetails.post) {
operations.push('post');
}
if (pathDetails.delete) {
operations.push('delete');
}
if (pathDetails.get && pathDetails.get.parameters && pathDetails.get.parameters[0].name === 'list') {
operations.push('list');
}
pathInfo.paths.push({
path: pathName,
itemType: itemType || displayAttrs.itemType,
itemName: itemName || pathInfo.itemType || displayAttrs.itemType,
operations,
action: displayAttrs.action,
navigation: displayAttrs.navigation === true,
param: pathName.includes('{') ? pathName.split('{')[1].split('}')[0] : false,
});
return pathInfo;
},
filterPathsByItemType(pathInfo, itemType) {
if (!itemType) {
return pathInfo.paths;
}
return pathInfo.paths.filter((path) => {
return itemType === path.itemType;
});
},
/**
* getPaths is used to fetch all the openAPI paths available for an auth method,
* to populate the tab navigation in each specific method page
* @param {string} apiPath path of openApi
* @param {string} backend backend name, mostly for debug purposes
* @param {string} itemType optional
* @param {string} itemID optional - ID of specific item being fetched
* @returns PathsInfo
*/
getPaths(apiPath, backend, itemType, itemID) {
const debugString =
itemID && itemType
@@ -167,7 +128,7 @@ export default Service.extend({
const pathInfo = help.openapi.paths;
const paths = Object.entries(pathInfo);
return paths.reduce(this.reducePathsByPathName, {
return paths.reduce(reducePathsByPathName, {
apiPath,
itemType,
itemTypes: [],
@@ -229,7 +190,7 @@ export default Service.extend({
getNewAdapter(pathInfo, itemType) {
// we need list and create paths to set the correct urls for actions
const paths = this.filterPathsByItemType(pathInfo, itemType);
const paths = filterPathsByItemType(pathInfo, itemType);
let { apiPath } = pathInfo;
const getPath = paths.find((path) => path.operations.includes('get'));

View File

@@ -0,0 +1,129 @@
import { dasherize } from '@ember/string';
import { singularize } from 'ember-inflector';
// TODO: Consolidate with openapi-to-attrs once it's typescript
interface Path {
path: string;
itemType: string;
itemName: string;
operations: string[];
action: string;
navigation: boolean;
param: string | false;
}
interface PathsInfo {
apiPath: string;
itemType: string;
itemTypes: string[];
paths: Path[];
}
interface OpenApiParameter {
description?: string;
in: string;
name: string;
required: boolean;
schema: object;
}
interface DisplayAttrs {
itemType: string;
action: string;
navigation?: boolean;
description?: string;
name?: string;
group?: string;
value?: string | number;
sensitive?: boolean;
}
interface OpenApiAction {
parameters: Array<{ name: string }>;
}
interface OpenApiPath {
description?: string;
parameters: OpenApiParameter[];
'x-vault-displayAttrs': DisplayAttrs;
get?: OpenApiAction;
post?: OpenApiAction;
delete?: OpenApiAction;
}
// Take object entries from the OpenAPI response and consolidate them into an object which includes itemTypes, operations, and paths
export function reducePathsByPathName(pathsInfo: PathsInfo, currentPath: [string, OpenApiPath]): PathsInfo {
const pathName = currentPath[0];
const pathDetails = currentPath[1];
const displayAttrs = pathDetails['x-vault-displayAttrs'];
if (!displayAttrs) {
// don't include paths that don't have display attrs
return pathsInfo;
}
let itemType, itemName;
if (displayAttrs.itemType) {
itemType = displayAttrs.itemType;
let items = itemType.split(':');
itemName = items[items.length - 1];
items = items.map((item) => dasherize(singularize(item.toLowerCase())));
itemType = items.join('~*');
}
if (itemType && !pathsInfo.itemTypes.includes(itemType)) {
pathsInfo.itemTypes.push(itemType);
}
const operations = [];
if (pathDetails.get) {
operations.push('get');
}
if (pathDetails.post) {
operations.push('post');
}
if (pathDetails.delete) {
operations.push('delete');
}
if (pathDetails.get && pathDetails.get.parameters && pathDetails.get.parameters[0]?.name === 'list') {
operations.push('list');
}
pathsInfo.paths.push({
path: pathName,
itemType: itemType || displayAttrs.itemType,
itemName: itemName || pathsInfo.itemType || displayAttrs.itemType,
operations,
action: displayAttrs.action,
navigation: displayAttrs.navigation === true,
param: _getPathParam(pathName),
});
return pathsInfo;
}
const apiPathRegex = new RegExp(/\{\w+\}/, 'g');
/**
* getPathParam takes an OpenAPI url and returns the first path param name, if it exists.
* This is an internal method, but exported for testing.
*/
export function _getPathParam(pathName: string): string | false {
if (!pathName) return false;
const params = pathName.match(apiPathRegex);
// returns array like ['{username}'] or null
if (!params) return false;
// strip curly brackets from param name
// previous behavior only returned the first param, so we match that for now
return params[0]?.replace(new RegExp('{|}', 'g'), '') || false;
}
export function pathToHelpUrlSegment(path: string): string {
if (!path) return '';
return path.replaceAll(apiPathRegex, 'example');
}
export function filterPathsByItemType(pathInfo: PathsInfo, itemType: string): Path[] {
if (!itemType) {
return pathInfo.paths;
}
return pathInfo.paths.filter((path) => {
return itemType === path.itemType;
});
}

View File

@@ -11,31 +11,30 @@
margin: 25px 0;
}
/*hide the swagger-ui headers*/
/* hide the swagger-ui headers */
.swagger-ember .swagger-ui .filter-container,
.swagger-ember .swagger-ui .information-container.wrapper {
display: none;
display: none;
}
/*some general de-rounding and removing backgrounds and drop shadows*/
/* some general de-rounding and removing backgrounds and drop shadows */
.swagger-ember .swagger-ui .btn {
border-width: 1px;
box-shadow: none;
border-radius: 0px;
border-width: 1px;
box-shadow: none;
border-radius: 0;
}
.swagger-ember .swagger-ui .opblock {
background: none;
border-width: 1px;
border-radius: 2px;
box-shadow: none;
background: none;
border-width: 1px;
border-radius: 2px;
box-shadow: none;
}
/*customize method, path, description*/
/* customize method, path, description */
.swagger-ember .swagger-ui .opblock .opblock-summary,
.swagger-ember .swagger-ui .opblock .opblock-summary-description {
display: block;
display: block;
margin: 0;
padding: 0;
}
@@ -49,26 +48,26 @@
}
.swagger-ember .swagger-ui .opblock .opblock-summary-method,
.swagger-ember .swagger-ui .opblock .opblock-summary-path{
.swagger-ember .swagger-ui .opblock .opblock-summary-path {
display: inline-block;
margin: 0;
padding: 0;
}
.swagger-ember .swagger-ui .opblock .opblock-summary-method {
border-radius: 1px;
min-width: auto;
text-align: left;
font-size: 10px;
box-shadow: 0 0 0 1px currentColor;
position: relative;
top: -2px;
padding: 0 2px;
margin-right: 8px;
border-radius: 1px;
min-width: auto;
text-align: left;
font-size: 10px;
box-shadow: 0 0 0 1px currentcolor;
position: relative;
top: -2px;
padding: 0 2px;
margin-right: 8px;
}
/*make tags look like list items */
.swagger-ember .swagger-ui .opblock-tag{
/* make tags look like list items */
.swagger-ember .swagger-ui .opblock-tag {
font-size: 16px;
}
@@ -92,66 +91,70 @@
padding-left: 0.75rem;
padding-right: 0.75rem;
position: relative;
box-shadow: 0 2px 0 -1px #BAC1CC, 0 -2px 0 -1px #BAC1CC, 0 0 0 1px #BAC1CC, 0 8px 4px -4px rgba(10, 10, 10, 0.1), 0 6px 8px -2px rgba(10, 10, 10, 0.05);
box-shadow: 0 2px 0 -1px #bac1cc, 0 -2px 0 -1px #bac1cc, 0 0 0 1px #bac1cc,
0 8px 4px -4px rgb(10 10 10 / 10%), 0 6px 8px -2px rgb(10 10 10 / 5%);
}
/*shrink the size of the arrows*/
/* shrink the size of the arrows */
.swagger-ember .swagger-ui .expand-methods svg,
.swagger-ember .swagger-ui .expand-operation svg {
height: 12px;
width: 12px;
}
/*operation box - GET (blue) */
/* operation box - GET (blue) */
.swagger-ember .swagger-ui .opblock.opblock-get {
background: #f5f8ff;
border: 1px solid #bfd4ff;
background: #f5f8ff;
border: 1px solid #bfd4ff;
}
/*operation label*/
/* operation label */
.swagger-ember .swagger-ui .opblock.opblock-get .opblock-summary-method {
color: #1563ff;
background: none;
}
/*and expanded tab highlight */
.swagger-ember .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after {
background: #1563ff;
}
/*operation box - POST (green) */
.swagger-ember .swagger-ui .opblock.opblock-post {
background: #fafdfa;
border: 1px solid #c6e9c9;
}
.swagger-ember .swagger-ui .opblock.opblock-post .opblock-summary-method {
color: #2eb039;
color: #1563ff;
background: none;
}
.swagger-ember .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after {
background: #2eb039;
/* and expanded tab highlight */
.swagger-ember .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after {
background: #1563ff;
}
/*operation box - POST (red) */
.swagger-ember .swagger-ui .opblock.opblock-delete {
background: #fdfafb;
border: 1px solid #f9ecee;
/* operation box - POST (green) */
.swagger-ember .swagger-ui .opblock.opblock-post {
background: #fafdfa;
border: 1px solid #c6e9c9;
}
.swagger-ember .swagger-ui .opblock.opblock-post .opblock-summary-method {
color: #2eb039;
background: none;
}
.swagger-ember .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after {
background: #2eb039;
}
/* operation box - POST (red) */
.swagger-ember .swagger-ui .opblock.opblock-delete {
background: #fdfafb;
border: 1px solid #f9ecee;
}
.swagger-ember .swagger-ui .opblock.opblock-delete .opblock-summary-method {
color: #c73445;
background: none;
background: none;
}
.swagger-ember .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after {
background: #c73445;
background: #c73445;
}
/*remove "LOADING" from initial loading spinner*/
/* remove "LOADING" from initial loading spinner */
.swagger-ember .swagger-ui .loading-container .loading::after {
content: "";
content: '';
}
/*add text about requests to a live vault server*/
/* add text about requests to a live vault server */
.swagger-ember .swagger-ui .btn.execute::after {
content: " - send a request with your token to Vault."
content: ' - send a request with your token to Vault.';
}

View File

@@ -0,0 +1,53 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'vault/tests/helpers';
import authPage from 'vault/tests/pages/auth';
import { deleteAuthCmd, deleteEngineCmd, mountAuthCmd, mountEngineCmd, runCmd } from '../helpers/commands';
import { authEngineHelper, secretEngineHelper } from '../helpers/openapi/test-helpers';
/**
* This set of tests is for ensuring that backend changes to the OpenAPI spec
* are known by UI developers and adequately addressed in the UI. When changes
* are detected from this set of tests, they should be updated to pass and
* smoke tested to ensure changes to not break the GUI workflow.
* Marked as enterprise so it only runs periodically
*/
module('Acceptance | OpenAPI provides expected attributes enterprise', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.pathHelp = this.owner.lookup('service:pathHelp');
this.store = this.owner.lookup('service:store');
return authPage.login();
});
// Secret engines that use OpenAPI
['ssh', 'kmip', 'pki'].forEach(function (testCase) {
return module(`${testCase} engine`, function (hooks) {
hooks.beforeEach(async function () {
this.backend = `${testCase}-openapi`;
await runCmd(mountEngineCmd(testCase, this.backend), false);
});
hooks.afterEach(async function () {
await runCmd(deleteEngineCmd(this.backend), false);
});
secretEngineHelper(test, testCase);
});
});
// All auth backends use OpenAPI except aws
['azure', 'userpass', 'cert', 'gcp', 'github', 'jwt', 'kubernetes', 'ldap', 'okta', 'radius'].forEach(
function (testCase) {
return module(`${testCase} auth`, function (hooks) {
hooks.beforeEach(async function () {
this.mount = `${testCase}-openapi`;
await runCmd(mountAuthCmd(testCase, this.mount), false);
});
hooks.afterEach(async function () {
await runCmd(deleteAuthCmd(this.backend), false);
});
authEngineHelper(test, testCase);
});
}
);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import authModelAttributes from './auth-model-attributes';
import secretModelAttributes from './secret-model-attributes';
export const secretEngineHelper = (test, secretEngine) => {
const engineData = secretModelAttributes[secretEngine];
if (!engineData)
throw new Error(`No engine attributes found in secret-model-attributes for ${secretEngine}`);
const modelNames = Object.keys(engineData);
// A given secret engine might have multiple models that are openApi driven
modelNames.forEach((modelName) => {
test(`${modelName} model getProps returns correct attributes`, async function (assert) {
const model = this.store.createRecord(modelName, {});
const helpUrl = model.getHelpUrl(this.backend);
const result = await this.pathHelp.getProps(helpUrl, this.backend);
const expected = engineData[modelName];
assert.deepEqual(result, expected, `getProps returns expected attributes for ${modelName}`);
});
});
};
export const authEngineHelper = (test, authBackend) => {
const authData = authModelAttributes[authBackend];
if (!authData) throw new Error(`No auth attributes found in auth-model-attributes for ${authBackend}`);
const itemNames = Object.keys(authData);
itemNames.forEach((itemName) => {
if (itemName.startsWith('auth-config/')) {
// Config test doesn't need to instantiate a new model
test(`${itemName} model`, async function (assert) {
const model = this.store.createRecord(itemName, {});
const helpUrl = model.getHelpUrl(this.mount);
const result = await this.pathHelp.getProps(helpUrl, this.mount);
const expected = authData[itemName];
assert.deepEqual(result, expected, `getProps returns expected attributes for ${itemName}`);
});
} else {
test.skip(`generated-${itemName}-${authBackend} model`, async function (assert) {
const modelName = `generated-${itemName}-${authBackend}`;
// Generated items need to instantiate the model first via getNewModel
await this.pathHelp.getNewModel(modelName, this.mount, `auth/${this.mount}/`, itemName);
const model = this.store.createRecord(modelName, {});
// Generated items don't have this method -- helpUrl is calculated in path-help.js line 101
const helpUrl = model.getHelpUrl(this.mount);
const result = await this.pathHelp.getProps(helpUrl, this.mount);
const expected = authData[modelName];
assert.deepEqual(result, expected, `getProps returns expected attributes for ${modelName}`);
});
}
});
};

View File

@@ -24,13 +24,12 @@ module('Integration | Component | dashboard/replication-state-text', function (h
test('it displays replication states', async function (assert) {
await render(
hbs`
<Dashboard::ReplicationStateText
@name={{this.name}}
@version={{this.version}}
@subText={{this.subText}}
@clusterStates={{this.clusterStates}} />
`
hbs`<Dashboard::ReplicationStateText
@name={{this.name}}
@version={{this.version}}
@subText={{this.subText}}
@clusterStates={{this.clusterStates}}
/>`
);
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'DR primary')).hasText('DR primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'DR primary')).hasText('running');
@@ -42,13 +41,12 @@ module('Integration | Component | dashboard/replication-state-text', function (h
isOk: false,
};
await render(
hbs`
<Dashboard::ReplicationStateText
@name={{this.name}}
@version={{this.version}}
@subText={{this.subText}}
@clusterStates={{this.clusterStates}} />
`
hbs`<Dashboard::ReplicationStateText
@name={{this.name}}
@version={{this.version}}
@subText={{this.subText}}
@clusterStates={{this.clusterStates}}
/>`
);
assert
.dom(SELECTORS.getReplicationTitle('dr-perf', 'Performance primary'))

View File

@@ -21,12 +21,9 @@ module('Integration | Component | empty-state', function (hooks) {
// Template block usage:
await render(hbs`
{{#empty-state
title="Empty State Title"
message="This is the empty state message"
}}
<EmptyState @title="Empty State Title" @message="This is the empty state message">
Actions Link
{{/empty-state}}
</EmptyState>
`);
assert.dom('.empty-state-title').hasText('Empty State Title', 'renders empty state title');

View File

@@ -21,9 +21,9 @@ module('Integration | Component | form-error', function (hooks) {
// Template block usage:
await render(hbs`
{{#form-error}}
<FormError>
template block text
{{/form-error}}
</FormError>
`);
assert.dom(this.element).hasText('template block text');

View File

@@ -21,9 +21,9 @@ module('Integration | Component | transform-edit-base', function (hooks) {
// Template block usage:
await render(hbs`
{{#transform-edit-base}}
<TransformEditBase>
template block text
{{/transform-edit-base}}
</TransformEditBase>
`);
assert.dom(this.element).hasText('template block text');

View File

@@ -13,16 +13,8 @@ module('Integration | Component | transform-role-edit', function (hooks) {
skip('it renders', async function (assert) {
// TODO: Fill out these tests, merging without to unblock other work
await render(hbs`{{transform-role-edit}}`);
assert.dom(this.element).hasText('');
// Template block usage:
await render(hbs`
{{#transform-role-edit}}
template block text
{{/transform-role-edit}}
<TransformRoleEdit />
`);
assert.dom(this.element).hasText('template block text');

View File

@@ -273,9 +273,7 @@ module('Integration | Component | ttl-picker', function (hooks) {
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker
@label={{this.label}}
@label="clicktest"
@initialValue="10m"
@label={{this.label}} @initialValue="10m"
@onChange={{this.onChange}}
/>
`);
@@ -295,9 +293,7 @@ module('Integration | Component | ttl-picker', function (hooks) {
test('inputs reflect initial value when toggled on', async function (assert) {
await render(hbs`
<TtlPicker
@label={{this.label}}
@label="inittest"
@onChange={{this.onChange}}
@label={{this.label}} @onChange={{this.onChange}}
@initialValue="100m"
/>
`);
@@ -311,9 +307,7 @@ module('Integration | Component | ttl-picker', function (hooks) {
test('it is enabled on init if initialEnabled is true', async function (assert) {
await render(hbs`
<TtlPicker
@label={{this.label}}
@label="inittest"
@onChange={{this.onChange}}
@label={{this.label}} @onChange={{this.onChange}}
@initialValue="100m"
@initialEnabled={{true}}
/>
@@ -330,9 +324,7 @@ module('Integration | Component | ttl-picker', function (hooks) {
test('it is enabled on init if initialEnabled evals to truthy', async function (assert) {
await render(hbs`
<TtlPicker
@label={{this.label}}
@label="inittest"
@onChange={{this.onChange}}
@label={{this.label}} @onChange={{this.onChange}}
@initialValue="100m"
@initialEnabled="100m"
/>
@@ -345,9 +337,7 @@ module('Integration | Component | ttl-picker', function (hooks) {
test('it converts days to go safe time', async function (assert) {
await render(hbs`
<TtlPicker
@label={{this.label}}
@label="clicktest"
@initialValue="2d"
@label={{this.label}} @initialValue="2d"
@onChange={{this.onChange}}
/>
`);
@@ -367,9 +357,7 @@ module('Integration | Component | ttl-picker', function (hooks) {
test('it converts to the largest round unit on init', async function (assert) {
await render(hbs`
<TtlPicker
@label={{this.label}}
@label="convertunits"
@onChange={{this.onChange}}
@label={{this.label}} @onChange={{this.onChange}}
@initialValue="60000s"
@initialEnabled="true"
/>
@@ -381,9 +369,7 @@ module('Integration | Component | ttl-picker', function (hooks) {
test('it converts to the largest round unit on init when no unit provided', async function (assert) {
await render(hbs`
<TtlPicker
@label={{this.label}}
@label="convertunits"
@onChange={{this.onChange}}
@label={{this.label}} @onChange={{this.onChange}}
@initialValue={{86400}}
@initialEnabled="true"
/>

View File

@@ -0,0 +1,32 @@
import { module, test } from 'qunit';
import { _getPathParam, pathToHelpUrlSegment } from 'vault/utils/openapi-helpers';
module('Unit | Utility | OpenAPI helper utils', function () {
test(`pathToHelpUrlSegment`, function (assert) {
assert.expect(5);
[
{ path: '/auth/{username}', result: '/auth/example' },
{ path: '{username}/foo', result: 'example/foo' },
{ path: 'foo/{username}/bar', result: 'foo/example/bar' },
{ path: '', result: '' },
{ path: undefined, result: '' },
].forEach((test) => {
assert.strictEqual(pathToHelpUrlSegment(test.path), test.result, `translates ${test.path}`);
});
});
test(`_getPathParam`, function (assert) {
assert.expect(7);
[
{ path: '/auth/{username}', result: 'username' },
{ path: '{unicorn}/foo', result: 'unicorn' },
{ path: 'foo/{bigfoot}/bar', result: 'bigfoot' },
{ path: '{alphabet}/bowl/{soup}', result: 'alphabet' },
{ path: 'no/params', result: false },
{ path: '', result: false },
{ path: undefined, result: false },
].forEach((test) => {
assert.strictEqual(_getPathParam(test.path), test.result, `returns first param for ${test.path}`);
});
});
});