Skip to content

Commit

Permalink
feat: Generalized support for better authentication
Browse files Browse the repository at this point in the history
Added the following new steps:

1. `/^I am an? "([^"]*)"$/` E.g. "Given I am an "admin".

...which will explicity create a SuperAgent agent that can be reused
across scenarios. This is useful so that actions like requesting an oauth2
bearer token are repeated when they only need to be performed onnce.

This also is part of an effort to simplify code responsible for setting and
reusing agents. The previous approach tried to make it implicit, but I think
was confusing and in some scenarios not reliable due to the way we generated
the agent's dictionary key.

2. `I am using basic authentication using credentials from: {string}` and
`I am using basic authentication with the credentials:` plus short forms.

Adds support for Basic auth
  • Loading branch information
Philip Mander committed Jul 3, 2019
1 parent f7e4996 commit 87ea1de
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 63 deletions.
14 changes: 14 additions & 0 deletions examples/auth.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"values": [
{
"key": "username",
"value": "<username>",
"enabled": true
},
{
"key": "password",
"value": "<password>",
"enabled": true
}
]
}
26 changes: 26 additions & 0 deletions examples/features/support/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2019 Harver B.V.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

const { setWorldConstructor, After, AfterAll, Before, BeforeAll, Given, When, Then } = require('cucumber');
const { registerHooks, World: BaseWorld, registerSteps } = require('../../../src/index');

class World extends BaseWorld {
constructor() {
super();
}
}

setWorldConstructor(World);
registerHooks({ After, AfterAll, Before, BeforeAll });
registerSteps({ Given, Then, When });
35 changes: 35 additions & 0 deletions examples/features/twitter-search.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2019 Harver B.V.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

Feature: Twitter Standard search API

Returns a collection of relevant Tweets matching a specified query.

Background: Set up common auth
Given I am a "twitter client"
And I am using basic authentication using credentials from: "./examples/auth.json"
And get token from "https://api.twitter.com/oauth2/token" using:
| grant_type | client_credentials |

Scenario: Search for tweets containing "Trump"
When I send a 'GET' request to 'https://api.twitter.com/1.1/search/tweets.json'
And I add the query string parameters:
| q | Trump |
Then I should receive a response with the status 200

