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) {