test: stop using axios for tests as it doesn't support 'traversal' paths
Massimo Melina committed
Nov 23, 2023 at 14:55 UTC
a7c697669c707e80be11908127141962f4c6ea80
4 files changed
+77
-66
package.json
-2
@@ -104,8 +104,6 @@
104
"@types/node": "^18.17.14",
105
"@types/tough-cookie": "^4.0.2",
106
"@types/unzipper": "^0.10.5",
107
- "axios": "^0.24.0",
108
- "axios-cookiejar-support": "^4.0.1",
107
"cross-env": "^7.0.3",
108
"koa-better-http-proxy": "^0.2.9",
109
"mocha": "^9.1.3",
src/misc.ts
-17
@@ -110,23 +110,6 @@ export function same(a: any, b: any) {
110
catch { return false }
111
}
112
113
-export async function stream2string(stream: Readable): Promise<string> {
114
- return new Promise((resolve, reject) => {
115
- let data = ''
116
- stream.on('data', chunk =>
117
- data += chunk)
118
- stream.on('error', reject)
119
- stream.on('end', () => {
120
- try {
121
- resolve(data)
122
- }
123
- catch(e) {
124
- reject(e)
125
- }
126
- })
127
- })
128
-}
129
-
113
export function asyncGeneratorToReadable<T>(generator: AsyncIterable<T>) {
114
const iterator = generator[Symbol.asyncIterator]()
115
return new Readable({
src/util-http.ts
+45
-17
@@ -1,42 +1,70 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { RequestOptions } from 'https'
3
+import https, { RequestOptions } from 'node:https'
4
import http, { IncomingMessage } from 'node:http'
5
-import https from 'node:https'
5
+import { Readable } from 'node:stream'
6
import _ from 'lodash'
7
8
// in case the response is not 2xx, it will throw and the error object is the Response object
9
-export function httpString(url: string, options?: XRequestOptions): Promise<string> {
10
- return httpStream(url, options).then(res =>
11
- new Promise(resolve => {
12
- let buf = ''
13
- res.on('data', chunk => buf += chunk.toString())
14
- res.on('end', () => {
15
- if (!_.inRange(res.statusCode!, 200, 299))
16
- throw res
17
- resolve(buf)
18
- })
9
+export async function httpString(url: string, options?: XRequestOptions): Promise<string> {
10
+ const res = await httpStream(url, options)
11
+ if (!_.inRange(res.statusCode!, 200, 299))
12
+ throw res
13
+ return await stream2string(res)
14
+}
15
+
16
+export async function stream2string(stream: Readable): Promise<string> {
17
+ return new Promise((resolve, reject) => {
18
+ let data = ''
19
+ stream.on('data', chunk =>
20
+ data += chunk)
21
+ stream.on('error', reject)
22
+ stream.on('end', () => {
23
+ try {
24
+ resolve(data)
25
+ }
26
+ catch(e) {
27
+ reject(e)
28
+ }
29
})
20
- )
30
+ })
31
+}
32
+
33
+export interface XRequestOptions extends RequestOptions {
34
+ body?: string | Buffer | Readable
35
+ // basic cookie store
36
+ jar?: Record<string, string>
37
+ noRedirect?: boolean
38
}
39
23
-export interface XRequestOptions extends RequestOptions { body?: string | Buffer }
24
-export function httpStream(url: string, { body, ...options }:XRequestOptions ={}): Promise<IncomingMessage> {
40
+export function httpStream(url: string, { body, jar, noRedirect, ...options }: XRequestOptions ={}): Promise<IncomingMessage> {
41
return new Promise((resolve, reject) => {
42
if (body)
43
options.method ||= 'POST'
44
+ if (jar)
45
+ (options.headers ||= {}).cookie = _.map(jar, (v,k) => `${k}=${v}; `).join('')
46
+ + (options.headers.cookie || '') // preserve parameter
47
const proto = url.startsWith('https:') ? https : http
48
const req = proto.request(url, options, res => {
49
console.debug("http responded", res.statusCode, "to", url)
50
+ if (jar) for (const entry of res.headers['set-cookie'] || []) {
51
+ const [, k, v] = /(.+?)=([^;]+)/.exec(entry) || []
52
+ if (!k) continue
53
+ if (v) jar[k] = v
54
+ else delete jar[k]
55
+ }
56
if (!res.statusCode || res.statusCode >= 400)
57
return reject(new Error(String(res.statusCode), { cause: res }))
33
- if (res.headers.location)
58
+ if (res.headers.location && !noRedirect)
59
return resolve(httpStream(res.headers.location, options))
60
resolve(res)
61
}).on('error', e => {
62
reject((req as any).res || e)
63
})
39
- req.end(body)
64
+ if (body && body instanceof Readable)
65
+ body.pipe(req).on('end', () => req.end())
66
+ else
67
+ req.end(body)
68
})
69
}
70
tests/test.ts
+32
-30
@@ -1,11 +1,9 @@
1
-import axios, { AxiosRequestConfig } from 'axios'
2
-import { wrapper } from 'axios-cookiejar-support'
3
-import { CookieJar } from 'tough-cookie'
1
import { srpClientSequence } from '../src/srp'
2
import { createReadStream, rmSync } from 'fs'
3
import { dirname, join } from 'path'
4
import _ from 'lodash'
8
-import { findDefined } from '../src/cross'
5
+import { findDefined, tryJson } from '../src/cross'
6
+import { httpStream, stream2string, XRequestOptions } from '../src/util-http'
7
/*
8
import { PORT, srv } from '../src'
9
@@ -20,13 +18,10 @@ const API = '/~/api/'
18
const BASE_URL = 'http://localhost'
19
const UPLOAD_URI = '/for-admins/upload/temp/gpl.png'
20
23
-const jar = new CookieJar()
24
-const client = wrapper(axios.create({ jar, maxRedirects: 0 }))
25
-
21
describe('basics', () => {
22
//before(async () => appStarted)
23
it('frontend', req('/', /<body>/, { headers: { accept: '*/*' } })) // workaround: 'accept' is necessary when running server-for-test-dev, still don't know why
29
- it('force slash', req('/f1', 302))
24
+ it('force slash', req('/f1', 302, { noRedirect: true }))
25
it('list', reqList('/f1/', { inList:['f2/', 'page/'] }))
26
it('search', reqList('f1', { inList:['f2/'], outList:['page'] }, { search:'2' }))
27
it('search root', reqList('/', { inList:['cantListPage/'], outList:['cantListPage/page/'] }, { search:'page' }))
@@ -130,12 +125,13 @@ describe('after-login', () => {
125
126
function login(usr: string, pwd=password) {
127
return srpClientSequence(usr, pwd, (cmd: string, params: any) =>
133
- reqApi(cmd, params, (x,res)=> !res.isAxiosError)())
128
+ reqApi(cmd, params, (x,res)=> res.statusCode < 400)())
129
}
130
131
function testUpload(name: string, dest: string, tester: Tester) {
137
- it(name, req('PUT' + dest, tester, {
138
- data: createReadStream(join(__dirname, 'page/gpl.png'))
132
+ it(name, req(dest, tester, {
133
+ method: 'PUT',
134
+ body: createReadStream(join(__dirname, 'page/gpl.png'))
135
}))
136
}
137
@@ -155,34 +151,36 @@ type Tester = number
151
cb?: TesterFunction
152
}
153
158
-function req(methodUrl: string, test:Tester, requestOptions: AxiosRequestConfig<any>={}) {
159
- // all url starts with /, so if one doesn't it's because the method is prefixed
160
- const i = methodUrl.indexOf('/')
161
- const method = methodUrl.slice(0,i) || requestOptions?.data && 'POST' || 'GET'
162
- const url = BASE_URL+methodUrl.slice(i)
163
- return () => client.request({ method, url, ...requestOptions })
164
- .then(process, process)
154
+const jar = {}
155
+
156
+function req(url: string, test:Tester, requestOptions: XRequestOptions={}) {
157
+ // passing 'path' keeps it as it is, avoiding internal resolving
158
+ return () => httpStream(BASE_URL + url, { path: url, jar, ...requestOptions }).catch(e => {
159
+ if (e.code === "ECONNREFUSED")
160
+ throw e
161
+ return e.cause
162
+ }).then(process)
163
166
- function process(res:any) {
164
+ async function process(res:any) {
165
//console.debug('sent', requestOptions, 'got', res instanceof Error ? String(res) : [res.status])
168
- if (res.code === "ECONNREFUSED") throw res
166
if (test && test instanceof RegExp)
167
test = { re:test }
168
if (typeof test === 'number')
169
test = { status: test }
173
- const { data } = res.response || res
170
+ const data = await stream2string(res)
171
+ const obj = tryJson(data)
172
if (typeof test === 'object') {
173
const { status, mime, re, inList, outList, length, permInList } = test
174
const gotMime = res.headers?.['content-type']
177
- const gotStatus = (res.status|| res.response.status)
175
+ const gotStatus = res.statusCode
176
const gotLength = res.headers?.['content-length']
177
const err = mime && !gotMime?.startsWith(mime) && 'expected mime ' + mime + ' got ' + gotMime
178
|| status && gotStatus !== status && 'expected status ' + status + ' got ' + gotStatus
181
- || re && !(typeof data === 'string' && re.test(data)) && 'expected content '+String(re)+' got '+(data || '-empty-')
182
- || inList && !inList.every(x => isInList(data, x)) && 'expected in list '+inList
183
- || outList && !outList.every(x => !isInList(data, x)) && 'expected not in list '+outList
179
+ || re && !re.test(data) && 'expected content '+String(re)+' got '+(data || '-empty-')
180
+ || inList && !inList.every(x => isInList(obj, x)) && 'expected in list '+inList
181
+ || outList && !outList.every(x => !isInList(obj, x)) && 'expected not in list '+outList
182
|| permInList && findDefined(permInList, (v, k) => {
185
- const got = _.find(data.list, { n: k })?.p
183
+ const got = _.find(obj.list, { n: k })?.p
184
const negate = v[0] === '!'
185
return findDefined(v.slice(negate ? 1 : 0).split(''), char =>
186
got?.includes(char) === negate ? `expected perm ${v} on ${k}, got ${got}` : undefined)
@@ -195,14 +193,18 @@ function req(methodUrl: string, test:Tester, requestOptions: AxiosRequestConfig<
193
throw Error(err)
194
}
195
if (typeof test === 'function')
198
- if (!test(data, res))
199
- throw Error()
200
- return data
196
+ if (!test(obj ?? data, res))
197
+ throw Error("failed test: " + test)
198
+ return obj ?? data
199
}
200
}
201
202
function reqApi(api: string, params: object, test:Tester) {
205
- return req(API+api, test, { data: params, headers: { 'x-hfs-anti-csrf': '1'} })
203
+ const isGet = api.startsWith('/')
204
+ return req(API+api, test, {
205
+ body: JSON.stringify(params),
206
+ headers: isGet ? undefined : { 'x-hfs-anti-csrf': '1'}
207
+ })
208
}
209
210
function reqList(uri:string, tester:Tester, params?: object) {