Scenario: Search for Tweets containing "Boris"
When I send a 'GET' request to 'https://api.twitter.com/1.1/search/tweets.json'
And I add the query string parameters:
| q | Boris |
Then I should receive a response with the status 200
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harver/bat",
"version": "0.7.0",
"version": "0.8.0",
"description": "A Gherkin based DSL for testing HTTP APIs via Cucumber.JS.",
"main": "src/index.js",
"scripts": {
Expand All @@ -9,7 +9,8 @@
"start": "node test/server",
"cucumber": "LATENCY_BUFFER=1000 ENV_FILE=test/env/dev.json API_SPEC_FILE=test/openapi.yaml cucumber-js test",
"test": "./run-tests.sh",
"release": "standard-version"
"release": "standard-version",
"examples" : "cucumber-js examples"
},
"standard-version": {
"message": "chore: Release v%s",
Expand Down
41 changes: 34 additions & 7 deletions src/steps-fn.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,42 @@ function defaultContentType(contentType) {
this.defaultContentType = contentType;
}

async function filterValuesFromEnvFile(filePath, keyFilter) {
const envFile = await readFileAsync(join(process.cwd(), filePath), 'utf8');
return JSON.parse(envFile).values.reduce((acc, item) => {
return item.enabled && item.value && keyFilter.includes(item.key) ?
Object.assign(acc, { [item.key]: item.value }) :
acc;
}, {});
}

function setCurrentAgentByRole(role) {
if(this.getAgentByRole(role)) {
this.currentAgent = this.getAgentByRole(role);
} else {
this.setAgentByRole(role, this.newAgent());
}
}

async function basicAuth(credentialsTable) {
const credentials = credentialsTable.rowsHash();
this.setBasicAuth(credentials);
}

async function basicAuthUsingFileCredentials(filePath) {
const keyFilter = ['username', 'password'];
const credentials = await filterValuesFromEnvFile(filePath, keyFilter);
this.setBasicAuth(credentials);
}

async function obtainAccessToken(url, credentialsTable) {
const credentials = credentialsTable.rowsHash();
await this.getOAuthAccessToken(url, credentials);
}

async function obtainAccessTokenUsingFileCredentials(url, filePath) {
const envFile = await readFileAsync(join(process.cwd(), filePath), 'utf8');
const keyFilter = ['client_id', 'client_secret', 'username', 'password', 'grant_type', 'refreshToken']
const credentials = JSON.parse(envFile).values.reduce((acc, item) => {
return item.enabled && item.value && keyFilter.includes(item.key) ?
Object.assign(acc, { [item.key]: item.value }) :
acc;
}, {});
const keyFilter = ['client_id', 'client_secret', 'username', 'password', 'grant_type', 'refreshToken'];
const credentials = await filterValuesFromEnvFile(filePath, keyFilter);
await this.getOAuthAccessToken(url, credentials);
}

Expand Down Expand Up @@ -224,7 +247,11 @@ async function validateAgainstFileSchema(filePath) {
}

module.exports = {
noop: () => {},
defaultContentType,
setCurrentAgentByRole,
basicAuth,
basicAuthUsingFileCredentials,
obtainAccessToken,
obtainAccessTokenUsingFileCredentials,
setVariables,
Expand Down
16 changes: 12 additions & 4 deletions src/steps.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ function registerSteps({ Given, When, Then }) {
*
* @function anonymous
*/
Given('I am anonymous', function anonymous() {
// nothing to do
});
Given('I am anonymous', fn.noop);

Given(/^I am an? "([^"]*)"$/, fn.setCurrentAgentByRole);

Given('I am using basic authentication with the credentials:', fn.basicAuth);

Given('basic auth using:', fn.basicAuth);

Given('I am using basic authentication using credentials from: {string}', fn.basicAuthUsingFileCredentials);

Given('basic auth using credentials from: {string}', fn.basicAuthUsingFileCredentials);

/**
* ### Given I obtain an access token from {string} using the credentials:
Expand Down Expand Up @@ -65,7 +73,7 @@ function registerSteps({ Given, When, Then }) {
* Sessions (access tokens) will be stored and supported for subsequent requests
*
* @example
* Given I obtain an access token from "{base}/auth/token" using the credentials: "/path/to/user.json"
* Given I obtain an access token from "{base}/auth/token" using the credentials from: "/path/to/user.json"
*
* @example <caption>Short form</caption>
* Given get token from "{base}/auth/token" using credentials from: "/path/to/user.json"
Expand Down
105 changes: 55 additions & 50 deletions src/world.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ let apiSepc = null;
class World {
constructor(...params) {
this._req = null;
this._currentAgent = null;

this._currentAgent = this.newAgent();
this.defaultContentType = 'application/json';

// Provide a base url for all relative paths.
Expand Down Expand Up @@ -77,15 +76,25 @@ class World {
this._req = val;
}

/**
* Getter for the full Open API spec
*/
get apiSpec() {
if (!apiSepc) {
throw new Error('No API spec is loaded. This assertion cannot be performed.')
}
return apiSepc;
}

get latencyBuffer() {
return this._latencyBuffer;
}

/**
* Getter for the current Superagent agent.
* Reuse this agent in step definitions to preserve client sessions
*/
get currentAgent() {
if (!this._currentAgent) {
this._currentAgent = this.newAgent();
this._currentAgent.set('User-agent', `behavioral-api-tester/${version}`);
}
return this._currentAgent;
}

Expand All @@ -98,17 +107,31 @@ class World {
}

/**
* Getter for the full Open API spec
* Creates and returns a new SuperAgent agent
*/
get apiSpec() {
if (!apiSepc) {
throw new Error('No API spec is loaded. This assertion cannot be performed.')
}
return apiSepc;
newAgent() {
const agent = request.agent();
agent._bat = {};
agent.set('User-Agent', `harver/behavioral-api-tester/${version}`);
return agent;
}

get latencyBuffer() {
return this._latencyBuffer;
/**
* Get a Superagent agent for a specific authorization role
* @param {string} role The role, such as 'admin'
*/
getAgentByRole(role) {
return agents.get(role);
}

/**
* Save a Superagent agent for a given authorization role
* @param {string} role
* @param {*} agent
*/
setAgentByRole(role, agent) {
this._currentAgent = agent;
agents.set(role, agent);
}

/**
Expand All @@ -135,32 +158,39 @@ class World {
}
}

async setBasicAuth(credentials) {
const { username, password } = credentials;
const agent = this.currentAgent;
const encodedCredentials = Buffer.from(`${username}:${password}`).toString('base64');
agent.set('Authorization', `Basic ${encodedCredentials}`);
}

/**
* Get an Oauth2 access token, by sending the credentials to the endpoint url
* @param {*} url The full token url ()
* @param {*} credentials
*/
async getOAuthAccessToken(url, credentials) {
const agentKey = `${credentials.client_id}:${credentials.username}`;
let agent = this.getAgentByRole(agentKey);
const agent = this.currentAgent;

// do an oauth2 login
if (!agent) {
const res = await request
// only set the bearer token once on the agent
if (!agent._bat.bearer) {
const res = await agent
.post(this.baseUrl + this.replaceVars(url))
.type('form')
.send(credentials);

if (res.body.accessToken) {
agent = this.newAgent();
agent.set('Authorization', `Bearer ${res.body.accessToken}`);
} else {
// get the access token from the response body
const getAccessToken = body => body.accessToken || body.access_token;
if (!getAccessToken(res.body)) {
// no access token received.
throw new Error(`Could not authenticate with OAuth2:\n\t${res.body}`);
}
}

// this also makes it the current agent
this.setAgentByRole(agentKey, agent);
agent._bat.bearer = getAccessToken(res.body);
}
agent.set('Authorization', `Bearer ${agent._bat.bearer}`);
}

/**
Expand Down Expand Up @@ -201,31 +231,6 @@ class World {
};
}

/**
* Creates and returns a new SuperAgent agent
*/
newAgent() {
return request.agent();
}

/**
* Get a Superagent agent for a specific authorization role
* @param {string} role The role, such as 'admin'
*/
getAgentByRole(role) {
return agents.get(role);
}

/**
* Save a Superagent agent for a given authorization role
* @param {string} role
* @param {*} agent
*/
setAgentByRole(role, agent) {
this._currentAgent = agent;
agents.set(role, agent);
}

/**
* Gets the body from a response. Includes logic to parse
* JSON from JSON responses that have an incorrect 'text/html' content type.
Expand Down

0 comments on commit 87ea1de

Please sign in to comment.