fix(pre-request script): avoid encoding tags in parsing request urls - INS-3379 (#7249)

* fix(pre-request script): avoid encoding tags in parsing request urls

* fix: refactor requestBody transforming as functions with tests

* fix: lint error

* fix: revert url decoding

* fix: query params are duplicated after script execution

* fix: query params are duplicated after script execution

* fix: pathParameters property is empty in pre-request script
This commit is contained in:
Hexxa
2024-04-10 15:39:29 +08:00
committed by GitHub
parent 75c6a94ae9
commit 36ecfc59cb
7 changed files with 297 additions and 134 deletions

View File

@@ -1,15 +1,9 @@
import url from 'node:url';
import { describe, expect, it } from '@jest/globals';
import { Certificate } from '../certificates';
import { setUrlParser } from '../urls';
describe('test Certificate object', () => {
it('test methods', () => {
// make URL work in Node.js
setUrlParser(url.URL);
const cert = new Certificate({
name: 'Certificate for example.com',
matches: ['https://example.com'],

View File

@@ -1,13 +1,8 @@
import url from 'node:url';
import { describe, expect, it } from '@jest/globals';
import { Request, RequestBody } from '../request';
import { setUrlParser } from '../urls';
import { mergeRequestBody, Request, RequestBody, toScriptRequestBody } from '../request';
describe('test request and response objects', () => {
setUrlParser(url.URL);
it('test RequestBody methods', () => {
const reqBody = new RequestBody({
mode: 'urlencoded',
@@ -72,4 +67,49 @@ describe('test request and response objects', () => {
const req2 = req.clone();
expect(req2.toJSON()).toEqual(req.toJSON());
});
it('test Request body transforming', () => {
const bodies = [
{
mimeType: 'text/plain',
text: 'rawContent',
},
{
mimeType: 'application/octet-stream',
fileName: 'path/to/file',
},
{
mimeType: 'application/x-www-form-urlencoded',
params: [
{ name: 'k1', value: 'v1' },
{ name: 'k2', value: 'v2' },
],
},
{
mimeType: 'application/json',
text: `{
query: 'query',
operationName: 'operation',
variables: 'var',
}`,
},
{
mimeType: 'image/gif',
fileName: '/path/to/image',
},
{
mimeType: 'multipart/form-data',
params: [
{ name: 'k1', type: 'text', value: 'v1' },
{ name: 'k2', type: 'file', value: '/path/to/image' },
],
},
];
bodies.forEach(body => {
const originalReqBody = body;
const scriptReqBody = new RequestBody(toScriptRequestBody(body));
expect(mergeRequestBody(scriptReqBody, originalReqBody)).toEqual(originalReqBody);
});
});
});

View File

@@ -1,14 +1,9 @@
import url from 'node:url';
import { describe, expect, it } from '@jest/globals';
import { Request } from '../request';
import { Response } from '../response';
import { setUrlParser } from '../urls';
describe('test request and response objects', () => {
setUrlParser(url.URL);
it('test Response methods', () => {
const req = new Request({
url: 'https://hostname.com/path',

View File

@@ -1,13 +1,9 @@
import url from 'node:url';
import { describe, expect, it } from '@jest/globals';
import { QueryParam, setUrlParser, Url, UrlMatchPattern } from '../urls';
import { QueryParam, Url, UrlMatchPattern } from '../urls';
import { Variable } from '../variables';
describe('test Url object', () => {
setUrlParser(url.URL);
it('test QueryParam', () => {
const queryParam = new QueryParam({
key: 'uname',
@@ -57,33 +53,34 @@ describe('test Url object', () => {
],
});
expect(url.getHost()).toEqual('hostvalue.com');
expect(url.getHost()).toEqual('hostValue.com');
expect(url.getPath()).toEqual('/pathLevel1/pathLevel2');
expect(url.getQueryString()).toEqual('key1=value1&key2=value2&key3=value3');
expect(url.getPathWithQuery()).toEqual('/pathLevel1/pathLevel2?key1=value1&key2=value2&key3=value3');
expect(url.getRemote(true)).toEqual('hostvalue.com:777');
expect(url.getRemote(false)).toEqual('hostvalue.com:777'); // TODO: add more cases
expect(url.getRemote(true)).toEqual('hostValue.com:777');
expect(url.getRemote(false)).toEqual('hostValue.com:777'); // TODO: add more cases
url.removeQueryParams([
new QueryParam({ key: 'key1', value: 'value1' }),
]);
url.removeQueryParams('key3');
expect(url.getQueryString()).toEqual('key2=value2');
expect(url.toString()).toEqual('https://usernameValue:passwordValue@hostvalue.com:777/pathLevel1/pathLevel2?key2=value2#hashValue');
expect(url.toString()).toEqual('https://usernameValue:passwordValue@hostValue.com:777/pathLevel1/pathLevel2?key2=value2#hashValue');
const url2 = new Url('https://usernameValue:passwordValue@hostvalue.com:777/pathLevel1/pathLevel2?key1=value1&key2=value2#hashValue');
expect(url2.getHost()).toEqual('hostvalue.com');
const url2 = new Url('https://usernameValue:passwordValue@hostValue.com:777/pathLevel1/pathLevel2?key1=value1&key2=value2#hashValue');
expect(url2.getHost()).toEqual('hostValue.com');
expect(url2.getPath()).toEqual('/pathLevel1/pathLevel2');
expect(url2.getQueryString()).toEqual('key1=value1&key2=value2');
expect(url2.getPathWithQuery()).toEqual('/pathLevel1/pathLevel2?key1=value1&key2=value2');
expect(url2.getRemote(true)).toEqual('hostvalue.com:777');
expect(url2.getRemote(false)).toEqual('hostvalue.com:777'); // TODO: add more cases
expect(url2.getRemote(true)).toEqual('hostValue.com:777');
expect(url2.getRemote(false)).toEqual('hostValue.com:777'); // TODO: add more cases
url2.removeQueryParams([
new QueryParam({ key: 'key1', value: 'value1' }),
]);
expect(url2.getQueryString()).toEqual('key2=value2');
expect(url2.toString()).toEqual('https://usernameValue:passwordValue@hostvalue.com:777/pathLevel1/pathLevel2?key2=value2#hashValue');
expect(url2.toString()).toEqual('https://usernameValue:passwordValue@hostValue.com:777/pathLevel1/pathLevel2?key2=value2#hashValue');
});
it('test Url static methods', () => {
@@ -94,6 +91,72 @@ describe('test Url object', () => {
expect(urlObj.toString()).toEqual(urlStr);
});
const urlParsingTests = [
{
testName: 'interal url',
url: 'inso/',
},
{
testName: 'interal url with protocol',
url: 'http://inso/',
},
{
testName: 'interal url with auth',
url: 'http://name:pwd@inso/',
},
{
testName: 'interal url with auth without protocol',
url: 'name:pwd@inso/',
},
{
testName: 'ip address',
url: 'http://127.0.0.1/',
},
{
testName: 'localhost',
url: 'https://localhost/',
},
{
testName: 'url with query params',
url: 'localhost/?k=v',
},
{
testName: 'url with hash',
url: 'localhost/#myHash',
},
{
testName: 'url with query params and hash',
url: 'localhost/?k=v#myHash',
},
{
testName: 'url with query params and hash',
url: 'localhost/?k={{ myValue }}',
},
{
testName: 'url with query params and hash',
url: 'localhost/#My{{ hashValue }}',
},
{
testName: 'url with path params',
url: 'inso.com/:path1/:path',
},
{
testName: 'url with tags and path params',
url: '{{ _.baseUrl }}/:path1/:path',
},
{
testName: 'hybrid of path params and tags',
url: '{{ baseUrl }}/:path_{{ _.pathSuffix }}',
},
];
urlParsingTests.forEach(testCase => {
it(`parsing url: ${testCase.testName}`, () => {
const urlObj = new Url(testCase.url);
expect(urlObj.toString()).toEqual(testCase.url);
});
});
});
describe('test Url Match Pattern', () => {

View File

@@ -1,14 +1,14 @@
import { expect } from 'chai';
import { ClientCertificate } from '../../models/client-certificate';
import { RequestBodyParameter, RequestHeader } from '../../models/request';
import { RequestHeader } from '../../models/request';
import { Settings } from '../../models/settings';
import { toPreRequestAuth } from './auth';
import { CookieObject } from './cookies';
import { Environment, Variables } from './environments';
import { RequestContext } from './interfaces';
import { unsupportedError } from './properties';
import { Request as ScriptRequest, RequestBodyOptions, RequestOptions } from './request';
import { Request as ScriptRequest, RequestOptions, toScriptRequestBody } from './request';
import { Response as ScriptResponse } from './response';
import { sendRequest } from './send-request';
import { test } from './test';
@@ -125,26 +125,6 @@ export function initInsomniaObject(
data: iterationData,
});
let reqBodyOpt: RequestBodyOptions = { mode: undefined };
if (rawObj.request.body.text != null) {
reqBodyOpt = {
mode: 'raw',
raw: rawObj.request.body.text,
};
} else if (rawObj.request.body.fileName != null && rawObj.request.body.fileName !== '') {
reqBodyOpt = {
mode: 'file',
file: rawObj.request.body.fileName,
};
} else if (rawObj.request.body.params != null) {
reqBodyOpt = {
mode: 'urlencoded',
urlencoded: rawObj.request.body.params.map(
(param: RequestBodyParameter) => ({ key: param.name, value: param.value })
),
};
}
const certificate = rawObj.clientCertificates != null && rawObj.clientCertificates.length > 0 ?
{
disabled: false,
@@ -203,10 +183,11 @@ export function initInsomniaObject(
header: rawObj.request.headers.map(
(header: RequestHeader) => ({ key: header.name, value: header.value })
),
body: reqBodyOpt,
body: toScriptRequestBody(rawObj.request.body),
auth: toPreRequestAuth(rawObj.request.authentication),
proxy,
certificate,
pathParameters: rawObj.request.pathParameters,
};
const request = new ScriptRequest(reqOpt);

View File

@@ -1,6 +1,7 @@
import { init as initClientCertificate } from '../../../src/models/client-certificate';
import { Request as InsomniaRequest, RequestPathParameter } from '../../../src/models/request';
import { Request as InsomniaRequest, RequestBody as InsomniaRequestBody, RequestPathParameter } from '../..//models/request';
import { ClientCertificate } from '../../models/client-certificate';
import { RequestBodyParameter } from '../../models/request';
import { Settings } from '../../models/settings';
import { AuthOptions, AuthOptionTypes, fromPreRequestAuth, RequestAuth } from './auth';
import { CertificateOptions } from './certificates';
@@ -20,7 +21,7 @@ export interface RequestBodyOptions {
formdata?: { key: string; value: string; type?: string }[];
graphql?: { query: string; operationName: string; variables: object };
raw?: string;
urlencoded?: { key: string; value: string }[];
urlencoded?: { key: string; value: string; type?: string }[];
options?: object;
}
@@ -91,7 +92,7 @@ function getClassFields(opts: RequestBodyOptions) {
QueryParam,
undefined,
opts.urlencoded
.map(entry => ({ key: entry.key, value: entry.value }))
.map(entry => ({ key: entry.key, value: entry.value, type: entry.type }))
.map(kv => new QueryParam(kv)),
);
}
@@ -526,13 +527,38 @@ export function mergeClientCertificates(
throw Error('Invalid certificate configuration: "cert+key" and "pfx" can not be set at the same time');
}
export function mergeRequests(
originalReq: InsomniaRequest,
updatedReq: Request
): InsomniaRequest {
export function toScriptRequestBody(insomniaReqBody: InsomniaRequestBody): RequestBodyOptions {
let reqBodyOpt: RequestBodyOptions = { mode: undefined };
if (insomniaReqBody.text !== undefined) {
reqBodyOpt = {
mode: 'raw',
raw: insomniaReqBody.text,
};
} else if (insomniaReqBody.fileName !== undefined && insomniaReqBody.fileName !== '') {
reqBodyOpt = {
mode: 'file',
file: insomniaReqBody.fileName,
};
} else if (insomniaReqBody.params !== undefined) {
reqBodyOpt = {
mode: 'urlencoded',
urlencoded: insomniaReqBody.params.map(
(param: RequestBodyParameter) => ({ key: param.name, value: param.value, type: param.type })
),
};
}
return reqBodyOpt;
}
export function mergeRequestBody(
updatedReqBody: RequestBody | undefined,
originalReqBody: InsomniaRequestBody
): InsomniaRequestBody {
let mimeType = 'application/octet-stream';
if (updatedReq.body) {
switch (updatedReq.body.mode) {
if (updatedReqBody) {
switch (updatedReqBody.mode) {
case undefined:
mimeType = 'application/octet-stream';
break;
@@ -554,31 +580,34 @@ export function mergeRequests(
mimeType = 'application/json';
break;
default:
throw Error(`unknown body mode: ${updatedReq.body.mode}`);
throw Error(`unknown request body mode: ${updatedReqBody.mode}`);
}
}
if (originalReq.body.mimeType) {
mimeType = originalReq.body.mimeType;
if (originalReqBody.mimeType) {
mimeType = originalReqBody.mimeType;
}
const queryParameters = updatedReq.url.query.map(
queryParam => ({ name: queryParam.key, value: queryParam.value })
,
{},
);
const updatedReqProperties: Partial<InsomniaRequest> = {
// url is encoded during parsing phase. Need decode url In order to recognized variables
url: decodeURI(typeof updatedReq.url === 'string' ? updatedReq.url : updatedReq.url.toString()),
method: updatedReq.method,
body: {
mimeType: mimeType,
text: updatedReq.body?.raw,
fileName: updatedReq.body?.file,
params: updatedReq.body?.urlencoded?.map(
(param: { key: string; value: string }) => ({ name: param.key, value: param.value }),
{},
return {
mimeType: mimeType,
text: updatedReqBody?.raw,
fileName: updatedReqBody?.file,
params: updatedReqBody?.urlencoded?.map(
(param: { key: string; value: string; type?: string }) => (
{ name: param.key, value: param.value, type: param.type }
),
},
{},
),
};
}
export function mergeRequests(
originalReq: InsomniaRequest,
updatedReq: Request
): InsomniaRequest {
const updatedReqProperties: Partial<InsomniaRequest> = {
url: updatedReq.url.toString(),
method: updatedReq.method,
body: mergeRequestBody(updatedReq.body, originalReq.body),
headers: updatedReq.headers.map(
(header: Header) => ({
name: header.key,
@@ -589,7 +618,7 @@ export function mergeRequests(
authentication: fromPreRequestAuth(updatedReq.auth),
preRequestScript: '',
pathParameters: updatedReq.pathParameters,
parameters: queryParameters,
parameters: [], // set empty array as parameters will be part of url field
};
return {

View File

@@ -1,12 +1,7 @@
import { Property, PropertyBase, PropertyList } from './properties';
import { Variable, VariableList } from './variables';
// TODO: make it also work with node.js
let UrlParser = URL;
let UrlSearchParams = URLSearchParams;
export function setUrlParser(provider: any) {
UrlParser = provider;
}
export function setUrlSearchParams(provider: any) {
UrlSearchParams = provider;
}
@@ -21,8 +16,9 @@ export class QueryParam extends Property {
key: string;
value: string;
type?: string;
constructor(options: { key: string; value: string } | string) {
constructor(options: { key: string; value: string; type?: string } | string) {
super();
if (typeof options === 'string') {
@@ -30,12 +26,14 @@ export class QueryParam extends Property {
const optionsObj = JSON.parse(options);
this.key = optionsObj.key;
this.value = optionsObj.value;
this.type = optionsObj.type;
} catch (e) {
throw Error(`invalid QueryParam options ${e}`);
}
} else if (typeof options === 'object' && ('key' in options) && ('value' in options)) {
this.key = options.key;
this.value = options.value;
this.type = options.type;
} else {
throw Error('unknown options for new QueryParam');
}
@@ -93,7 +91,7 @@ export class QueryParam extends Property {
return params.toString();
}
update(param: string | { key: string; value: string }) {
update(param: string | { key: string; value: string; type?: string }) {
if (typeof param === 'string') {
const paramObj = QueryParam.parseSingle(param);
this.key = typeof paramObj.key === 'string' ? paramObj.key : '';
@@ -101,6 +99,7 @@ export class QueryParam extends Property {
} else if ('key' in param && 'value' in param) {
this.key = param.key;
this.value = param.value;
this.type = param.type;
} else {
throw Error('the param for update must be: string | { key: string; value: string }');
}
@@ -145,12 +144,6 @@ export class Url extends PropertyBase {
}
private setFields(def: UrlOptions | string) {
if (typeof def === 'string') {
def = def.includes('://') ? def : 'http://' + def;
} else if (!def.protocol || def.protocol === '') {
def.protocol = 'http://';
}
const urlObj = typeof def === 'string' ? Url.parse(def) : def;
if (urlObj) {
@@ -193,30 +186,98 @@ export class Url extends PropertyBase {
}
static parse(urlStr: string): UrlOptions | undefined {
// TODO: enable validation
// if (!UrlParser.canParse(urlStr)) {
// console.error(`invalid URL string ${urlStr}`);
// return undefined;
// }
// the URL API (for web) is not leveraged here because the input string could contain tags for interpolation
// which will be encoded, then it would introduce confusion for users in manipulation
const url = new UrlParser(urlStr);
const query = Array.from(url.searchParams.entries())
.map(kv => {
const kvArray = kv as [string, string];
return { key: kvArray[0], value: kvArray[1] };
});
const endOfProto = urlStr.indexOf('://');
const protocol = endOfProto >= 0 ? urlStr.slice(0, endOfProto + 1) : '';
const potentialStartOfAuth = protocol === '' ? 0 : endOfProto + 3;
const endOfAuth = urlStr.indexOf('@', potentialStartOfAuth);
let auth = undefined;
if (endOfAuth >= 0 && potentialStartOfAuth < endOfAuth) { // e.g., '@insomnia.com' will be ignored
const authStr = endOfAuth >= 0 ? urlStr.slice(potentialStartOfAuth, endOfAuth) : '';
const authParts = authStr?.split(':');
if (authParts.length < 2) {
throw Error('new Url(): failed to parse auth in url ${urlStr}');
}
auth = { username: authParts[0], password: authParts[1] };
}
const startOfHash = urlStr.indexOf('#');
const hash = startOfHash >= 0 ? urlStr.slice(startOfHash + 1) : undefined;
const endOfQuery = startOfHash >= 0 ? startOfHash : urlStr.length;
const startOfQuery = urlStr.lastIndexOf('?', endOfQuery);
const query = new Array<{ key: string; value: string }>();
if (startOfQuery >= 0) {
const queryStr = urlStr.slice(startOfQuery + 1, endOfQuery);
query.push(
...queryStr
.split('&')
.map(pairStr => {
const queryParts = pairStr.split('=');
const key = queryParts[0];
const value = queryParts.length > 1 ? queryParts[1] : '';
return { key, value };
})
);
}
const startOfPathname = urlStr.indexOf('/', endOfProto >= 0 ? endOfProto + 3 : 0);
const path = new Array<string>();
if (startOfPathname >= 0) {
let endOfPathname = urlStr.length;
if (startOfQuery >= 0) {
endOfPathname = startOfQuery;
} else if (startOfHash >= 0) {
endOfPathname = startOfHash;
}
const pathname = urlStr.slice(startOfPathname, endOfPathname);
path.push(
...pathname.split('/'),
);
}
let potentialStartOfHostname = 0;
if (endOfAuth >= 0) {
potentialStartOfHostname = endOfAuth + 1;
} else if (endOfProto >= 0) {
potentialStartOfHostname = endOfProto + 3;
}
let potentialEndOfHostname = urlStr.length;
if (startOfPathname >= 0) {
potentialEndOfHostname = startOfPathname;
} else if (startOfQuery >= 0) {
potentialEndOfHostname = startOfQuery;
} else if (startOfHash >= 0) {
potentialEndOfHostname = startOfHash;
}
const host = new Array<string>();
let port = undefined;
if (potentialStartOfHostname < potentialEndOfHostname) {
const hostname = urlStr.slice(potentialStartOfHostname, potentialEndOfHostname);
const hostnameParts = hostname.split(':');
if (hostnameParts.length === 2) {
port = hostnameParts[1];
} else if (hostnameParts.length > 2) {
throw Error('new Url(): failed to parse hostname in url ${urlStr}');
}
host.push(
...
hostnameParts[0].split('.'),
);
}
return {
auth: url.username !== '' ? { // TODO: make it compatible with RequestAuth
username: url.username,
password: url.password,
} : undefined,
hash: url.hash,
host: url.hostname.split('/'),
path: url.pathname.split('/'),
port: url.port,
protocol: url.protocol, // e.g. https:
auth,
protocol,
host,
port,
path,
query,
hash,
variables: [],
};
}
@@ -236,7 +297,7 @@ export class Url extends PropertyBase {
}
getHost() {
return this.host.join('.').toLowerCase();
return this.host.join('.');
}
getPath(unresolved?: boolean) {
@@ -259,7 +320,11 @@ export class Url extends PropertyBase {
const params = new UrlSearchParams();
this.query.each(param => params.append(param.key, param.value), {});
return params.toString();
const queryParamStrs = this.query.map(pair => {
return pair.value ? `${pair.key}=${pair.value}` : pair.key;
}, {});
return queryParamStrs.join('&');
}
getRemote(forcePort?: boolean) {
@@ -310,20 +375,19 @@ export class Url extends PropertyBase {
}
toString(forceProtocol?: boolean) {
const protocol = forceProtocol ?
(this.protocol ? this.protocol : 'https:') :
(this.protocol ? this.protocol : '');
const protocolStr = forceProtocol ?
(this.protocol ? `${this.protocol}//` : 'http://') :
(this.protocol ? `${this.protocol}//` : '');
const parser = new UrlParser(`${protocol}//` + this.getHost());
parser.username = this.auth?.username || '';
parser.password = this.auth?.password || '';
parser.port = this.port || '';
parser.pathname = this.getPath();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parser.search = this.getQueryString();
parser.hash = this.hash || '';
const authStr = this.auth ? `${this.auth.username}:${this.auth.password}@` : '';
const hostStr = this.getHost();
const portStr = this.port ? `:${this.port}` : '';
const pathStr = this.getPath();
const queryStr = this.getQueryString() ? `?${this.getQueryString()}` : '';
const hashStr = this.hash ? `#${this.hash}` : '';
return parser.toString();
return `${protocolStr}${authStr}${hostStr}${portStr}${pathStr}${queryStr}${hashStr}`;
// return parser.toString();
}
update(url: UrlOptions | string) {
@@ -567,9 +631,6 @@ export class UrlMatchPatternList<T extends UrlMatchPattern> extends PropertyList
return '_kind' in obj && obj._kind === 'UrlMatchPatternList';
}
// TODO: unsupported yet
// toObject(excludeDisabledopt, nullable, caseSensitiveopt, nullable, multiValueopt, nullable, sanitizeKeysopt) → {Object}
test(urlStr: string) {
return this
.filter(matchPattern => matchPattern.test(urlStr), {})