From 62c33e6a25f15f45937ab578c1b02315b39a0fb7 Mon Sep 17 00:00:00 2001 From: JP S Date: Wed, 14 Mar 2018 23:36:45 -0400 Subject: [PATCH] Added a bot test mode --- .gitignore | 3 +- README.md | 72 +++++++++++++- bot-test/bot-interceptor.js | 33 +++++++ bot-test/bot-test.js | 53 +++++++++++ package.json | 4 +- resources/memberships.js | 11 ++- sample-bot-test/bot.js | 76 +++++++++++++++ server.js | 18 +++- storage/memory.js | 2 + storage/response-builder.js | 183 ++++++++++++++++++++++++++++++++++++ tokens.json | 14 +++ 11 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 bot-test/bot-interceptor.js create mode 100644 bot-test/bot-test.js create mode 100644 sample-bot-test/bot.js create mode 100644 storage/response-builder.js diff --git a/.gitignore b/.gitignore index 40b878d..ec71af6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules/ \ No newline at end of file +node_modules/ +.env* \ No newline at end of file diff --git a/README.md b/README.md index ce5dbac..4994c47 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ Therefore, the emulator mimics Cisco Spark REST API behavior for /rooms, /messag The following features are NOT implemented: Messages Attachements, Room moderation, People LastActivity & Status and Pagination, as well as Teams, Automatic Invitations (if non Spark users are added to rooms) and Administration APIs, The emulator can be used for several purposes: -- testing: on a developer laptop machine or on a CI environment to run a battery of test with no connection to Cisco Spark, and without 409 (Rate Limitations), +- testing: on a developer laptop machine or on a CI environment to run a battery of test with no connection to Cisco Spark, and without 409 (Rate Limitations) - QA: run your code against a stable version of Cisco Spark (as the Cloud Service is incrementally upgraded, some bugs can be hard to replay. The emulator complies with a version of the API at a specific date, and helps reproduce an issue, or test for an upcoming feature (not released yet or toggled on) +- Bot Regression testing: Create a set of regression tests to ensure that for given user inputs you will get expected bot responses. See [Bot Testing Mode](#Bot-Testing-Mode) below. - Training: backup plan in case of low or no connectivity location - QA: simulate specific behaviors or errors from CiscoSpark (429, 500, 503) - ## Give it a try from Heroku The emulator is accessible at "https://mini-spark.herokuapp.com" via Heroku free dynos. @@ -50,13 +50,79 @@ DEBUG="emulator*" node server.js - POST /rooms create a new room - POST /rooms create another room - GET /rooms shows your rooms (2) + - DELETE /rooms:id deletes a room - POST /memberships add a bot to the room - POST /memberships 409 (conflict) - GET /memberships show all your memberships - GET /memberships?room= fetch memberships for you and your bot in current room - - POST /messages create a new message + - DELETE /memberships + - POST /messages create a new message. If the payload includes a personId, will create a new one on one space if one does not already exist. (personEmail not yet supported) - POST /webhooks register a new webhook pointing to a target URL on your local machine, or on the internet + - DELETE /webhooks/:id deletes a webhook + +## Bot Testing Mode + The emultator can be run in a special mode for bot testing by running with the environment variable BOT_UNDER_TEST set to the email of a user specified in tokens.json. When this environment variable is set additional middleware is loaded the performs that following functions: + - Inspect all incoming requests for an X-Bot-Responses header. When found it will intercept the response that normally would be sent to the caller of the API + - Inspect all incoming requests from the BOT_UNDER_TEST. When found the system checks to see if the bot request is correlated to any prior requests with the X-Bot-Response header. Generally correlation is done using the roomID. + - When correlated Bot responses are found, the emulator will build a new response body that will contain the response body to the original request in an object called testFrameworkResponse, as well as an array of botResponse objects that occured in response to the test input. Each botResponse object will contain the request sent by the Bot. The number of botResponse objects collected is the value specified in the X-Bot-Responses header. + + As an example if we may expect a bot to send a message in response to being added to a space, and then to leave the space. To validate this with a test we would write a test that adds the bot to a space using the /memberships endpoint, which also set an X-Bot-Response header to 2, ie: +``` +POST http://localhost:3210/memberships +{ + "headers" { + "x-bot-responses": "2", + ... + }, + "body" { + "roomId": "Y2lzY29zcGFyazovL2VtL1JPT00vMzUzN2Q2ZTAtMmY5My00N2M0LWIwODMtZDYxNTg3MWZiMzFj" + "personId": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hODYwYmFkZC0wNGZkLTQwYWEtYWFjNS05NmYyYWRhZDE3NTA" + "isModerator": "false" + }, + ... +} +``` + + A sample consolidated response to such a request will have the status for the original request resposne and a body which might look as follows: + ``` +{ + "testFrameworkResponse": { + "id": "Y2lzY29zcGFyazovL2VtL01FTUJFUlNISVAvMDMzMjllODctMzA4Ni00OThiLTg4ZGMtMzM5ZTVhODdlZGEy", + "roomId": "Y2lzY29zcGFyazovL2VtL1JPT00vMzUzN2Q2ZTAtMmY5My00N2M0LWIwODMtZDYxNTg3MWZiMzFj", + "personId": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9hODYwYmFkZC0wNGZkLTQwYWEtYWFjNS05NmYyYWRhZDE3NTA", + "personEmail": "bot@sparkbot.io", + "personDisplayName": "Bot", + "personOrgId": "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8xZWI2NWZkZi05NjQzLTQxN2YtOTk3NC1hZDcyY2FlMGUxMGY", + "isModerator": false, + "isMonitor": false, + "created": "2018-03-15T03:12:59.220Z" + }, + "botResponses": [ + { + "method": "POST", + "route": "/messages", + "body": { + "markdown": "Hi! Sorry, I only work in one on one rooms at the moment. Goodbye.", + "roomId": "Y2lzY29zcGFyazovL2VtL1JPT00vMzUzN2Q2ZTAtMmY5My00N2M0LWIwODMtZDYxNTg3MWZiMzFj" + } + }, + { + "method": "DELETE", + "route": "/memberships/Y2lzY29zcGFyazovL2VtL01FTUJFUlNISVAvMDMzMjllODctMzA4Ni00OThiLTg4ZGMtMzM5ZTVhODdlZGEy", + "body": {} + } + ] +} +``` +The developer can then use tools like Postman to build a collection of test request and write tests to validate the expected response. + +An added benefit of the Bot Test reqression framework is that it can be run on a laptop that is completely offline. Its great for working on your bot while on an airplane! In general your bot code itself needs to change only one thing:namely it needs to direct its Cisco Spark API calls to the emulator. The way this is done varies depending on the framework you are using. + +[ToDo] provide example + + +## Testing - The emulator comes with a Postman collection companion to quickly run requests againt the Emulator, and easilly switch back and forth between the emulator and the Cisco Spark API. To install the postman collection: diff --git a/bot-test/bot-interceptor.js b/bot-test/bot-interceptor.js new file mode 100644 index 0000000..085fbe1 --- /dev/null +++ b/bot-test/bot-interceptor.js @@ -0,0 +1,33 @@ +// +// Copyright (c) 2017 Cisco Systems +// Licensed under the MIT License +// + +// Middleware to correlate bot responses to bot-test input + +const debug = require("debug")("emulator:botInterceptor"); +const sendError = require('../utils').sendError; +const sendSuccess = require('../utils').sendSuccess; +var interceptor = require('express-interceptor'); + +let botInterceptor = interceptor(function(req, res){ + return { + // Only JSON responses will be intercepted + isInterceptable: function(){ + return /application\/json/.test(res.get('Content-Type')); + }, + // See if this is a response that we've been waiting for + intercept: function(body, send) { + console.log(body); + const db = req.app.locals.datastore; + if (db.responses.isTrackedResponse(res, JSON.parse(body))) { + console.log('Holding the response while waiting for bot input...'); + } else { + send(body); + } + } + }; +}) + + +module.exports = botInterceptor; \ No newline at end of file diff --git a/bot-test/bot-test.js b/bot-test/bot-test.js new file mode 100644 index 0000000..ed3aba3 --- /dev/null +++ b/bot-test/bot-test.js @@ -0,0 +1,53 @@ +// +// Copyright (c) 2017 Cisco Systems +// Licensed under the MIT License +// + +// Middleware to look for X-Bot-Test + +const debug = require("debug")("emulator:botTest"); +const sendError = require('../utils').sendError; +const sendSuccess = require('../utils').sendSuccess; + +let botTest = {}; +let testIdCounter = 1; + +botTest.middleware = function (req, res, next) { + + // Public resources + if ((req.path == "/") || (req.path == "/tokens")) { + next(); + } + + debug('New Request from: '+ res.locals.person.emails[0]); + debug(req.method + ': ' + req.url); + if (req.method == 'GET') { + debug(req.params); + } else { + debug(req.body); + } + + let botUnderTestEmail = req.app.locals.botUnderTestEmail + // Check if this is a test request that should generate bot requests + const botTestHeader = req.get("X-Bot-Responses"); + if (botTestHeader) { + console.log('Found X-Bot-Test header: ' + botTestHeader); + const db = req.app.locals.datastore; + db.responses.initResponseObj(req, res, testIdCounter++); + } else { + if(res.locals.person.emails[0] == botUnderTestEmail) { + //See if this is a bot request in response to a test input + const db = req.app.locals.datastore; + if (db.responses.isTrackedBotResponse(req)) { + console.log('Updated response to test framework with bot request'); + } + } + } + + // Check if this is a request from the bot under test + + next(); +} + + +module.exports = botTest; \ No newline at end of file diff --git a/package.json b/package.json index 7037e62..423ee58 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "dependencies": { "base-64": "^0.1.0", "body-parser": "^1.17.2", + "dotenv": "^5.0.1", "express": "^4.15.3", + "express-interceptor": "^1.2.0", "request": "^2.81.0", "uuid": "^3.1.0" }, @@ -24,4 +26,4 @@ ], "author": "Stève Sfartz (Cisco DevNet) ", "license": "MIT" -} \ No newline at end of file +} diff --git a/resources/memberships.js b/resources/memberships.js index e45e123..f6bc998 100644 --- a/resources/memberships.js +++ b/resources/memberships.js @@ -10,10 +10,6 @@ const express = require("express"); // default routing properties to mimic Cisco Spark const router = express.Router({ "caseSensitive": true, "strict": false }); -// for parsing application/json -const bodyParser = require("body-parser"); -router.use(bodyParser.json()); - // Extra imports const sendError = require('../utils').sendError; const sendSuccess = require('../utils').sendSuccess; @@ -203,6 +199,13 @@ router.get("/:id", function (req, res) { }); }); +// Update a membership +router.put("/:id", function (req, res) { + debug(`Update membership not implemented yet`); + return sendError(res, 501, "[EMULATOR] Update membership not implemented yet"); +}); + + // Delete a membership router.delete("/:id", function (req, res) { diff --git a/sample-bot-test/bot.js b/sample-bot-test/bot.js new file mode 100644 index 0000000..92c0dc6 --- /dev/null +++ b/sample-bot-test/bot.js @@ -0,0 +1,76 @@ +// +// Copyright (c) 2016 Cisco Systems +// Licensed under the MIT License +// + +/* + * a Cisco Spark webhook based on pure Express.js. + * + * goal here is to illustrate how to create a bot without any library + * + */ + +var express = require("express"); +var app = express(); + +var bodyParser = require("body-parser"); +app.use(bodyParser.urlencoded({extended: true})); +app.use(bodyParser.json()); + +var debug = require("debug")("samples"); +var Utils = require("../sparkbot/utils"); + + +var started = Date.now(); +app.route("/") + // healthcheck + .get(function (req, res) { + res.json({ + message: "Congrats, your Cisco Spark bot is up and running", + since: new Date(started).toISOString(), + code: "express-all-in-one.js", + tip: "Register your bot as a WebHook to start receiving events: https://developer.ciscospark.com/endpoint-webhooks-post.html" + }); + }) + + // webhook endpoint + .post(function (req, res) { + + // analyse incoming payload, should conform to Spark webhook trigger specifications + debug("DEBUG: webhook invoked"); + if (!req.body || !Utils.checkWebhookEvent(req.body)) { + console.log("WARNING: Unexpected payload POSTed, aborting..."); + res.status(400).json({message: "Bad payload for Webhook", + details: "either the bot is misconfigured or Cisco Spark is running a new API version"}); + return; + } + + // event is ready to be processed, let's send a response to Spark without waiting any longer + res.status(200).json({message: "message is being processed by webhook"}); + + // process incoming resource/event, see https://developer.ciscospark.com/webhooks-explained.html + processWebhookEvent(req.body); + }); + + +// Starts the Bot service +// +// [WORKAROUND] in some container situation (ie, Cisco Shipped), we need to use an OVERRIDE_PORT to force our bot to start and listen to the port defined in the Dockerfile (ie, EXPOSE), +// and not the PORT dynamically assigned by the host or scheduler. +var port = process.env.OVERRIDE_PORT || process.env.PORT || 8080; +app.listen(port, function () { + console.log("Cisco Spark Bot started at http://localhost:" + port + "/"); + console.log(" GET / for health checks"); + console.log(" POST / to procress new Webhook events"); +}); + + +// Invoked when the Spark webhook is triggered +function processWebhookEvent(trigger) { + + // + // YOUR CODE HERE + // + console.log("EVENT: " + trigger.resource + "/" + trigger.event + ", with data id: " + trigger.data.id + ", triggered by person id:" + trigger.actorId); + +} \ No newline at end of file diff --git a/server.js b/server.js index 25a81fa..25f60d5 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,6 @@ const debug = require("debug")("emulator"); const express = require("express"); const uuid = require('uuid/v4'); - // // Setting up common services // @@ -46,6 +45,23 @@ app.use(function (req, res, next) { const authentication = require("./auth"); app.use(authentication.middleware); +// for parsing application/json in middleware +const bodyParser = require("body-parser"); +app.use(bodyParser.json()); + +// Allow user to set the BOT_UNDER_TEST environmnet var in a .env file +require('dotenv').load(); +if (process.env.BOT_UNDER_TEST) { + app.locals.botUnderTestEmail = process.env.BOT_UNDER_TEST; + // Middleware to catch requests with the X-Bot-Responses header + const botTest = require("./bot-test/bot-test"); + app.use(botTest.middleware); + + // Middleware to delay responses until we get the expected bot response + var botInterceptor = require('./bot-test/bot-interceptor'); + app.use(botInterceptor); +} + // Load initial list of accounts const accounts = Object.keys(authentication.tokens).map(function (item, index) { return authentication.tokens[item]; diff --git a/storage/memory.js b/storage/memory.js index 0456281..c199ff1 100644 --- a/storage/memory.js +++ b/storage/memory.js @@ -22,5 +22,7 @@ const MessageStorage = require("./messages"); datastore.messages = new MessageStorage(datastore); const WebhookStorage = require("./webhooks"); datastore.webhooks = new WebhookStorage(datastore); +const ResponseStorage = require("./response-builder") +datastore.responses = new ResponseStorage(datastore); module.exports = datastore; diff --git a/storage/response-builder.js b/storage/response-builder.js new file mode 100644 index 0000000..c7147f4 --- /dev/null +++ b/storage/response-builder.js @@ -0,0 +1,183 @@ +// +// Copyright (c) 2017 Cisco Systems +// Licensed under the MIT License +// + +/* + * Helper module to manipulate response from the spark emulator + * The emulator inspects all requests. Requests from the test framework + * that emulate user activity may include an X-Bot-Response header. + * When this headers has a value greater than zero the emulator framework + * will intercept responses and hold the response to the test request + * until it also recieves the coorect number of responses to bot requests + * as well + * + */ + +const assert = require("assert"); +const uuid = require('uuid/v4'); +const base64 = require('base-64'); +const debug = require("debug")("emulator:storage:response-builder"); +// Extra imports +const sendError = require('../utils').sendError; +const sendSuccess = require('../utils').sendSuccess; + + +function ResponseStorage(datastore) { + this.datastore = datastore; + this.data = []; +} + +ResponseStorage.prototype.initResponseObj = function (req, res, testId) { + + assert.ok(req); + let expectedBotResponses = req.get('X-Bot-Responses'); + + // Set the testId in the request header so we can catch the initial + // response to the request from the test framework + res.setHeader('X-Bot-Test-Id', testId); + + // Create an object for building the response to the test framework + var respObj = { + "testId": testId, + "response": {}, + "expectedBotResponses": parseInt(req.get('X-Bot-Responses')), + "seenBotResponses": 0, + "roomId": '', + "membershipId": '' + } + + // Store reqObj + this.data[testId] = respObj; +} + +ResponseStorage.prototype.removeResponseObj = function (index) { + + this.data.splice(index); +} + +// Check if this is a response to a request with a tracking ID +// If so save the response object so we can send it after we get +// the expected bot responses +ResponseStorage.prototype.isTrackedResponse = function(response, body) { + if (!this.data.length) { + return false; + } + + assert.ok(response); + if (response.statusCode != 200) { + return false; + } + + let testId = response.get('X-Bot-Test-Id'); + if ((!testId) || (!this.data[testId])) { + return false; + } + + let respObj = this.data[testId]; + if (respObj.roomId) { + } else { + // This is the response to the original spark API request from the + // test framework. Store it now to send after we get the bot responses + respObj.response = response; + respObj.body = body; + // Add the roomId which we'll use to correlate bot responses + respObj.roomId = body.roomId; + respObj.membershipId = body.id; + return true; + } + return false; +} + +// Check if this is a bot request in response to a test request +// If so save the info about the request in the test framework response +ResponseStorage.prototype.isTrackedBotResponse = function(req) { + if (!this.data.length) { + return false; // No test requests awaiting responses + } + + assert.ok(req); + if (req.method == 'GET') { + return false; // Not interested in responses to webhooks + } + + let id = ''; + let keyName = ''; + if (req.method == 'DELETE') { + let parts = req.url.split('/') + let endpoint = parts[1]; + if (endpoint === 'memberships') { + id = parts[2]; + keyName = 'membershipId'; + } + } else { + id = req.body.roomId; + keyName = 'roomId' + } + // Iterate through all the stored responses until + // we find one with this roomID + const self = this; + let respObjs = []; + let index = 0; + Object.keys(this.data).forEach(function (key) { + let respObj = self.data[key]; + if (respObj[keyName] == id) { + index = key; + respObjs.push(respObj); + } + }); + console.log('Found %d saved responses that match bot request', respObjs.length); + if (!respObjs.length) { + return false; + } else if (respObjs.length > 1) { + // Need a better way to deal with this + console.error('Too many saved responses that match this room! Bailing') + return false; + } else { + // This must be a BotResponse that correlates to an earlier + // request from the test framework. Add it to the response + let respObj = respObjs[0]; + if (!respObj.seenBotResponses) { + respObj.body = buildComplexResponseBody(respObj.body, req); + } else { + respObj.body = addToComplexResponseBody(respObj.body, req); + } + respObj.seenBotResponses += 1; + if (respObj.seenBotResponses == respObj.expectedBotResponses) { + // We have all the bot responses we are waiting for + // Send the new complex response to the test framework + sendSuccess(respObj.response, 200, respObj.body); + // Remove the response from our db of test responses to process + self.removeResponseObj(index); + } + return true; + } +} + +// Replace the original response body with an object that +// includes both the response to the original test +// framework request as well as one or more bot objects +function buildComplexResponseBody(testBody, req) { + return { + 'testFrameworkResponse': testBody, + 'botResponses': [{ + 'method': req.method, + 'route': req.url, + 'body': req.body + }] + } +} + +// Add another bot Response to the existing complexResponseBody +// includes both the response to the original test +// framework request as well as one or more bot objects +function addToComplexResponseBody(testBody, req) { + testBody.botResponses.push({ + 'method': req.method, + 'route': req.url, + 'body': req.body + }); + return (testBody); +} + +module.exports = ResponseStorage; \ No newline at end of file diff --git a/tokens.json b/tokens.json index a0dc9f6..d5b5464 100644 --- a/tokens.json +++ b/tokens.json @@ -26,5 +26,19 @@ "orgId": "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8xZWI2NWZkZi05NjQzLTQxN2YtOTk3NC1hZDcyY2FlMGUxMGY", "created": "2017-07-18T00:00:00.000Z", "type": "bot" + }, + "01234567890123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ": { + "id": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS85MmIzZGQ5YS02NzVkLTRhNDEtOGM0MS0yYWJkZjg5ZjQ0ZjQ", + "emails": [ + "postman-test@cisco.com" + ], + "displayName": "Mr. Postman", + "nickName": "Posty", + "firstName": "Post", + "lastName": "Man", + "avatar": "https://cdn-images-1.medium.com/max/1600/1*Iel5Q6qAxgBdl_IHUx3scA.jpeg", + "orgId": "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8xZWI2NWZkZi05NjQzLTQxN2YtOTk3NC1hZDcyY2FlMGUxMGY", + "created": "2017-07-18T00:00:00.000Z", + "type": "person" } } \ No newline at end of file