+2016-06-13 Ryosuke Niwa <rniwa@webkit.org>
+
+ Invalid token error when trying to create an A/B analysis for a range
+ https://bugs.webkit.org/show_bug.cgi?id=158679
+
+ Reviewed by Chris Dumez.
+
+ The problem in this particular case was due to another website overriding cookies for our subdomain.
+ Make PrivilegedAPI robust against its token becoming invalid in general to fix the bug since the cookie
+ is only available under /privileged-api/ and the v3 UI can't access it for security reasons.
+
+ This patch factors out PrivilegedAPI out of remote.js so that it can be tested separately in server tests
+ as well as unit tests even though RemoteAPI itself is implemented differently in each case.
+
+ * init-database.sql: Added a forgotten default value "false" to run_marked_outlier.
+ * public/v3/index.html:
+ * public/v3/privileged-api.js: Added. Extracted out of public/v3/remote.js.
+ (PrivilegedAPI.sendRequest): Fixed the bug. When the initial request fails with "InvalidToken" error,
+ re-generate the token and re-issue the request.
+ (PrivilegedAPI.requestCSRFToken):
+ * public/v3/remote.js:
+ (RemoteAPI.postJSON): Added to match tools/js/remote.js.
+ (RemoteAPI.postJSONWithStatus): Ditto.
+ (PrivilegedAPI): Moved to privileged-api.js.
+ * server-tests/api-measurement-set-tests.js: Removed the unused require for crypto.
+ * server-tests/privileged-api-upate-run-status.js: Added tests for /privileged-api/update-run-status.
+ * server-tests/resources/test-server.js:
+ (TestServer.prototype.inject): Clear the cookies as well as tokens in PrivilegedAPI.
+ * tools/js/remote.js:
+ (RemoteAPI): Added the support for PrivilegedAPI by making cookie set by the server persist.
+ (RemoteAPI.prototype.clearCookies): Added for tests.
+ (RemoteAPI.prototype.postJSON): Make sure sendHttpRequest always sends a valid JSON.
+ (RemoteAPI.prototype.postJSONWithStatus): Added since this API is used PrivilegedAPI.
+ (RemoteAPI.prototype.sendHttpRequest): Retain the cookie set by the server and send it back in each request.
+ * tools/js/v3-models.js:
+ * unit-tests/privileged-api-tests.js: Added unit tests for PrivilegedAPI.
+ * unit-tests/resources/mock-remote-api.js:
+ (MockRemoteAPI.postJSON): Added for unit testing.
+ (MockRemoteAPI.postJSONWithStatus): Ditto.
+
2016-06-13 Ryosuke Niwa <rniwa@webkit.org>
/admin/tests is very slow
run_mean_cache double precision,
run_sum_cache double precision,
run_square_sum_cache double precision,
- run_marked_outlier boolean,
+ run_marked_outlier boolean NOT NULL DEFAULT FALSE,
CONSTRAINT test_config_build_must_be_unique UNIQUE(run_config, run_build));
CREATE INDEX run_config_index ON test_runs(run_config);
CREATE INDEX run_build_index ON test_runs(run_build);
<script src="instrumentation.js"></script>
<script src="remote.js"></script>
+ <script src="privileged-api.js"></script>
<script src="models/time-series.js"></script>
<script src="models/measurement-adaptor.js"></script>
--- /dev/null
+"use strict";
+
+// FIXME: Use real class syntax once the dependency on data.js has been removed.
+var PrivilegedAPI = class {
+
+ static sendRequest(path, data)
+ {
+ var clonedData = {};
+ for (var key in data)
+ clonedData[key] = data[key];
+
+ return this.requestCSRFToken().then(function (token) {
+ clonedData['token'] = token;
+ return RemoteAPI.postJSONWithStatus('/privileged-api/' + path, clonedData).catch(function (status) {
+ if (status != 'InvalidToken')
+ return Promise.reject(status);
+ PrivilegedAPI._token = null;
+ return PrivilegedAPI.requestCSRFToken().then(function (token) {
+ clonedData['token'] = token;
+ return RemoteAPI.postJSONWithStatus('/privileged-api/' + path, clonedData);
+ });
+ });
+ });
+ }
+
+ static requestCSRFToken()
+ {
+ var maxNetworkLatency = 3 * 60 * 1000; /* 3 minutes */
+ if (this._token && this._expiration > Date.now() + maxNetworkLatency)
+ return Promise.resolve(this._token);
+
+ return RemoteAPI.postJSONWithStatus('/privileged-api/generate-csrf-token').then(function (result) {
+ PrivilegedAPI._token = result['token'];
+ PrivilegedAPI._expiration = new Date(result['expiration']);
+ return PrivilegedAPI._token;
+ });
+ }
+
+}
+
+PrivilegedAPI._token = null;
+PrivilegedAPI._expiration = null;
+
+if (typeof module != 'undefined')
+ module.exports.PrivilegedAPI = PrivilegedAPI;
+"use strict";
var RemoteAPI = {};
+RemoteAPI.postJSON = function (path, data)
+{
+ return this.getJSON(path, data || {});
+}
+
+RemoteAPI.postJSONWithStatus = function (path, data)
+{
+ console.log(document.cookie);
+ return this.getJSONWithStatus(path, data || {});
+}
+
RemoteAPI.getJSON = function(path, data)
{
console.assert(!path.startsWith('http:') && !path.startsWith('https:') && !path.startsWith('file:'));
return content;
});
}
-
-// FIXME: Use real class syntax once the dependency on data.js has been removed.
-PrivilegedAPI = class {
-
- static sendRequest(path, data)
- {
- return this.requestCSRFToken().then(function (token) {
- var clonedData = {};
- for (var key in data)
- clonedData[key] = data[key];
- clonedData['token'] = token;
- return RemoteAPI.getJSONWithStatus('../privileged-api/' + path, clonedData);
- });
- }
-
- static requestCSRFToken()
- {
- var maxNetworkLatency = 3 * 60 * 1000; /* 3 minutes */
- if (this._token && this._expiration > Date.now() + maxNetworkLatency)
- return Promise.resolve(this._token);
-
- return RemoteAPI.getJSONWithStatus('../privileged-api/generate-csrf-token', {}).then(function (result) {
- PrivilegedAPI._token = result['token'];
- PrivilegedAPI._expiration = new Date(result['expiration']);
- return PrivilegedAPI._token;
- });
- }
-
-}
-
-PrivilegedAPI._token = null;
-PrivilegedAPI._expiration = null;
'use strict';
const assert = require('assert');
-const crypto = require('crypto');
const TestServer = require('./resources/test-server.js');
const addBuilderForReport = require('./resources/common-operations.js').addBuilderForReport;
--- /dev/null
+'use strict';
+
+require('../tools/js/v3-models.js');
+
+const assert = require('assert');
+
+const TestServer = require('./resources/test-server.js');
+const addBuilderForReport = require('./resources/common-operations.js').addBuilderForReport;
+const connectToDatabaseInEveryTest = require('./resources/common-operations.js').connectToDatabaseInEveryTest;
+
+describe("/privileged-api/update-run-status", function () {
+ this.timeout(1000);
+ TestServer.inject();
+ connectToDatabaseInEveryTest();
+
+ const reportWithRevision = [{
+ "buildNumber": "124",
+ "buildTime": "2013-02-28T15:34:51",
+ "revisions": {
+ "WebKit": {
+ "revision": "191622",
+ "timestamp": (new Date(1445945816878)).toISOString(),
+ },
+ },
+ "builderName": "someBuilder",
+ "builderPassword": "somePassword",
+ "platform": "some platform",
+ "tests": {
+ "Suite": {
+ "tests": {
+ "test1": {
+ "metrics": {"Time": { "current": [11] }}
+ }
+ }
+ },
+ }}];
+
+ it("should be able to mark a run as an outlier", function (done) {
+ const db = TestServer.database();
+ let id;
+ addBuilderForReport(reportWithRevision[0]).then(function () {
+ return TestServer.remoteAPI().postJSON('/api/report/', reportWithRevision);
+ }).then(function (response) {
+ assert.equal(response['status'], 'OK');
+ return db.selectAll('test_runs');
+ }).then(function (runRows) {
+ assert.equal(runRows.length, 1);
+ assert.equal(runRows[0]['mean_cache'], 11);
+ assert.equal(runRows[0]['iteration_count_cache'], 1);
+ assert.equal(runRows[0]['marked_outlier'], false);
+ id = runRows[0]['id'];
+ return PrivilegedAPI.requestCSRFToken();
+ }).then(function () {
+ return PrivilegedAPI.sendRequest('update-run-status', {'run': id, 'markedOutlier': true, 'token': PrivilegedAPI._token});
+ }).then(function () {
+ return db.selectAll('test_runs');
+ }).then(function (runRows) {
+ assert.equal(runRows.length, 1);
+ assert.equal(runRows[0]['mean_cache'], 11);
+ assert.equal(runRows[0]['iteration_count_cache'], 1);
+ assert.equal(runRows[0]['marked_outlier'], true);
+ done();
+ }).catch(done);
+ });
+
+ it("should reject when the token is not set in cookie", function (done) {
+ const db = TestServer.database();
+ addBuilderForReport(reportWithRevision[0]).then(function () {
+ return TestServer.remoteAPI().postJSON('/api/report/', reportWithRevision);
+ }).then(function (response) {
+ assert.equal(response['status'], 'OK');
+ return db.selectAll('test_runs');
+ }).then(function (runRows) {
+ assert.equal(runRows.length, 1);
+ assert.equal(runRows[0]['marked_outlier'], false);
+ return PrivilegedAPI.requestCSRFToken();
+ }).then(function () {
+ RemoteAPI.clearCookies();
+ return RemoteAPI.postJSONWithStatus('/privileged-api/update-run-status', {token: PrivilegedAPI._token});
+ }).then(function () {
+ assert(false, 'PrivilegedAPI.sendRequest should reject');
+ }, function (response) {
+ assert.equal(response['status'], 'InvalidToken');
+ done();
+ }).catch(done);
+ });
+
+ it("should reject when the token in the request content is bad", function (done) {
+ const db = TestServer.database();
+ addBuilderForReport(reportWithRevision[0]).then(function () {
+ return TestServer.remoteAPI().postJSON('/api/report/', reportWithRevision);
+ }).then(function (response) {
+ assert.equal(response['status'], 'OK');
+ return db.selectAll('test_runs');
+ }).then(function (runRows) {
+ assert.equal(runRows.length, 1);
+ assert.equal(runRows[0]['marked_outlier'], false);
+ return PrivilegedAPI.requestCSRFToken();
+ }).then(function () {
+ return RemoteAPI.postJSONWithStatus('/privileged-api/update-run-status', {token: 'bad'});
+ }).then(function () {
+ assert(false, 'PrivilegedAPI.sendRequest should reject');
+ }, function (response) {
+ assert.equal(response['status'], 'InvalidToken');
+ done();
+ }).catch(done);
+ });
+
+ it("should be able to unmark a run as an outlier", function (done) {
+ const db = TestServer.database();
+ addBuilderForReport(reportWithRevision[0]).then(function () {
+ return TestServer.remoteAPI().postJSON('/api/report/', reportWithRevision);
+ }).then(function (response) {
+ assert.equal(response['status'], 'OK');
+ return db.selectAll('test_runs');
+ }).then(function (runRows) {
+ assert.equal(runRows.length, 1);
+ assert.equal(runRows[0]['marked_outlier'], false);
+ return PrivilegedAPI.sendRequest('update-run-status', {'run': runRows[0]['id'], 'markedOutlier': true});
+ }).then(function () {
+ return db.selectAll('test_runs');
+ }).then(function (runRows) {
+ assert.equal(runRows.length, 1);
+ assert.equal(runRows[0]['marked_outlier'], true);
+ return PrivilegedAPI.sendRequest('update-run-status', {'run': runRows[0]['id'], 'markedOutlier': false});
+ }).then(function () {
+ return db.selectAll('test_runs');
+ }).then(function (runRows) {
+ assert.equal(runRows.length, 1);
+ assert.equal(runRows[0]['marked_outlier'], false);
+ done();
+ }).catch(done);
+ });
+});
self.cleanDataDirectory();
originalRemote = global.RemoteAPI;
global.RemoteAPI = self._remote;
+ self._remote.clearCookies();
+
+ if (global.PrivilegedAPI) {
+ global.PrivilegedAPI._token = null;
+ global.PrivilegedAPI._expiration = null;
+ }
});
after(function () {
constructor(server)
{
this._server = null;
+ this._cookies = new Map;
if (server)
this.configure(server);
}
};
}
+ clearCookies() { this._cookies = new Map; }
+
getJSON(path)
{
return this.sendHttpRequest(path, 'GET', null, null).then(function (result) {
postJSON(path, data)
{
const contentType = 'application/json';
- const payload = JSON.stringify(data);
+ const payload = JSON.stringify(data || {});
return this.sendHttpRequest(path, 'POST', 'application/json', payload).then(function (result) {
try {
return JSON.parse(result.responseText);
});
}
+ postJSONWithStatus(path, data)
+ {
+ return this.postJSON(path, data).then(function (result) {
+ if (result['status'] != 'OK')
+ return Promise.reject(result);
+ return result;
+ });
+ }
+
postFormUrlencodedData(path, data)
{
const contentType = 'application/x-www-form-urlencoded';
sendHttpRequest(path, method, contentType, content)
{
let server = this._server;
+ const self = this;
return new Promise(function (resolve, reject) {
let options = {
hostname: server.host,
let responseText = '';
response.setEncoding('utf8');
response.on('data', function (chunk) { responseText += chunk; });
- response.on('end', function () { resolve({statusCode: response.statusCode, responseText: responseText}); });
+ response.on('end', function () {
+ if ('set-cookie' in response.headers) {
+ for (const cookie of response.headers['set-cookie']) {
+ var nameValue = cookie.split('=')
+ self._cookies.set(nameValue[0], nameValue[1]);
+ }
+ }
+ resolve({statusCode: response.statusCode, responseText: responseText});
+ });
});
request.on('error', reject);
if (contentType)
request.setHeader('Content-Type', contentType);
+ if (self._cookies.size) {
+ request.setHeader('Cookie', Array.from(self._cookies.keys()).map(function (key) {
+ return `${key}=${self._cookies.get(key)}`;
+ }).join('; '));
+ }
+
if (content)
request.write(content);
importFromV3('models/test.js', 'Test');
importFromV3('models/test-group.js', 'TestGroup');
+importFromV3('privileged-api.js', 'PrivilegedAPI');
importFromV3('instrumentation.js', 'Instrumentation');
global.Statistics = require('../../public/shared/statistics.js');
--- /dev/null
+'use strict';
+
+const assert = require('assert');
+
+let MockRemoteAPI = require('./resources/mock-remote-api.js').MockRemoteAPI;
+require('../tools/js/v3-models.js');
+
+describe('PrivilegedAPI', function () {
+ let requests = MockRemoteAPI.inject();
+
+ beforeEach(function () {
+ PrivilegedAPI._token = null;
+ })
+
+ describe('requestCSRFToken', function () {
+ it('should generate a new token', function () {
+ PrivilegedAPI.requestCSRFToken();
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ });
+
+ it('should not generate a new token if the existing token had not expired', function (done) {
+ const tokenRequest = PrivilegedAPI.requestCSRFToken();
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ requests[0].resolve({
+ token: 'abc',
+ expiration: Date.now() + 3600 * 1000,
+ });
+ tokenRequest.then(function (token) {
+ assert.equal(token, 'abc');
+ PrivilegedAPI.requestCSRFToken();
+ assert.equal(requests.length, 1);
+ done();
+ }).catch(done);
+ });
+
+ it('should generate a new token if the existing token had already expired', function (done) {
+ const tokenRequest = PrivilegedAPI.requestCSRFToken();
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ requests[0].resolve({
+ token: 'abc',
+ expiration: Date.now() - 1,
+ });
+ tokenRequest.then(function (token) {
+ assert.equal(token, 'abc');
+ PrivilegedAPI.requestCSRFToken();
+ assert.equal(requests.length, 2);
+ assert.equal(requests[1].url, '/privileged-api/generate-csrf-token');
+ done();
+ }).catch(done);
+ });
+ });
+
+ describe('sendRequest', function () {
+
+ it('should generate a new token if no token had been fetched', function (done) {
+ PrivilegedAPI.sendRequest('test', {});
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ requests[0].resolve({
+ token: 'abc',
+ expiration: Date.now() + 100 * 1000,
+ });
+ Promise.resolve().then(function () {
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 2);
+ assert.equal(requests[1].url, '/privileged-api/test');
+ done();
+ }).catch(done);
+ });
+
+ it('should not generate a new token if the existing token had not been expired', function (done) {
+ PrivilegedAPI.sendRequest('test', {});
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ requests[0].resolve({
+ token: 'abc',
+ expiration: Date.now() + 3600 * 1000,
+ });
+ Promise.resolve().then(function () {
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 2);
+ assert.equal(requests[1].url, '/privileged-api/test');
+ PrivilegedAPI.sendRequest('test2', {});
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 3);
+ assert.equal(requests[2].url, '/privileged-api/test2');
+ done();
+ }).catch(done);
+ });
+
+ it('should reject immediately when a token generation had failed', function (done) {
+ const request = PrivilegedAPI.sendRequest('test', {});
+ let caught = false;
+ request.catch(function () { caught = true; });
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ requests[0].reject({status: 'FailedToGenerateToken'});
+ Promise.resolve().then(function () {
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 1);
+ assert(caught);
+ done();
+ }).catch(done);
+ });
+
+ it('should re-generate token when it had become invalid', function (done) {
+ PrivilegedAPI.sendRequest('test', {});
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ requests[0].resolve({
+ token: 'abc',
+ expiration: Date.now() + 3600 * 1000,
+ });
+ Promise.resolve().then(function () {
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 2);
+ assert.equal(requests[1].data.token, 'abc');
+ assert.equal(requests[1].url, '/privileged-api/test');
+ requests[1].reject('InvalidToken');
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 3);
+ assert.equal(requests[2].url, '/privileged-api/generate-csrf-token');
+ requests[2].resolve({
+ token: 'def',
+ expiration: Date.now() + 3600 * 1000,
+ });
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 4);
+ assert.equal(requests[3].data.token, 'def');
+ assert.equal(requests[3].url, '/privileged-api/test');
+ done();
+ }).catch(done);
+ });
+
+ it('should not re-generate token when the re-fetched token was invalid', function (done) {
+ PrivilegedAPI.sendRequest('test', {});
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, '/privileged-api/generate-csrf-token');
+ requests[0].resolve({
+ token: 'abc',
+ expiration: Date.now() + 3600 * 1000,
+ });
+ Promise.resolve().then(function () {
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 2);
+ assert.equal(requests[1].data.token, 'abc');
+ assert.equal(requests[1].url, '/privileged-api/test');
+ requests[1].reject('InvalidToken');
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 3);
+ assert.equal(requests[2].url, '/privileged-api/generate-csrf-token');
+ requests[2].resolve({
+ token: 'def',
+ expiration: Date.now() + 3600 * 1000,
+ });
+ return Promise.resolve();
+ }).then(function () {
+ assert.equal(requests.length, 4);
+ assert.equal(requests[3].data.token, 'def');
+ assert.equal(requests[3].url, '/privileged-api/test');
+ requests[3].reject('InvalidToken');
+ }).then(function () {
+ assert.equal(requests.length, 4);
+ done();
+ }).catch(done);
+ });
+
+ });
+
+});
{
return this._addRequest(url, 'GET', null);
},
+ postJSON: function (url, data)
+ {
+ return this._addRequest(url, 'POST', data);
+ },
+ postJSONWithStatus: function (url, data)
+ {
+ return this._addRequest(url, 'POST', data);
+ },
postFormUrlencodedData: function (url, data)
{
return this._addRequest(url, 'POST', data);