From 3e5fabce2cc02cbb7cc78fe511965112171a939c Mon Sep 17 00:00:00 2001 From: Teo Anastasiadis <31965686+TheoAnastasiadis@users.noreply.github.com> Date: Fri, 23 Feb 2024 23:11:18 +0200 Subject: [PATCH] fix: Boolean and null literals should be considered valid request bodies (#28835) * fix(types): RequestBody type should be able to accept booleans and null values, which are all valid JSON literals * refactor: boolean literals are valid JSON objects. Null values should also be considered valid when explicitly passed to the request function. * refactor: body is explicitly defined when passed as positional argument or when supplied through the options object * test: JSON literals should be parsed as valid JSON and set json=true * docs: issue reference * fix: boolean and null literal should be send to request promise as strings * docs: fixes #28789 -- added issue reference * test: tests proper conversion of JSON literals to strings. * docs: added isssue reference * docs: fixes #28789 -- changelog entry * refactor: change isValidJsonObj to isValidBody Co-authored-by: Bill Glesias * refactor: change isValidJsonObj to isValidBody Co-authored-by: Bill Glesias * refactor: use lodash utils Co-authored-by: Bill Glesias * Update cli/CHANGELOG.md Co-authored-by: Bill Glesias * docs: moved entry to 13.6.5 * docs: fixed changelog entry * Update CHANGELOG.md --------- Co-authored-by: Bill Glesias Co-authored-by: Jennifer Shehane --- cli/CHANGELOG.md | 8 +++ cli/types/cypress.d.ts | 2 +- .../driver/cypress/e2e/commands/request.cy.js | 54 +++++++++++++++++ packages/driver/src/cy/commands/request.ts | 11 +++- packages/server/lib/request.js | 5 ++ packages/server/test/unit/request_spec.js | 58 +++++++++++++++++++ 6 files changed, 134 insertions(+), 4 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 519880dce4e3..05491b212dd2 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,4 +1,12 @@ +## 13.6.7 + +_Released 2/27/2024 (PENDING)_ + +**Bugfixes:** + +- Changed RequestBody type to allow for boolean and null literals to be passed as body values. [#28789](https://github.com/cypress-io/cypress/issues/28789) + ## 13.6.6 _Released 2/22/2024_ diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 670944b3ff71..4cc3f6bfa233 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -7,7 +7,7 @@ declare namespace Cypress { type FileContents = string | any[] | object type HistoryDirection = 'back' | 'forward' type HttpMethod = string - type RequestBody = string | object + type RequestBody = string | object | boolean | null type ViewportOrientation = 'portrait' | 'landscape' type PrevSubject = keyof PrevSubjectMap type TestingType = 'e2e' | 'component' diff --git a/packages/driver/cypress/e2e/commands/request.cy.js b/packages/driver/cypress/e2e/commands/request.cy.js index 8e69f51f1d65..3fe89f44362f 100644 --- a/packages/driver/cypress/e2e/commands/request.cy.js +++ b/packages/driver/cypress/e2e/commands/request.cy.js @@ -129,6 +129,60 @@ describe('src/cy/commands/request', () => { }) }) + // https://github.com/cypress-io/cypress/issues/28789 + context('accepts trivial RFC 8259 compliant body objects', () => { + it('accepts body equal to true', () => { + cy.request({ method: 'POST', url: 'http://www.github.com/projects/foo', body: true }).then(function () { + this.expectOptionsToBe({ + method: 'POST', + url: 'http://www.github.com/projects/foo', + body: true, + json: true, + }) + }) + }) + + it('accepts body equal to false', () => { + cy.request({ method: 'POST', url: 'http://www.github.com/projects/foo', body: false }).then(function () { + this.expectOptionsToBe({ + method: 'POST', + url: 'http://www.github.com/projects/foo', + body: false, + json: true, + }) + }) + }) + + it('accepts (explicitly defined) null body', () => { + cy.request({ method: 'POST', url: 'http://www.github.com/projects/foo', body: null }).then(function () { + this.expectOptionsToBe({ + method: 'POST', + url: 'http://www.github.com/projects/foo', + //body: null, + json: true, + }) + }) + + cy.request('POST', 'http://www.github.com/projects/foo', null).then(function () { + this.expectOptionsToBe({ + method: 'POST', + url: 'http://www.github.com/projects/foo', + //body: null, + json: true, + }) + }) + + cy.request('http://www.github.com/projects/foo', null).then(function () { + this.expectOptionsToBe({ + method: 'POST', + url: 'http://www.github.com/projects/foo', + //body: null, + json: true, + }) + }) + }) + }) + context('method normalization', () => { it('uppercases method', () => { cy.request('post', 'https://www.foo.com').then(function () { diff --git a/packages/driver/src/cy/commands/request.ts b/packages/driver/src/cy/commands/request.ts index b33759abfaef..ddbb7853fc1c 100644 --- a/packages/driver/src/cy/commands/request.ts +++ b/packages/driver/src/cy/commands/request.ts @@ -43,8 +43,9 @@ const hasFormUrlEncodedContentTypeHeader = (headers) => { return header && (_.toLower(header) === 'content-type') } -const isValidJsonObj = (body) => { - return _.isObject(body) && !_.isFunction(body) +const isValidBody = (body, isExplicitlyDefined: boolean = false) => { + return (_.isObject(body) || _.isBoolean(body) || (isExplicitlyDefined && _.isNull(body))) + && !_.isFunction(body) } const whichAreOptional = (val, key) => { @@ -81,9 +82,11 @@ export default (Commands, Cypress, cy, state, config) => { request (...args) { const o: any = {} const userOptions = o + let bodyIsExplicitlyDefined = false if (_.isObject(args[0])) { _.extend(userOptions, args[0]) + bodyIsExplicitlyDefined = _.has(args[0], 'body') } else if (args.length === 1) { o.url = args[0] } else if (args.length === 2) { @@ -96,11 +99,13 @@ export default (Commands, Cypress, cy, state, config) => { // set url + body o.url = args[0] o.body = args[1] + bodyIsExplicitlyDefined = true } } else if (args.length === 3) { o.method = args[0] o.url = args[1] o.body = args[2] + bodyIsExplicitlyDefined = true } let options = _.defaults({}, userOptions, REQUEST_DEFAULTS, { @@ -222,7 +227,7 @@ export default (Commands, Cypress, cy, state, config) => { // only set json to true if form isnt true // and we have a valid object for body - if ((options.form !== true) && isValidJsonObj(options.body)) { + if ((options.form !== true) && isValidBody(options.body, bodyIsExplicitlyDefined)) { options.json = true } diff --git a/packages/server/lib/request.js b/packages/server/lib/request.js index fb85429590f2..4554740153ae 100644 --- a/packages/server/lib/request.js +++ b/packages/server/lib/request.js @@ -698,6 +698,11 @@ module.exports = function (options = {}) { // either turn these both on or off options.followAllRedirects = options.followRedirect + // https://github.com/cypress-io/cypress/issues/28789 + if (options.json === true) { + if (_.isBoolean(options.body) || _.isNull(options.body)) options.body = String(options.body) + } + if (options.form === true) { // reset form to whatever body is // and nuke body diff --git a/packages/server/test/unit/request_spec.js b/packages/server/test/unit/request_spec.js index cf46a763b03c..0f64d39cc0c2 100644 --- a/packages/server/test/unit/request_spec.js +++ b/packages/server/test/unit/request_spec.js @@ -937,6 +937,64 @@ describe('lib/request', () => { }) }) + // https://github.com/cypress-io/cypress/issues/28789 + context('json=true', () => { + beforeEach(() => { + nock('http://localhost:8080') + .matchHeader('Content-Type', 'application/json') + .post('/login') + .reply(200, '') + }) + + it('does not modify regular JSON objects', function () { + const init = sinon.spy(request.rp.Request.prototype, 'init') + const body = { + foo: 'bar', + } + + return request.sendPromise({}, this.fn, { + url: 'http://localhost:8080/login', + method: 'POST', + cookies: false, + json: true, + body, + }) + .then(() => { + expect(init).to.be.calledWithMatch({ body }) + }) + }) + + it('converts boolean JSON literals to strings', function () { + const init = sinon.spy(request.rp.Request.prototype, 'init') + + return request.sendPromise({}, this.fn, { + url: 'http://localhost:8080/login', + method: 'POST', + cookies: false, + json: true, + body: true, + }) + .then(() => { + expect(init).to.be.calledWithMatch({ body: 'true' }) + }) + }) + + it('converts null JSON literals to \'null\'', function () { + const init = sinon.spy(request.rp.Request.prototype, 'init') + + return request.sendPromise({}, this.fn, { + url: 'http://localhost:8080/login', + method: 'POST', + cookies: false, + json: true, + body: null, + }) + .then(() => { + expect(init).to.be.calledWithMatch({ body: 'null' }) + }) + }) + }) + context('bad headers', () => { beforeEach(function (done) { this.srv = http.createServer((req, res) => {