From 0a655ec65315e413742f65682580cecc6cf0f22c Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Aug 2019 22:46:40 +0200 Subject: [PATCH 1/2] experiments with agents --- SuperTest/actionapi.js | 176 ++++++++++++++++++++++++++++++++++ SuperTest/config.json | 3 +- SuperTest/fixtures.js | 44 +++++++++ SuperTest/package.json | 3 +- SuperTest/test/DiffCompare.js | 2 +- SuperTest/test/SiteStats.js | 2 +- SuperTest/test/UserBlock.js | 44 +++++++++ 7 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 SuperTest/actionapi.js create mode 100644 SuperTest/fixtures.js create mode 100644 SuperTest/test/UserBlock.js diff --git a/SuperTest/actionapi.js b/SuperTest/actionapi.js new file mode 100644 index 0000000..8649673 --- /dev/null +++ b/SuperTest/actionapi.js @@ -0,0 +1,176 @@ +const { assert } = require('chai'); +const supertest = require('supertest'); +const uniqid = require('uniqid'); +const config = require('./config.json'); + +const methods = { + /** + * @param actionName + * @param {Object} params + * @param post + * @returns Response + */ + async action(actionName, params, post = false) { + const defaultParams = { + action: actionName, + format: 'json', + }; + + let resp; + if (post) { + resp = this.post('') + .type('form') + .send({ ...defaultParams, ...params }); + } else { + resp = this.get('') + .query({ ...defaultParams, ...params }); + } + + await resp.expect(200); + return resp.response; + }, + + /** + * @param {string[]} ttypes + * @returns Response + */ + loadTokens(ttypes) { + const resp = this.action( + 'query', + { meta: 'tokens', types: ttypes.join('|') }, + ); + + this.tokens = resp.body.query.tokens; + return resp; + }, + + /** + * @param {string} ttype + * @returns string + */ + token(ttype) { + if (ttype in this.tokens) { + return this.tokens[ttype]; + } + + // TODO: skip tokens we already have! + const newTokens = this.action( + 'query', + { meta: 'tokens', type: ttype }, + ).body.query.tokens; + + this.tokens = { ...this.tokens, ...newTokens }; + return this.tokens[ttype]; + }, + + /** + * @param {string} username + * @param {string} password + * @returns string + */ + login(username, password) { + this.action( + { + action: 'login', + lgname: username, + lgpassword: password, + lgtoken: this.token('login'), + }, + 'POST', + ).then((response) => { + assert.equal(response.body.login.result, 'Success'); + }); + }, + + /** + * @param params + * @returns Response + */ + edit(params) { + const editParams = { + text: 'Lorem Ipsum', + comment: 'testing', + }; + + editParams.token = params.token || this.token('edit'); + + return this.action('edit', { ...editParams, ...params }, 'POST') + .then((response) => { + assert.equal(response.body.edit.result, 'Success'); + return response.body.edit.newrevid; + }); + }, + + /** + * @param params + * @returns Response + */ + createAccount(params) { + const defaults = { + token: params.token || this.token('createaccount'), + retype: params.retype || params.password, + }; + + return this.action('createaccount', { ...defaults, ...params }, 'POST') + .then((response) => { + assert.equal(response.body.createuser.result, 'PASS'); + }); + }, + + /** + * @param userName + * @param groups + * @returns Response + */ + addGroups(userName, groups) { + const gprops = { + action: 'userrights', + user: userName, + add: groups.join('|'), + token: this.token('userrights'), + }; + + return this.action(gprops, 'POST').then((response) => { + assert.isDefined(response.body.userrights.added); + }); + }, +}; + +/** + * @param {string|null} name + * @param {string|null} passwd + * @returns TestAgent + */ +module.exports.agent = (name = null, passwd = null) => { + const instance = supertest.agent(config.base_uri); + + instance.tokens = {}; + + // FIXME: is this the correct way? + for (const m in methods) { + instance[m] = methods[m].bind(instance); + } + + if (name) { + let uname = name; + let upass = passwd; + + if (!upass) { + uname = name + uniqid(); + upass = uniqid(); + + instance.createAccount({ name: uname, password: upass }); + } + + instance.login(uname, passwd); + instance.name = uname; + } + + return instance; +}; + +/** + * @param {string|null} namePrefix + * @returns string + */ +module.exports.title = namePrefix => namePrefix + uniqid(); diff --git a/SuperTest/config.json b/SuperTest/config.json index 61972c6..c88815c 100644 --- a/SuperTest/config.json +++ b/SuperTest/config.json @@ -1,6 +1,7 @@ { "base_uri": "http://default.web.mw.localhost:8080/mediawiki/api.php", - "user": { + "main_page": "Main_Page", + "root_user": { "name": "Wikiuser", "password": "wikiuser123" } diff --git a/SuperTest/fixtures.js b/SuperTest/fixtures.js new file mode 100644 index 0000000..ee9e318 --- /dev/null +++ b/SuperTest/fixtures.js @@ -0,0 +1,44 @@ +const uniqid = require('uniqid'); +const api = require('./actionapi'); +const config = require('./config.json'); + +const fixtures = { + root: null, // lazified later + mindy: null, // lazified later +}; + +const root = () => { + const agent = api.agent(config.root_user.name, config.root_user.password); + agent.loadTokens(['edit', 'createaccount', 'userrights', 'csrf']); + + return agent; +}; + +const mindy = () => { + const passwd = uniqid(); + + fixtures.root.createAccount({ name: 'Mindy', password: passwd }); + fixtures.root.addGroups('Mindy', ['sysop']); + + const agent = api.agent('Mindy', passwd); + agent.loadTokens(['edit', 'userrights', 'csrf']); + + return agent; +}; + +// Define lazy initialization accessors for fixtures. +// FIXME: is this the correct way? Isn't there a module for this? +const lazyfy = (obj, name, getter) => { + Object.defineProperty(obj, name, { + get: () => { + const v = getter(); + Object.defineProperty(obj, name, { value: v }); + return v; + }, + }); +}; + +lazyfy(fixtures, 'root', root); +lazyfy(fixtures, 'mindy', mindy); + +module.exports = fixtures; diff --git a/SuperTest/package.json b/SuperTest/package.json index 8322926..45d992f 100644 --- a/SuperTest/package.json +++ b/SuperTest/package.json @@ -14,6 +14,7 @@ "eslint-config-airbnb-base": "^13.2.0", "eslint-plugin-import": "^2.18.2", "mocha": "^6.2.0", - "supertest": "^4.0.2" + "supertest": "^4.0.2", + "uniqid": "^5.0.3" } } diff --git a/SuperTest/test/DiffCompare.js b/SuperTest/test/DiffCompare.js index b015429..bc10d0f 100644 --- a/SuperTest/test/DiffCompare.js +++ b/SuperTest/test/DiffCompare.js @@ -14,7 +14,7 @@ describe('Diff Compare with Variables', function () { before(async () => { const loginToken = await utils.loginToken(user); - await utils.login(user, config.user.name, config.user.password, loginToken); + await utils.login(user, config.root_user.name, config.root_user.password, loginToken); variables.editToken = await utils.editToken(user); }); diff --git a/SuperTest/test/SiteStats.js b/SuperTest/test/SiteStats.js index 45805fd..8274237 100644 --- a/SuperTest/test/SiteStats.js +++ b/SuperTest/test/SiteStats.js @@ -1,7 +1,7 @@ const { assert } = require('chai'); const request = require('supertest'); const config = require('../config.json'); -const utils = require('../utils'); +const utils = require('../actionapi'); describe("Testing site statistics' edits value", function () { diff --git a/SuperTest/test/UserBlock.js b/SuperTest/test/UserBlock.js new file mode 100644 index 0000000..edc78b5 --- /dev/null +++ b/SuperTest/test/UserBlock.js @@ -0,0 +1,44 @@ +const { assert } = require('chai'); +const fixtures = require('../fixtures'); +const api = require('../actionapi'); + +describe('Blocking a user', function () { + this.timeout('5s'); + + let name, eve; + + before(() => { + name = api.title('Block_'); + eve = api.agent('Eve_'); + }); + + it('should edit a page', () => { + eve.edit(name, 'One', 'first'); // FIXME + }); + + it('should block a user', () => { + fixtures.mindy.action('blockuser', { // FIXME + user: eve.name, + reason: 'testing', + }, 'POST').then((response) => { + assert.equal(response.body.blockuser.result, 'Success'); + }); + }); + + it('should fail to edit a page', () => { + eve.edit(name, 'Two', 'second'); // FIXME + }); + + it('should unblock a user', () => { + fixtures.mindy.action('blockuser', { // FIXME + user: eve.name, + reason: 'testing', + }, 'POST').then((response) => { + assert.equal(response.body.blockuser.result, 'Success'); + }); + }); + + it('should by able to edit a page', () => { + eve.edit(name, 'Three', 'third'); // FIXME + }); +}); From 1b0a31235a9a5d344bceca8466e65ed722735944 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 6 Aug 2019 13:41:55 +0200 Subject: [PATCH 2/2] UserBLock test passes! --- SuperTest/actionapi.js | 199 ++++++++++++++++++++++++++---------- SuperTest/fixtures.js | 58 +++++------ SuperTest/test/UserBlock.js | 65 +++++++----- 3 files changed, 212 insertions(+), 110 deletions(-) diff --git a/SuperTest/actionapi.js b/SuperTest/actionapi.js index 8649673..7b494a0 100644 --- a/SuperTest/actionapi.js +++ b/SuperTest/actionapi.js @@ -4,144 +4,227 @@ const uniqid = require('uniqid'); const config = require('./config.json'); const methods = { + /** + * Constructs an HTTP request to the action API and returns the + * corresponding supertest Test object, which behaves like a + * superagent Request. It can be used like a Promise that resolves + * to a Response. + * + * The request has not been sent when this method returns, + * and can still be modified like a superagent request. + * Call end() or then(), use use await to send the request. + * * @param actionName * @param {Object} params * @param post - * @returns Response + * + * @returns Test */ - async action(actionName, params, post = false) { + async request(actionName, params, post = false) { + // FIXME: it would be nice if we could resolve/await any promises in params + const defaultParams = { action: actionName, format: 'json', }; - let resp; + let req; if (post) { - resp = this.post('') + req = this.post('') .type('form') .send({ ...defaultParams, ...params }); } else { - resp = this.get('') + req = this.get('') .query({ ...defaultParams, ...params }); } - await resp.expect(200); - return resp.response; + return req; + }, + + /** + * Executes an HTTP request to the action API and returns the parsed + * response body. Will fail if the reponse contains an error code. + * + * @param actionName + * @param {Object} params + * @param post + * @returns {Promise} + */ + async action(actionName, params, post = false) { + const response = await this.request(actionName, params, post); + + assert.equal(response.status, 200); + assert.exists(response.body); + + if (response.body.error) { + assert.fail(`Action "${actionName}" returned error code "${response.body.error.code}": ${response.body.error.info}!`); + } + + return response.body; + }, + + /** + * Executes an HTTP request to the action API and returns the error + * stanza of the response body. Will fail if there is no error stanza. + * This is intended as an easy way to test for expected errors. + * + * @param actionName + * @param {Object} params + * @param post + * @returns {Promise} + */ + async actionError(actionName, params, post = false) { + const response = await this.request(actionName, params, post); + + assert.equal(response.status, 200); + assert.exists(response.body); + assert.exists(response.body.error); + return response.body.error; }, /** + * Loads the given tokens. Any cached tokens are reset. + * * @param {string[]} ttypes - * @returns Response + * @returns {Promise} */ - loadTokens(ttypes) { - const resp = this.action( + async loadTokens(ttypes) { + const result = await this.action( 'query', { meta: 'tokens', types: ttypes.join('|') }, ); - this.tokens = resp.body.query.tokens; - return resp; + this.tokens = result.query.tokens; + return result.query.tokens; }, /** + * Returns the given token. If the token is not cached, it is requested + * and then cached. + * * @param {string} ttype - * @returns string + * @returns {Promise} */ - token(ttype) { + async token(ttype = 'csrf') { if (ttype in this.tokens) { return this.tokens[ttype]; } // TODO: skip tokens we already have! - const newTokens = this.action( + const newTokens = (await this.action( 'query', { meta: 'tokens', type: ttype }, - ).body.query.tokens; + )).query.tokens; this.tokens = { ...this.tokens, ...newTokens }; - return this.tokens[ttype]; + + const tname = `${ttype}token`; + assert.exists(this.tokens[tname]); + return this.tokens[tname]; }, /** + * Logs this agent in as the given user. + * * @param {string} username * @param {string} password - * @returns string + * @returns {Promise} */ - login(username, password) { - this.action( + async login(username, password) { + const result = await this.action( + 'login', { - action: 'login', lgname: username, lgpassword: password, - lgtoken: this.token('login'), + lgtoken: await this.token('login'), }, 'POST', - ).then((response) => { - assert.equal(response.body.login.result, 'Success'); - }); + ); + assert.equal(result.login.result, 'Success', + `Login for "${username}": ${result.login.reason}`); + return result.login; }, /** - * @param params - * @returns Response + * Performs an edit on a page. + * + * @param {string} pageTitle + * @param {Object} params + * @returns {Promise} */ - edit(params) { + async edit(pageTitle, params) { const editParams = { + title: pageTitle, text: 'Lorem Ipsum', comment: 'testing', }; - editParams.token = params.token || this.token('edit'); + editParams.token = params.token || await this.token('csrf'); + + const result = await this.action('edit', { ...editParams, ...params }, 'POST'); + assert.equal(result.edit.result, 'Success'); - return this.action('edit', { ...editParams, ...params }, 'POST') - .then((response) => { - assert.equal(response.body.edit.result, 'Success'); - return response.body.edit.newrevid; - }); + return result.edit; }, /** - * @param params - * @returns Response + * @param {Object} params + * @returns {Promise} */ - createAccount(params) { + async createAccount(params) { const defaults = { - token: params.token || this.token('createaccount'), + createtoken: params.token || await this.token('createaccount'), retype: params.retype || params.password, + createreturnurl: config.base_uri, }; - return this.action('createaccount', { ...defaults, ...params }, 'POST') - .then((response) => { - assert.equal(response.body.createuser.result, 'PASS'); - }); + const result = await this.action('createaccount', { ...defaults, ...params }, 'POST'); + assert.equal(result.createaccount.status, 'PASS'); + return result.createaccount; }, /** - * @param userName - * @param groups - * @returns Response + * @param {string} userName + * @param {string[]} groups + * @returns {Promise} */ - addGroups(userName, groups) { + async addGroups(userName, groups) { const gprops = { - action: 'userrights', user: userName, add: groups.join('|'), - token: this.token('userrights'), + token: await this.token('userrights'), }; - return this.action(gprops, 'POST').then((response) => { - assert.isDefined(response.body.userrights.added); - }); + const result = await this.action('userrights', gprops, 'POST'); + assert.isOk(result.userrights.added); + return result.userrights; }, }; /** + * Constructs a new agent for making HTTP requests to the action API. + * The agent acts like a browser session and has its own cookie yar. + * + * If a user name and a password is given, a login for this user is performed, + * and the corresponding server session is associated with this agent. + * This should only be used when instantiating fixtures. + * + * If no password is given, a new user account is created with a random + * password and a random suffix appended to the user name. The new user is + * then logged in. This should be used to construct a temporary unique + * user account that can be modified and detroyed by tests. + * + * When used with no user name, the agent behaves like an "anonymous" user. + * Note that all anonymous users share the same IP address, even though they + * don't share a browser session (cookie jar). This means that are treated + * as the same user in some respects, but not in others. + * * @param {string|null} name * @param {string|null} passwd - * @returns TestAgent + * @returns {Promise} */ -module.exports.agent = (name = null, passwd = null) => { +module.exports.agent = async (name = null, passwd = null) => { const instance = supertest.agent(config.base_uri); instance.tokens = {}; @@ -159,17 +242,21 @@ module.exports.agent = (name = null, passwd = null) => { uname = name + uniqid(); upass = uniqid(); - instance.createAccount({ name: uname, password: upass }); + const account = await instance.createAccount({ username: uname, password: upass }); + uname = account.username; } - instance.login(uname, passwd); - instance.name = uname; + const login = await instance.login(uname, upass); + instance.username = login.lgusername; + instance.userid = login.lguserid; } return instance; }; /** + * Returns a unique title for use in tests. + * * @param {string|null} namePrefix * @returns string */ diff --git a/SuperTest/fixtures.js b/SuperTest/fixtures.js index ee9e318..7b4a42e 100644 --- a/SuperTest/fixtures.js +++ b/SuperTest/fixtures.js @@ -2,43 +2,39 @@ const uniqid = require('uniqid'); const api = require('./actionapi'); const config = require('./config.json'); -const fixtures = { - root: null, // lazified later - mindy: null, // lazified later -}; +module.exports = { -const root = () => { - const agent = api.agent(config.root_user.name, config.root_user.password); - agent.loadTokens(['edit', 'createaccount', 'userrights', 'csrf']); + // singletons + singletons: {}, - return agent; -}; + async root() { + if (this.singletons.root) { + return this.singletons.root; + } -const mindy = () => { - const passwd = uniqid(); + this.singletons.root = await api.agent(config.root_user.name, config.root_user.password); + await this.singletons.root.loadTokens(['createaccount', 'userrights', 'csrf']); - fixtures.root.createAccount({ name: 'Mindy', password: passwd }); - fixtures.root.addGroups('Mindy', ['sysop']); + return this.singletons.root; + }, - const agent = api.agent('Mindy', passwd); - agent.loadTokens(['edit', 'userrights', 'csrf']); + async mindy() { + if (this.singletons.mindy) { + return this.singletons.mindy; + } - return agent; -}; + // TODO: Use a fixed user name for Mindy. Works only on a blank wiki. + const uname = 'Mindy_' + uniqid(); + const passwd = uniqid(); + const root = await this.root(); -// Define lazy initialization accessors for fixtures. -// FIXME: is this the correct way? Isn't there a module for this? -const lazyfy = (obj, name, getter) => { - Object.defineProperty(obj, name, { - get: () => { - const v = getter(); - Object.defineProperty(obj, name, { value: v }); - return v; - }, - }); -}; + await root.createAccount({ username: uname, password: passwd }); + await root.addGroups(uname, ['sysop']); + + this.singletons.mindy = await api.agent(uname, passwd); + await this.singletons.mindy.loadTokens(['userrights', 'csrf']); -lazyfy(fixtures, 'root', root); -lazyfy(fixtures, 'mindy', mindy); + return this.singletons.mindy; + }, -module.exports = fixtures; +}; diff --git a/SuperTest/test/UserBlock.js b/SuperTest/test/UserBlock.js index edc78b5..c0d0400 100644 --- a/SuperTest/test/UserBlock.js +++ b/SuperTest/test/UserBlock.js @@ -2,43 +2,62 @@ const { assert } = require('chai'); const fixtures = require('../fixtures'); const api = require('../actionapi'); -describe('Blocking a user', function () { +describe('Blocking a user', function testBlockingAUser() { this.timeout('5s'); - let name, eve; + let pageTitle, eve, mindy; - before(() => { - name = api.title('Block_'); - eve = api.agent('Eve_'); + before(async () => { + [pageTitle, eve, mindy] = await Promise.all([ + api.title('Block_'), + api.agent('Eve_'), + fixtures.mindy(), + ]); }); - it('should edit a page', () => { - eve.edit(name, 'One', 'first'); // FIXME + it('the user should edit a page', async () => { + await eve.edit(pageTitle, { text: 'One', comment: 'first' }); }); - it('should block a user', () => { - fixtures.mindy.action('blockuser', { // FIXME - user: eve.name, + it('an admin should block the user', async () => { + const result = await mindy.action('block', { + user: eve.username, reason: 'testing', - }, 'POST').then((response) => { - assert.equal(response.body.blockuser.result, 'Success'); - }); + token: await mindy.token(), + }, 'POST'); + + assert.exists(result.block.id); + assert.equal(result.block.userID, eve.userid); + assert.equal(result.block.user, eve.username); }); - it('should fail to edit a page', () => { - eve.edit(name, 'Two', 'second'); // FIXME + it('the user should fail to edit a page', async () => { + const error = await eve.actionError( + 'edit', + { + title: pageTitle, + text: 'Two', + comment: 'second', + token: await eve.token('csrf'), + }, + 'POST', + ); + assert.equal(error.code, 'blocked'); }); - it('should unblock a user', () => { - fixtures.mindy.action('blockuser', { // FIXME - user: eve.name, + it('an admin should unblock the user', async () => { + const result = await mindy.action('unblock', { + user: eve.username, reason: 'testing', - }, 'POST').then((response) => { - assert.equal(response.body.blockuser.result, 'Success'); - }); + token: await mindy.token(), + }, 'POST'); + + assert.exists(result.unblock.id); + assert.equal(result.unblock.userid, eve.userid); + assert.equal(result.unblock.user, eve.username); }); - it('should by able to edit a page', () => { - eve.edit(name, 'Three', 'third'); // FIXME + it('the user should by able to edit a page again', async () => { + await eve.edit(pageTitle, { text: 'Three', comment: 'third' }); }); });