const crypto = require('crypto') const jose = require('jose') const base64Url = require('base64url') const stealthyRequire = require('stealthy-require') require('../../../spec_helper') const _ = require('lodash') const os = require('os') const encryption = require('../../../../lib/cloud/encryption') const { agent, } = require('@packages/network') const pkg = require('@packages/root') const api = require('../../../../lib/cloud/api').default const cache = require('../../../../lib/cache').cache const errors = require('../../../../lib/errors') const machineId = require('../../../../lib/cloud/machine_id') const Promise = require('bluebird') const API_BASEURL = 'http://localhost:1234' const API_PROD_BASEURL = 'https://api.cypress.io' const API_PROD_PROXY_BASEURL = 'https://api-proxy.cypress.io' const CLOUD_BASEURL = 'http://localhost:3000' const AUTH_URLS = { 'dashboardAuthUrl': 'http://localhost:3000/test-runner.html', 'dashboardLogoutUrl': 'http://localhost:3000/logout', } const { PROTOCOL_STUB_VALID, } = require('@tooling/system-tests/lib/protocol-stubs/protocolStubResponse') const makeError = (details = {}) => { return _.extend(new Error(details.message || 'Some error'), details) } const OS_PLATFORM = 'linux' const encryptRequest = encryption.encryptRequest const decryptReqBodyAndRespond = ({ reqBody, resBody }, fn) => { const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, }) /** * @type {crypto.KeyObject} */ let _secretKey sinon.stub(encryption, 'encryptRequest').callsFake(async (params) => { if (reqBody) { expect(params.body).to.deep.eq(reqBody) } const { secretKey, jwe } = await encryptRequest(params, publicKey) if (fn) { encryption.encryptRequest.restore() } _secretKey = secretKey return { secretKey, jwe } }) return async (uri, encReqBody) => { const decryptedSecretKey = crypto.createSecretKey( crypto.privateDecrypt( privateKey, Buffer.from(base64Url.toBase64(encReqBody.recipients[0].encrypted_key), 'base64'), ), ) expect(_secretKey.export().toString('utf8')).to.eq(decryptedSecretKey.export().toString('utf8')) const enc = new jose.GeneralEncrypt( Buffer.from(JSON.stringify(resBody)), ) enc.setProtectedHeader({ alg: 'A256GCMKW', enc: 'A256GCM', zip: 'DEF' }).addRecipient(decryptedSecretKey) const jweResponse = await enc.encrypt() fn && fn() return jweResponse } } const preflightNock = (baseUrl) => { return nock(baseUrl) .matchHeader('x-route-version', '1') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/preflight') } describe('lib/cloud/api', () => { beforeEach(() => { api.setPreflightResult({ encrypt: false }) preflightNock(API_BASEURL) .reply(200, decryptReqBodyAndRespond({ resBody: { encrypt: false, apiUrl: `${API_BASEURL}/`, }, })) nock(API_BASEURL) .matchHeader('x-route-version', '2') .get('/auth') .reply(200, AUTH_URLS) api.clearCache() sinon.stub(os, 'platform').returns(OS_PLATFORM) if (this.oldEnv) { process.env = this.oldEnv } this.oldEnv = Object.assign({}, process.env) process.env.DISABLE_API_RETRIES = true return sinon.stub(cache, 'getUser').resolves({ name: 'foo bar', email: 'foo@bar', //authToken: 'auth-token-123' }) }) afterEach(() => { api.resetPreflightResult() sinon.restore() }) context('.rp', () => { beforeEach(() => { sinon.spy(agent, 'addRequest') return nock.enableNetConnect() }) // nock will prevent requests from reaching the agent it('makes calls using the correct agent', () => { nock.cleanAll() return api.ping() .thenThrow() .catch(() => { expect(agent.addRequest).to.be.calledOnce expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { href: 'http://localhost:1234/ping', }) }) }) it('sets rejectUnauthorized on the request', () => { nock.cleanAll() return api.ping() .thenThrow() .catch(() => { expect(agent.addRequest).to.be.calledOnce expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { rejectUnauthorized: true, }) }) }) context('with a proxy defined', () => { beforeEach(function () { nock.cleanAll() }) it('makes calls using the correct agent', () => { process.env.HTTP_PROXY = (process.env.HTTPS_PROXY = 'http://foo.invalid:1234') process.env.NO_PROXY = '' return api.ping() .thenThrow() .catch(() => { expect(agent.addRequest).to.be.calledOnce expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { href: 'http://localhost:1234/ping', }) }) }) }) }) context('.ping', () => { it('GET /ping', () => { nock(API_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .get('/ping') .reply(200, 'OK') return api.ping() .then((resp) => { expect(resp).to.eq('OK') }) }) it('tags errors', () => { nock(API_BASEURL) .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .get('/ping') .reply(500, {}) return api.ping() .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) }) context('.sendPreflight', () => { let prodApi beforeEach(function () { this.timeout(30000) nock.cleanAll() sinon.restore() sinon.stub(os, 'platform').returns(OS_PLATFORM) process.env.CYPRESS_CONFIG_ENV = 'production' process.env.CYPRESS_API_URL = 'https://some.server.com' if (!prodApi) { prodApi = stealthyRequire(require.cache, () => { return require('../../../../lib/cloud/api').default }, () => { require('../../../../lib/cloud/encryption') }, module) } prodApi.resetPreflightResult() }) it('POST /preflight to proxy. returns encryption', () => { preflightNock(API_PROD_PROXY_BASEURL) .reply(200, decryptReqBodyAndRespond({ reqBody: { envUrl: 'https://some.server.com', dependencies: {}, errors: [], apiUrl: 'https://api.cypress.io/', projectId: 'abc123', }, resBody: { encrypt: true, apiUrl: `${API_PROD_BASEURL}/`, }, })) return prodApi.sendPreflight({ projectId: 'abc123' }) .then((ret) => { expect(ret).to.deep.eq({ encrypt: true, apiUrl: `${API_PROD_BASEURL}/` }) }) }) it('POST /preflight to proxy, and then api on response status code failure. returns encryption', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .reply(500) const scopeApi = preflightNock(API_PROD_BASEURL) .reply(200, decryptReqBodyAndRespond({ reqBody: { envUrl: 'https://some.server.com', dependencies: {}, errors: [], apiUrl: 'https://api.cypress.io/', projectId: 'abc123', }, resBody: { encrypt: true, apiUrl: `${API_PROD_BASEURL}/`, }, })) return prodApi.sendPreflight({ projectId: 'abc123' }) .then((ret) => { scopeProxy.done() scopeApi.done() expect(ret).to.deep.eq({ encrypt: true, apiUrl: `${API_PROD_BASEURL}/` }) }) }) it('POST /preflight to proxy, and then api on network failure. returns encryption', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .replyWithError('some request error') const scopeApi = preflightNock(API_PROD_BASEURL) .reply(200, decryptReqBodyAndRespond({ reqBody: { envUrl: 'https://some.server.com', dependencies: {}, errors: [], apiUrl: 'https://api.cypress.io/', projectId: 'abc123', }, resBody: { encrypt: true, apiUrl: `${API_PROD_BASEURL}/`, }, })) return prodApi.sendPreflight({ projectId: 'abc123' }) .then((ret) => { scopeProxy.done() scopeApi.done() expect(ret).to.deep.eq({ encrypt: true, apiUrl: `${API_PROD_BASEURL}/` }) }) }) it('sets timeout to 5 seconds when no CYPRESS_INITIAL_PREFLIGHT_TIMEOUT env is set', () => { sinon.stub(api.rp, 'post').resolves({}) return api.sendPreflight({}) .then(() => { expect(api.rp.post).to.be.calledWithMatch({ timeout: 5000 }) }) }) describe('when CYPRESS_INITIAL_PREFLIGHT_TIMEOUT env is set to a negative number', () => { const configuredTimeout = -1 let prevEnv beforeEach(() => { prevEnv = process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = configuredTimeout }) afterEach(() => { process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = prevEnv }) it('skips the no-agent preflight request', () => { preflightNock(API_PROD_PROXY_BASEURL) .replyWithError('should not be called') preflightNock(API_PROD_BASEURL) .reply(200, decryptReqBodyAndRespond({ reqBody: { envUrl: 'https://some.server.com', dependencies: {}, errors: [], apiUrl: 'https://api.cypress.io/', projectId: 'abc123', }, resBody: { encrypt: true, apiUrl: `${API_PROD_BASEURL}/`, }, })) return prodApi.sendPreflight({ projectId: 'abc123' }) .then((ret) => { expect(ret).to.deep.eq({ encrypt: true, apiUrl: `${API_PROD_BASEURL}/` }) }) }) }) describe('when CYPRESS_INITIAL_PREFLIGHT_TIMEOUT env is set to a positive number', () => { const configuredTimeout = 10000 let prevEnv beforeEach(() => { prevEnv = process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = configuredTimeout }) afterEach(() => { process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT = prevEnv api.rp.post.restore() }) it('makes the initial request with the number set in the env', () => { sinon.stub(api.rp, 'post').resolves({}) return api.sendPreflight({}) .then(() => { expect(api.rp.post).to.be.calledWithMatch({ timeout: configuredTimeout }) }) }) }) describe('errors', () => { it('[F1] POST /preflight TimeoutError', () => { preflightNock(API_BASEURL) .times(2) .delayConnection(5000) .reply(200, {}) return api.sendPreflight({ timeout: 100, }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err.message).to.eq('Error: ESOCKETTIMEDOUT') }) }) it('[F1] POST /preflight RequestError', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .replyWithError('first request error') const scopeApi = preflightNock(API_PROD_BASEURL) .replyWithError('2nd request error') return prodApi.sendPreflight({ projectId: 'abc123' }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { scopeProxy.done() scopeApi.done() expect(err).not.to.have.property('statusCode') expect(err).to.contain({ name: 'RequestError', message: 'Error: 2nd request error', }) }) }) it('[F1] POST /preflight statusCode >= 500', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .reply(500) const scopeApi = preflightNock(API_PROD_BASEURL) .reply(500) return prodApi.sendPreflight({ projectId: 'abc123' }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { scopeProxy.done() scopeApi.done() expect(err).to.contain({ name: 'StatusCodeError', statusCode: 500, }) }) }) it('[F2] POST /preflight statusCode = 404', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .reply(404) const scopeApi = preflightNock(API_PROD_BASEURL) .reply(404, '404 not found', { 'Content-Type': 'text/html', }) return prodApi.sendPreflight({ projectId: 'abc123' }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { scopeProxy.done() scopeApi.done() expect(err).to.contain({ name: 'StatusCodeError', statusCode: 404, }) }) }) it('[F3] POST /preflight statusCode = 422 but decrypt error', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .reply(422, { data: 'very encrypted and secure string' }) const scopeApi = preflightNock(API_PROD_BASEURL) .reply(422, { data: 'very encrypted and secure string' }) return prodApi.sendPreflight({ projectId: 'abc123' }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { scopeProxy.done() scopeApi.done() expect(err).not.to.have.property('statusCode') expect(err).to.have.property('name', 'DecryptionError') expect(err).to.have.property('message', 'JWE Recipients missing or incorrect type') }) }) it('[F3] POST /preflight statusCode = 200 but decrypt error', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .reply(200, { data: 'very encrypted and secure string' }) const scopeApi = preflightNock(API_PROD_BASEURL) .reply(201, 'very encrypted and secure string') return prodApi.sendPreflight({ projectId: 'abc123' }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { scopeProxy.done() scopeApi.done() expect(err).not.to.have.property('statusCode') expect(err).to.have.property('name', 'DecryptionError') expect(err).to.have.property('message', 'General JWE must be an object') }) }) it('[F3] POST /preflight statusCode = 201 but no body', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .reply(200) const scopeApi = preflightNock(API_PROD_BASEURL) .reply(201) return prodApi.sendPreflight({ projectId: 'abc123' }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { scopeProxy.done() scopeApi.done() expect(err).not.to.have.property('statusCode') expect(err).to.have.property('name', 'DecryptionError') expect(err).to.have.property('message', 'General JWE must be an object') }) }) it('[F4] POST /preflight statusCode = 412 valid decryption', () => { const scopeProxy = preflightNock(API_PROD_PROXY_BASEURL) .reply(412, decryptReqBodyAndRespond({ reqBody: { envUrl: 'https://some.server.com', dependencies: {}, errors: [], apiUrl: 'https://api.cypress.io/', projectId: 'abc123', }, resBody: { message: 'Recording is not working', errors: [ 'attempted to send invalid data', ], object: { projectId: 'cy12345', }, }, })) const scopeApi = preflightNock(API_PROD_BASEURL) .reply(200) return prodApi.sendPreflight({ projectId: 'abc123' }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { scopeProxy.done() expect(scopeApi.isDone()).to.be.false expect(err).to.contain({ name: 'StatusCodeError', message: '412 - {"message":"Recording is not working","errors":["attempted to send invalid data"],"object":{"projectId":"cy12345"}}', statusCode: 412, }) }) }) }) }) context('.createRun', () => { beforeEach(function () { this.protocolManager = { prepareAndSetupProtocol: sinon.stub(), } this.buildProps = { group: null, parallel: null, ciBuildId: null, projectId: 'id-123', recordKey: 'token-123', testingType: 'e2e', ci: { provider: 'circle', buildNumber: '987', params: { foo: 'bar' }, }, platform: {}, commit: { sha: 'sha', branch: 'master', authorName: 'brian', authorEmail: 'brian@cypress.io', message: 'such hax', remoteOrigin: 'https://github.com/foo/bar.git', }, specs: ['foo.js', 'bar.js'], runnerCapabilities: { 'protocolMountVersion': 2, 'dynamicSpecsInSerialMode': true, 'skipSpecAction': true, }, } }) it('POST /runs + returns runId', function () { nock(API_BASEURL) .get('/capture-protocol/script/protocolStub.js') .reply(200, PROTOCOL_STUB_VALID.compressed, { 'x-cypress-signature': PROTOCOL_STUB_VALID.sign, 'Content-Encoding': 'gzip', }) nock(API_BASEURL) .matchHeader('x-route-version', '4') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/runs', this.buildProps) .reply(200, { runId: 'new-run-id-123', capture: { url: 'http://localhost:1234/capture-protocol/script/protocolStub.js', }, }) const protocolManager = this.protocolManager const project = { set protocolManager (val) { // don't override with the setter so that the protocol manager is always the same }, get protocolManager () { return protocolManager }, getConfig: () => { return { port: 1234, devServerPublicPathRoute: '/dev-server', proxyUrl: 'http://localhost:1234', namespace: '__cypress', } }, get configDebugData () { return { filePreprocessorHandlerText: 'function () {}', } }, } return api.createRun({ ...this.buildProps, project, }) .then((ret) => { expect(ret).to.deep.eq({ runId: 'new-run-id-123', capture: { url: 'http://localhost:1234/capture-protocol/script/protocolStub.js', }, }) expect(this.protocolManager.prepareAndSetupProtocol).to.be.calledWith( PROTOCOL_STUB_VALID.value, { runId: 'new-run-id-123', testingType: 'e2e', mountVersion: 2, projectId: 'id-123', cloudApi: { url: 'http://localhost:1234/', retryWithBackoff: api.retryWithBackoff, requestPromise: api.rp, }, projectConfig: { port: 1234, devServerPublicPathRoute: '/dev-server', proxyUrl: 'http://localhost:1234', namespace: '__cypress', }, debugData: { filePreprocessorHandlerText: 'function () {}', }, mode: 'record', }, ) }) }) it('POST /runs + returns runId with encryption', function () { nock.cleanAll() sinon.restore() sinon.stub(os, 'platform').returns(OS_PLATFORM) nock(API_BASEURL) .get('/capture-protocol/script/protocolStub.js') .reply(200, PROTOCOL_STUB_VALID.compressed, { 'x-cypress-signature': PROTOCOL_STUB_VALID.sign, 'Content-Encoding': 'gzip', }) preflightNock(API_BASEURL) .reply(200, decryptReqBodyAndRespond({ resBody: { encrypt: true, apiUrl: `${API_BASEURL}/`, }, }, () => { nock(API_BASEURL) .defaultReplyHeaders({ 'x-cypress-encrypted': 'true' }) .matchHeader('x-route-version', '4') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/runs') .reply(200, decryptReqBodyAndRespond({ reqBody: this.buildProps, resBody: { runId: 'new-run-id-123', capture: { url: 'http://localhost:1234/capture-protocol/script/protocolStub.js', }, }, })) })) const protocolManager = this.protocolManager const project = { set protocolManager (val) { // don't override with the setter so that the protocol manager is always the same }, get protocolManager () { return protocolManager }, getConfig: () => { return { port: 1234, devServerPublicPathRoute: '/dev-server', proxyUrl: 'http://localhost:1234', namespace: '__cypress', } }, configDebugData: { filePreprocessorHandlerText: 'function () {}', }, } return api.createRun({ ...this.buildProps, project, }) .then((ret) => { expect(ret).to.deep.eq({ runId: 'new-run-id-123', capture: { url: 'http://localhost:1234/capture-protocol/script/protocolStub.js', }, }) expect(this.protocolManager.prepareAndSetupProtocol).to.be.calledWith( PROTOCOL_STUB_VALID.value, { runId: 'new-run-id-123', testingType: 'e2e', mountVersion: 2, projectId: 'id-123', cloudApi: { url: 'http://localhost:1234/', retryWithBackoff: api.retryWithBackoff, requestPromise: api.rp, }, projectConfig: { port: 1234, devServerPublicPathRoute: '/dev-server', proxyUrl: 'http://localhost:1234', namespace: '__cypress', }, debugData: { filePreprocessorHandlerText: 'function () {}', }, mode: 'record', }, ) }) }) it('POST /runs does not call prepareAndSetupProtocol with invalid signature', function () { nock(API_BASEURL) .get('/capture-protocol/script/protocolStub.js') .reply(200, PROTOCOL_STUB_VALID.compressed, { 'x-cypress-signature': 'invalid', 'Content-Encoding': 'gzip', }) nock(API_BASEURL) .matchHeader('x-route-version', '4') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/runs', this.buildProps) .reply(200, { runId: 'new-run-id-123', capture: { url: 'http://localhost:1234/capture-protocol/script/protocolStub.js', }, }) const protocolManager = this.protocolManager const project = { set protocolManager (val) { // don't override with the setter so that the protocol manager is always the same }, get protocolManager () { return protocolManager }, } return api.createRun({ ...this.buildProps, project, }) .then((ret) => { expect(ret).to.deep.eq({ runId: 'new-run-id-123', capture: { url: 'http://localhost:1234/capture-protocol/script/protocolStub.js', }, }) expect(this.protocolManager.prepareAndSetupProtocol).not.to.be.called }) }) it('POST /runs failure formatting', function () { nock(API_BASEURL) .matchHeader('x-route-version', '4') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/runs', this.buildProps) .reply(422, { errors: { runId: ['is required'], }, }) return api.createRun({ ...this.buildProps, protocolManager: this.protocolManager, }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq(`\ 422 { "errors": { "runId": [ "is required" ] } }\ `) expect(this.protocolManager.prepareAndSetupProtocol).not.to.be.called }) }) it('handles timeouts', () => { nock(API_BASEURL) .matchHeader('x-route-version', '4') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/runs') .delayConnection(5000) .reply(200, {}) return api.createRun({ timeout: 100, }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq('Error: ESOCKETTIMEDOUT') }) }) it('sets timeout to 10 seconds', () => { sinon.stub(api.rp, 'post').resolves({ runId: 'foo' }) const protocolManager = this.protocolManager const project = { set protocolManager (val) { // don't override with the setter so that the protocol manager is always the same }, get protocolManager () { return protocolManager }, } return api.createRun({ project }) .then(() => { expect(api.rp.post).to.be.calledWithMatch({ timeout: 60000 }) }) }) it('tags errors', function () { nock(API_BASEURL) .matchHeader('x-route-version', '4') .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .post('/runs', this.buildProps) .reply(500, {}) return api.createRun({ ...this.buildProps, protocolManager: this.protocolManager, }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err).to.have.property('isApiError', true) expect(this.protocolManager.prepareAndSetupProtocol).not.to.be.called }) }) it('tags errors on /preflight', function () { preflightNock(API_BASEURL) .times(2) .reply(500, {}) return api.createRun({}) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) }) context('.postInstanceTests', () => { beforeEach(function () { this.props = { runId: 'run-id-123', instanceId: 'instance-id-123', config: {}, tests: [], hooks: [], } this.bodyProps = _.omit(this.props, 'instanceId', 'runId') }) it('POSTs /instances/:id/results', function () { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('x-cypress-run-id', this.props.runId) .matchHeader('x-cypress-request-attempt', '0') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/instances/instance-id-123/tests', this.bodyProps) .reply(200) return api.postInstanceTests(this.props) }) it('PUT /instances/:id failure formatting', () => { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/instances/instance-id-123/tests') .reply(422, { errors: { tests: ['is required'], }, }) return api.postInstanceTests({ instanceId: 'instance-id-123' }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq(`\ 422 { "errors": { "tests": [ "is required" ] } }\ `) }) }) it('handles timeouts', () => { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/instances/instance-id-123/tests') .delayConnection(5000) .reply(200, {}) return api.postInstanceTests({ instanceId: 'instance-id-123', timeout: 100, }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq('Error: ESOCKETTIMEDOUT') }) }) it('sets timeout to 60 seconds', () => { sinon.stub(api.rp, 'post').resolves() return api.postInstanceTests({}) .then(() => { expect(api.rp.post).to.be.calledWithMatch({ timeout: 60000 }) }) }) it('tags errors', function () { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .post('/instances/instance-id-123/tests', this.bodyProps) .reply(500, {}) return api.postInstanceTests(this.props) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) }) context('.postInstanceResults', () => { beforeEach(function () { this.updateProps = { runId: 'run-id-123', instanceId: 'instance-id-123', stats: {}, error: 'err msg', video: true, screenshots: [], reporterStats: {}, } this.postProps = _.pick(this.updateProps, 'stats', 'video', 'screenshots', 'reporterStats') }) it('POSTs /instances/:id/results', function () { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('x-cypress-run-id', this.updateProps.runId) .matchHeader('x-cypress-request-attempt', '0') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/instances/instance-id-123/results', this.postProps) .reply(200) return api.postInstanceResults(this.updateProps) }) it('PUT /instances/:id failure formatting', () => { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/instances/instance-id-123/results') .reply(422, { errors: { tests: ['is required'], }, }) return api.postInstanceResults({ instanceId: 'instance-id-123' }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq(`\ 422 { "errors": { "tests": [ "is required" ] } }\ `) }) }) it('handles timeouts', () => { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .post('/instances/instance-id-123/results') .delayConnection(5000) .reply(200, {}) return api.postInstanceResults({ instanceId: 'instance-id-123', timeout: 100, }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq('Error: ESOCKETTIMEDOUT') }) }) it('sets timeout to 60 seconds', () => { sinon.stub(api.rp, 'post').resolves() return api.postInstanceResults({}) .then(() => { expect(api.rp.post).to.be.calledWithMatch({ timeout: 60000 }) }) }) it('tags errors', function () { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .post('/instances/instance-id-123/results', this.postProps) .reply(500, {}) return api.postInstanceResults(this.updateProps) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) }) context('.updateInstanceStdout', () => { it('PUTs /instances/:id/stdout', () => { nock(API_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-run-id', 'run-id-123') .matchHeader('x-cypress-request-attempt', '0') .matchHeader('x-cypress-version', pkg.version) .put('/instances/instance-id-123/stdout', { stdout: 'foobarbaz\n', }) .reply(200) return api.updateInstanceStdout({ runId: 'run-id-123', instanceId: 'instance-id-123', stdout: 'foobarbaz\n', }) }) it('PUT /instances/:id/stdout failure formatting', () => { nock(API_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .put('/instances/instance-id-123/stdout') .reply(422, { errors: { tests: ['is required'], }, }) return api.updateInstanceStdout({ instanceId: 'instance-id-123' }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq(`\ 422 { "errors": { "tests": [ "is required" ] } }\ `) }) }) it('handles timeouts', () => { nock(API_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .put('/instances/instance-id-123/stdout') .delayConnection(5000) .reply(200, {}) return api.updateInstanceStdout({ instanceId: 'instance-id-123', timeout: 100, }) .then(() => { throw new Error('should have thrown here') }).catch((err) => { expect(err.message).to.eq('Error: ESOCKETTIMEDOUT') }) }) it('sets timeout to 60 seconds', () => { sinon.stub(api.rp, 'put').resolves() return api.updateInstanceStdout({}) .then(() => { expect(api.rp.put).to.be.calledWithMatch({ timeout: 60000 }) }) }) it('tags errors', () => { nock(API_BASEURL) .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .put('/instances/instance-id-123/stdout', { stdout: 'foobarbaz\n', }) .reply(500, {}) return api.updateInstanceStdout({ instanceId: 'instance-id-123', stdout: 'foobarbaz\n', }) .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) }) context('.getAuthUrls', () => { it('GET /auth + returns the urls', () => { return api.getAuthUrls().then((urls) => { expect(urls).to.deep.eq(AUTH_URLS) }) }) it('tags errors', () => { nock.cleanAll() nock(API_BASEURL) .matchHeader('accept-encoding', /gzip/) .matchHeader('x-route-version', '2') .get('/auth') .reply(500, {}) return api.getAuthUrls() .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) it('caches the response from the first request', () => { return api.getAuthUrls() .then(() => { // nock will throw if this makes a second HTTP call return api.getAuthUrls() }).then((urls) => { expect(urls).to.deep.eq(AUTH_URLS) }) }) }) context('.postLogout', () => { beforeEach(() => { return sinon.stub(machineId, 'machineId').resolves('foo') }) it('POSTs /logout', () => { nock(CLOUD_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .matchHeader('x-machine-id', 'foo') .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .post('/logout') .reply(200) return api.postLogout('auth-token-123') }) it('tags errors', () => { nock(CLOUD_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .matchHeader('x-machine-id', 'foo') .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .post('/logout') .reply(500, {}) return api.postLogout('auth-token-123') .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) }) context('.createCrashReport', () => { beforeEach(function () { this.setup = (body, authToken, delay = 0) => { return nock(API_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .matchHeader('authorization', `Bearer ${authToken}`) .post('/exceptions', body) .delayConnection(delay) .reply(200) } }) it('POSTs /exceptions', function () { this.setup({ foo: 'bar' }, 'auth-token-123') return api.createCrashReport({ foo: 'bar' }, 'auth-token-123') }) it('by default times outs after 3 seconds', function () { // return our own specific promise // so we can spy on the timeout function const p = Promise.resolve({}) sinon.spy(p, 'timeout') sinon.stub(api.rp, 'post').returns(p) this.setup({ foo: 'bar' }, 'auth-token-123') return api.createCrashReport({ foo: 'bar' }, 'auth-token-123').then(() => { expect(p.timeout).to.be.calledWith(3000) }) }) it('times out after exceeding timeout', function () { // force our connection to be delayed 5 seconds this.setup({ foo: 'bar' }, 'auth-token-123', 5000) // and set the timeout to only be 50ms return api.createCrashReport({ foo: 'bar' }, 'auth-token-123', 50) .then(() => { throw new Error('errored: it did not catch the timeout error!') }).catch(Promise.TimeoutError, () => {}) }) it('tags errors', () => { nock(API_BASEURL) .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .matchHeader('authorization', 'Bearer auth-token-123') .matchHeader('accept-encoding', /gzip/) .post('/exceptions', { foo: 'bar' }) .reply(500, {}) return api.createCrashReport({ foo: 'bar' }, 'auth-token-123') .then(() => { throw new Error('should have thrown here') }) .catch((err) => { expect(err).to.have.property('isApiError', true) }) }) }) context('.retryWithBackoff', () => { beforeEach(() => { process.env.DISABLE_API_RETRIES = '' return sinon.stub(Promise, 'delay').resolves() }) it('attempts passed-in function', () => { const fn = sinon.stub() return api.retryWithBackoff(fn).then(() => { expect(fn).to.be.called }) }) it('retries if function times out', () => { const fn = sinon.stub() .rejects(new Promise.TimeoutError()) fn.onCall(1).resolves() return api.retryWithBackoff(fn) .then(() => { expect(fn).to.be.calledTwice expect(fn.firstCall.args[0]).eq(0) expect(fn.secondCall.args[0]).eq(1) }) }) it('retries on 5xx errors', () => { const fn1 = sinon.stub().rejects(makeError({ statusCode: 500 })) fn1.onCall(1).resolves() const fn2 = sinon.stub().rejects(makeError({ statusCode: 599 })) fn2.onCall(1).resolves() return api.retryWithBackoff(fn1) .then(() => { expect(fn1).to.be.calledTwice return api.retryWithBackoff(fn2) }).then(() => { expect(fn2).to.be.calledTwice }) }) it('retries on error without status code', () => { const fn = sinon.stub().rejects(makeError()) fn.onCall(1).resolves() return api.retryWithBackoff(fn) .then(() => { expect(fn).to.be.calledTwice }) }) it('does not retry on non-5xx errors', () => { const fn1 = sinon.stub().rejects(makeError({ message: '499 error', statusCode: 499 })) const fn2 = sinon.stub().rejects(makeError({ message: '600 error', statusCode: 600 })) return api.retryWithBackoff(fn1) .then(() => { throw new Error('Should not resolve 499 error') }) .catch((err) => { expect(err.message).to.equal('499 error') return api.retryWithBackoff(fn2) }) .then(() => { throw new Error('Should not resolve 600 error') }) .catch((err) => { expect(err.message).to.equal('600 error') }) }) it('backs off with strategy: 30s, 60s, 2m', () => { const fn = sinon.stub().rejects(new Promise.TimeoutError()) fn.onCall(3).resolves() return api.retryWithBackoff(fn).then(() => { expect(Promise.delay).to.be.calledThrice expect(Promise.delay.firstCall).to.be.calledWith(30 * 1000) expect(Promise.delay.secondCall).to.be.calledWith(60 * 1000) expect(Promise.delay.thirdCall).to.be.calledWith(2 * 60 * 1000) }) }) it('fails after third retry fails', () => { const fn = sinon.stub().rejects(makeError({ message: '500 error', statusCode: 500 })) return api.retryWithBackoff(fn) .then(() => { throw new Error('Should not resolve') }).catch((err) => { expect(err.message).to.equal('500 error') }) }) it('calls errors.warning before each retry', () => { const err = makeError({ message: '500 error', statusCode: 500 }) sinon.spy(errors, 'warning') const fn = sinon.stub().rejects(err) fn.onCall(3).resolves() return api.retryWithBackoff(fn).then(() => { expect(errors.warning).to.be.calledThrice expect(errors.warning.firstCall.args[0]).to.eql('CLOUD_API_RESPONSE_FAILED_RETRYING') expect(errors.warning.firstCall.args[1]).to.eql({ delayMs: 30000, tries: 3, response: err, }) expect(errors.warning.secondCall.args[1]).to.eql({ delayMs: 60000, tries: 2, response: err, }) expect(errors.warning.thirdCall.args[1]).to.eql({ delayMs: 120000, tries: 1, response: err, }) }) }) }) context('.updateInstanceArtifacts', () => { beforeEach(function () { this.artifactOptions = { runId: 'run-id-123', instanceId: 'instance-id-123', } this.artifactProps = { screenshots: [{ url: `http://localhost:1234/screenshots/upload/instance-id-123/a877e957-f90e-4ba4-9fa8-569812f148c4.png`, uploadSize: 100, uploadDuration: 100, }], video: { url: `http://localhost:1234/video/upload/instance-id-123/f17754c4-581d-4e08-a922-1fa402f9c6de.mp4`, uploadSize: 122, uploadDuration: 100, }, protocol: { url: `http://localhost:1234/protocol/upload/instance-id-123/2ed89c81-e7eb-4b97-8a6e-185c410471df.db`, uploadSize: 123, uploadDuration: 100, }, } // TODO: add schema validation }) it('PUTs/instances/:id/artifacts', function () { nock(API_BASEURL) .matchHeader('x-route-version', '1') .matchHeader('x-cypress-run-id', this.artifactOptions.runId) .matchHeader('x-cypress-request-attempt', '0') .matchHeader('x-os-name', OS_PLATFORM) .matchHeader('x-cypress-version', pkg.version) .put('/instances/instance-id-123/artifacts', { protocol: this.artifactProps.protocol, screenshots: this.artifactProps.screenshots, video: this.artifactProps.video, }) .reply(200) return api.updateInstanceArtifacts(this.artifactOptions, this.artifactProps) }) }) })