@zombieland/tallahassee 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +15 -0
- package/README.md +257 -0
- package/browser.js +150 -0
- package/package.json +18 -0
- package/reverse-proxy.js +65 -0
- package/tallahassee.js +2 -0
- package/test/browser-test.js +467 -0
- package/test/reverse-proxy-test.js +218 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/)
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-04-22
|
|
9
|
+
|
|
10
|
+
Initial release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonas Waldén
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Tallahassee
|
|
2
|
+
|
|
3
|
+
A browser module around JSDOM for testing a web application as opposed to a document. Navigation with headers, cookies, clicks and form submits.
|
|
4
|
+
|
|
5
|
+
> I really want the name Tallahassee to remain, although Columbus sounds more _browsery_.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Basic usage](#basic-usage)
|
|
10
|
+
- [API](#api)
|
|
11
|
+
- [Browser](#browser)
|
|
12
|
+
- [new Browser(origin[, cookieJar])](#new-browserorigin-cookiejar)
|
|
13
|
+
- [browser.navigateTo(resource[, fetchOptions, loadOptions])](#browsernavigatetoResource-fetchoptions-loadoptions)
|
|
14
|
+
- [browser.fetch(resource[, options])](#browserfetchresource-options)
|
|
15
|
+
- [browser.load(resource[, options])](#browserloadresource-options)
|
|
16
|
+
- [browser.captureNavigation(dom[, follow])](#browsercapturenavigationdom-follow)
|
|
17
|
+
- [ReverseProxy](#reverseproxy)
|
|
18
|
+
- [new ReverseProxy(proxyOrigin, upstreamOrigin[, headers])](#new-reverseproxyproxyorigin-upstreamorigin-headers)
|
|
19
|
+
- [reverseProxy.modifyUpstreamRequest(req)](#reverseproxymodifyupstreamrequestreq)
|
|
20
|
+
- [reverseProxy.clear()](#reverseproxyclear)
|
|
21
|
+
|
|
22
|
+
## Basic usage
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { Browser } from '@zombieland/tallahassee';
|
|
27
|
+
|
|
28
|
+
let browser;
|
|
29
|
+
before('a browser with a default origin', () => {
|
|
30
|
+
browser = new Browser('http://localhost:7411');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('simple navigation', () => {
|
|
34
|
+
const dom = await browser.navigateTo('/');
|
|
35
|
+
assert.equal(dom.window.document.title, 'Zombieland');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('detailed navigation', () => {
|
|
39
|
+
const response = await browser.fetch('/', {
|
|
40
|
+
headers: { 'Cookie': 'signed-in=1' },
|
|
41
|
+
});
|
|
42
|
+
assert.equal(response.status, 200);
|
|
43
|
+
|
|
44
|
+
const dom = await browser.load(response, { runScripts: 'dangerously' });
|
|
45
|
+
assert.equal(dom.window.document.title, 'Zombieland');
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
### `Browser`
|
|
52
|
+
|
|
53
|
+
A module for testing navigation within an origin
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { Browser } from '@zombieland/tallahassee';
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
#### `new Browser(origin[, cookieJar])`
|
|
60
|
+
|
|
61
|
+
Creates a new browser instance
|
|
62
|
+
|
|
63
|
+
- `origin` `<string>` Base URL used by `browser.fetch`
|
|
64
|
+
- `cookieJar` `<CookieJar>` A [jar of cookies](https://github.com/jsdom/jsdom/blob/main/README.md#cookie-jars) to be used by `browser.fetch` method. **Default** `new CookieJar()`
|
|
65
|
+
|
|
66
|
+
#### `browser.navigateTo(resource[, fetchOptions, loadOptions])`
|
|
67
|
+
|
|
68
|
+
Fetches a document and loads a DOM
|
|
69
|
+
|
|
70
|
+
- `resource` Passed on to `browser.fetch`
|
|
71
|
+
- `fetchOptions` Passed on to `browser.fetch`
|
|
72
|
+
- `loadOptions` Passed on to `browser.load`.
|
|
73
|
+
- Returns: `<Promise>` Fulfills with a `JSDOM` on success
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const dom = await browser.navigateTo(
|
|
77
|
+
'/',
|
|
78
|
+
{ headers: { 'Cookie'; 'some-cookie=value' } },
|
|
79
|
+
{ runScripts: 'dangerously' }
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
#### `browser.fetch(resource[, options])`
|
|
84
|
+
|
|
85
|
+
Fetches a document. Useful for inspecting response details before loading DOM with `browser.load()`.
|
|
86
|
+
|
|
87
|
+
- `resource` `<string>` | `<URL>` | `<Request>` path (relative to the browser origin) / URL to a document or a request object
|
|
88
|
+
- `options` `<Object>` A `RequestInit` dictionary
|
|
89
|
+
- Returns: `<Promise>` Fulfills with a `Response` on success
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
const pendingResponse = browser.fetch('/', {
|
|
93
|
+
headers: { 'Cookie'; 'some-cookie=value' }
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Any request `Cookie` header will be applied to `browser.cookieJar` before actual `fetch()`.
|
|
98
|
+
|
|
99
|
+
Any response `Set-Cookie` will be applied to `browser.cookieJar` before creating `new JSDOM()`.
|
|
100
|
+
|
|
101
|
+
Redirects are followed manually with recursive calls to `browser.fetch` in order to properly set/get cookies from `browser.cookieJar`.
|
|
102
|
+
|
|
103
|
+
#### `browser.load(resource[, options])`
|
|
104
|
+
|
|
105
|
+
Loads a DOM from a document string or response
|
|
106
|
+
|
|
107
|
+
- `resource` `<string>` | `<Response>` | `<Promise>` A document string or response string to load into JSDOM
|
|
108
|
+
- `options` `<Object>` `{[painter, resources, ...jsdomOptions]}`
|
|
109
|
+
- `painter`: `<Painter>` Little Rock `Painter` instance
|
|
110
|
+
- `resources`: `<Resources>` Wichita `Resources` instance
|
|
111
|
+
- `jsdomOptions`: `<Object>` [Options to be passed onto JSDOM](https://github.com/jsdom/jsdom/blob/main/README.md#customizing-jsdom)
|
|
112
|
+
- **Default**:
|
|
113
|
+
- `runScripts`: `'outside-only'` if `options.painter`
|
|
114
|
+
- `pretendToBeVisual`: `true` if `options.painter`
|
|
115
|
+
- **Fixed values**:
|
|
116
|
+
- `url`: `url` from `resource` if instance of `Response`
|
|
117
|
+
- `contentType`: `Content-Type` response header from `resource` if instance of `Response`
|
|
118
|
+
- `cookieJar`: `cookieJar` from `browser` instance
|
|
119
|
+
- `beforeParse`: A function that will run:
|
|
120
|
+
- `options.painter?.beforeParse`: From a Little Rock `Painter` instance
|
|
121
|
+
- `options.resources?.beforeParse`: From a Wichita `Resources` instance
|
|
122
|
+
- `options.beforeParse`
|
|
123
|
+
- Returns: `<Promise>` Fulfills with a `JSDOM` on success
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
const dom = await browser.load(pendingResponse, {
|
|
127
|
+
runScripts: 'dangerously'
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If using Little Rock and/or Wichita their `beforeParse` methods will be run automatically if passed into `options`:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
import { Browser } from "@zombieland/tallahassee";
|
|
135
|
+
import { Painter } from "@zombieland/little-rock";
|
|
136
|
+
import { ResourceLoader } from "@zombieland/wichita";
|
|
137
|
+
|
|
138
|
+
const browser = new Browser(…);
|
|
139
|
+
const pendingResponse = browser.fetch('/');
|
|
140
|
+
const dom = await browser.load(pendingResponse, {
|
|
141
|
+
painter: new Painter(…),
|
|
142
|
+
resources: new ResourceLoader(…),
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
#### `browser.captureNavigation(dom[, follow])`
|
|
147
|
+
|
|
148
|
+
Captures navigation from link clicks and form submits.
|
|
149
|
+
|
|
150
|
+
- `dom` `<JSDOM>` A DOM to observe
|
|
151
|
+
- `follow` `<Boolean>` To follow request or not. **Default** `false`
|
|
152
|
+
- Returns: `<Promise>` Resolves with a `<Request>` or `<Response>` from `fetch` if `follow: true`. Rejects with an `Event` which blocked the navigation.
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
const linkOrFormSubmit = dom.window.querySelector('a, button[type=submit]');
|
|
156
|
+
|
|
157
|
+
const pendingNavigation = browser.captureNavigation(dom, false);
|
|
158
|
+
linkOrFormSubmit.click();
|
|
159
|
+
const request = await pendingNavigation;
|
|
160
|
+
assert(request instanceof Request);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Or with `follow: true` to perform a call to `browser.fetch()`
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
const pendingNavigation = browser.captureNavigation(dom, true);
|
|
167
|
+
linkOrFormSubmit.click();
|
|
168
|
+
const response = await pendingNavigation;
|
|
169
|
+
assert(request instanceof Response);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Navigation will fail if stopped by:
|
|
173
|
+
- Prevented default action of link `click` / form `submit` event using `preventDefault()`
|
|
174
|
+
- Form element `invalid` event
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
await assert.reject(pendingNavigation, (event) => {
|
|
178
|
+
assert.equal(event.type, 'invalid');
|
|
179
|
+
assert.equal(event.target, form.elements[1]);
|
|
180
|
+
return true;
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Navigation is intercepted at the `window` level using event listeners. Promise will not settle if event propagation is stopped or if form submit is triggered without event, e.g. with the `submit` method.
|
|
185
|
+
|
|
186
|
+
### `ReverseProxy`
|
|
187
|
+
|
|
188
|
+
A module for emulating a CDN like reverse proxy. Basically a wrapper around `nock`.
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
import { ReverseProxy } from '@zombieland/tallahassee';
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### `new ReverseProxy(proxyOrigin, upstreamOrigin[, headers])`
|
|
195
|
+
|
|
196
|
+
Creates HTTP interceptor for a _public_ proxy origin and proxies request to a _local_ upstream origin.
|
|
197
|
+
|
|
198
|
+
- `proxyOrigin` `<string>` Public URL origin
|
|
199
|
+
- `upstreamOrigin` `<string>` Server URL origin
|
|
200
|
+
- `headers` `<Object>` | `<Headers>` Headers to pass along to server. **Default** Standard forwarding headers (`Forwarded`, `X-Forwarded-Proto`, `X-Forwarded-Host`) derived from `proxyOrigin`
|
|
201
|
+
- Returns: `<ReverseProxy>`
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
import http from 'node:http';
|
|
205
|
+
import { Browser, ReverseProxy } from '@zombieland/tallahassee';
|
|
206
|
+
|
|
207
|
+
http.createServer(…).listen(7411);
|
|
208
|
+
const reverseProxy = new ReverseProxy('https://tallahassee.zl', 'http://localhost:7411')
|
|
209
|
+
const browser = new Browser('https://tallahassee.zl');
|
|
210
|
+
const dom = await browser.navigateTo('/safe-house');
|
|
211
|
+
assert.equal(dom.window.location, 'https://tallahassee.zl/safe-house');
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### `reverseProxy.modifyUpstreamRequest(req)`
|
|
215
|
+
|
|
216
|
+
Modifies the upstream request before it is sent to the upstream origin. This method can be overridden to customize request headers or other properties.
|
|
217
|
+
|
|
218
|
+
The default implementation applies the `headers` supplied to the constructor.
|
|
219
|
+
|
|
220
|
+
- `req` `<Request>` The request object to be sent to the upstream origin
|
|
221
|
+
- Returns: `<Request>` The modified request object
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
class CustomReverseProxy extends ReverseProxy {
|
|
225
|
+
modifyUpstreamRequest(req) {
|
|
226
|
+
req = super.modifyUpstreamRequest(req);
|
|
227
|
+
|
|
228
|
+
const forwarded = req.headers.get('forwarded');
|
|
229
|
+
req.headers.set('forwarded', `for=192.168.0.1;${forwarded}`);
|
|
230
|
+
req.headers.set('Via', '1.1 MyProxy');
|
|
231
|
+
|
|
232
|
+
return req;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### `reverseProxy.clear()`
|
|
238
|
+
|
|
239
|
+
Clears nocked responses for `proxyOrigin`
|
|
240
|
+
|
|
241
|
+
- Returns: `undefined`
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
reverseProxy.clear();
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Todo
|
|
248
|
+
|
|
249
|
+
- [ ] Reloading page
|
|
250
|
+
- [ ] Unload browser and all its active jsdom instances
|
|
251
|
+
- [ ] Expose network requests
|
|
252
|
+
- [x] Use node `fetch` / `Response` / `Request`
|
|
253
|
+
- [x] Stable version of Nock
|
|
254
|
+
- [x] In-page navigation (clicking links etc.)
|
|
255
|
+
- [x] Containing requests to the app is currently done by setting up a `nock` scope around app origin which intercepts all reqs and proxies them through `supertest`. Not ideal for a bunch of reasons:
|
|
256
|
+
- [x] There is no built in way to clear a specific scope - [creative workaround](https://github.com/nock/nock/issues/1495#issuecomment-499594455)
|
|
257
|
+
- [x] Scrap use of SuperTest. It's incorrectly used as an HTTP lib because of its ability to _make requests to a server_. Not having a listening server makes handling of client side requests messy. Calls to `XMLHttpRequest` needs to be intercepted and cookies will need to be handled manually. Also having the consumer starting / stopping their server once per test process would be more performant than doing it adhoc for each request.
|
package/browser.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import jsdom from 'jsdom';
|
|
2
|
+
|
|
3
|
+
export default class Browser {
|
|
4
|
+
constructor (origin, cookieJar) {
|
|
5
|
+
this.origin = origin;
|
|
6
|
+
this.cookieJar = cookieJar || new jsdom.CookieJar();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async navigateTo (resource, fetchOptions, loadOptions) {
|
|
10
|
+
const response = this.fetch(resource, fetchOptions);
|
|
11
|
+
return this.load(response, loadOptions);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async fetch (resource, options = {}) {
|
|
15
|
+
resource = typeof resource === 'string' ? new URL(resource, this.origin) : resource;
|
|
16
|
+
const isRequest = resource instanceof Request;
|
|
17
|
+
const request = isRequest ? resource : new Request(resource, {
|
|
18
|
+
...options,
|
|
19
|
+
redirect: 'manual'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
this.#persistCookies(request.url, request.headers);
|
|
23
|
+
request.headers.delete('cookie');
|
|
24
|
+
request.headers.set('cookie', this.cookieJar.getCookieStringSync(request.url));
|
|
25
|
+
|
|
26
|
+
const response = await fetch(request);
|
|
27
|
+
|
|
28
|
+
this.#persistCookies(response.url, null, response.headers);
|
|
29
|
+
|
|
30
|
+
if (![ 301, 302, 303, 307, 308 ].includes(response.status))
|
|
31
|
+
return response;
|
|
32
|
+
|
|
33
|
+
const redirectOptions = { ...options, headers: undefined };
|
|
34
|
+
if (response.status <= 303) {
|
|
35
|
+
delete redirectOptions.method;
|
|
36
|
+
delete redirectOptions.body;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return this.fetch(response.headers.get('location'), redirectOptions);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async load (resource, options = {}) {
|
|
43
|
+
resource = await resource;
|
|
44
|
+
const isResponse = resource instanceof Response;
|
|
45
|
+
const document = isResponse ? await resource.text() : resource;
|
|
46
|
+
|
|
47
|
+
return new jsdom.JSDOM(document, {
|
|
48
|
+
pretendToBeVisual: Boolean(options?.painter),
|
|
49
|
+
runScripts: options.resources && 'outside-only',
|
|
50
|
+
...options,
|
|
51
|
+
...(isResponse && {
|
|
52
|
+
url: resource.url || undefined,
|
|
53
|
+
contentType: resource.headers.get('content-type') || undefined,
|
|
54
|
+
}),
|
|
55
|
+
cookieJar: this.cookieJar,
|
|
56
|
+
beforeParse: window => {
|
|
57
|
+
options.painter?.beforeParse(window);
|
|
58
|
+
options.resources?.beforeParse?.(window);
|
|
59
|
+
options.beforeParse?.(window);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
captureNavigation (dom, follow) {
|
|
65
|
+
const browser = this;
|
|
66
|
+
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
dom.window.addEventListener('click', captureLinkClick);
|
|
69
|
+
dom.window.addEventListener('submit', captureFormSubmit);
|
|
70
|
+
for (const form of dom.window.document.forms)
|
|
71
|
+
for (const element of form.elements)
|
|
72
|
+
element.addEventListener('invalid', captureFormElementInvalid);
|
|
73
|
+
|
|
74
|
+
function captureLinkClick (event) {
|
|
75
|
+
const link = event.target.closest('a');
|
|
76
|
+
if (!link) return;
|
|
77
|
+
|
|
78
|
+
runDefault(event, link.href);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function captureFormSubmit (event) {
|
|
82
|
+
const form = event.target;
|
|
83
|
+
const submitter = event.submitter;
|
|
84
|
+
const method = submitter?.formMethod || form.method;
|
|
85
|
+
const action = new URL(submitter?.formAction || form.action);
|
|
86
|
+
const body = new dom.window.FormData(form, submitter);
|
|
87
|
+
|
|
88
|
+
if (method === 'post') {
|
|
89
|
+
const enctype = submitter?.formEnctype || form.enctype;
|
|
90
|
+
|
|
91
|
+
return runDefault(event, action, {
|
|
92
|
+
method,
|
|
93
|
+
headers: { 'content-type': enctype },
|
|
94
|
+
body,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const [ key, value ] of body) {
|
|
99
|
+
action.searchParams.set(key, value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
runDefault(event, action);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function captureFormElementInvalid (event) {
|
|
106
|
+
cleanUp();
|
|
107
|
+
return reject(event);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function runDefault (event, url, options) {
|
|
111
|
+
cleanUp();
|
|
112
|
+
if (event.defaultPrevented) {
|
|
113
|
+
return reject(event);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
if (!follow) {
|
|
118
|
+
return resolve(new Request(url, options));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
dom.window.dispatchEvent(new dom.window.Event('pagehide'));
|
|
122
|
+
dom.window.close();
|
|
123
|
+
resolve(browser.fetch(url, options));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cleanUp () {
|
|
127
|
+
dom.window.removeEventListener('click', captureLinkClick);
|
|
128
|
+
dom.window.removeEventListener('submit', captureFormSubmit);
|
|
129
|
+
for (const form of dom.window.document.forms)
|
|
130
|
+
for (const element of form.elements)
|
|
131
|
+
element.removeEventListener('invalid', captureFormElementInvalid);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#persistCookies (url, reqHeaders, resHeaders) {
|
|
137
|
+
let directives;
|
|
138
|
+
if (reqHeaders)
|
|
139
|
+
directives = reqHeaders.get('cookie')
|
|
140
|
+
?.split(';')
|
|
141
|
+
.map(d => d.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
else if (resHeaders)
|
|
144
|
+
directives = resHeaders.getSetCookie();
|
|
145
|
+
|
|
146
|
+
for (const directive of directives || []) {
|
|
147
|
+
this.cookieJar.setCookieSync(directive, url);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zombieland/tallahassee",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "",
|
|
6
|
+
"main": "tallahassee.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "mocha"
|
|
9
|
+
},
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"jsdom": "^27.4.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"nock": "^14.0.12"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/reverse-proxy.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import nock from 'nock';
|
|
2
|
+
|
|
3
|
+
const httpVerbs = [ 'DELETE', 'GET', 'HEAD', 'MERGE', 'OPTIONS', 'PATCH', 'POST', 'PUT' ];
|
|
4
|
+
|
|
5
|
+
export default class ReverseProxy {
|
|
6
|
+
#interceptors = [];
|
|
7
|
+
|
|
8
|
+
constructor (proxyOrigin, upstreamOrigin, headers) {
|
|
9
|
+
this.proxyOrigin = proxyOrigin;
|
|
10
|
+
this.upstreamOrigin = upstreamOrigin;
|
|
11
|
+
this.headers = new Headers(headers || (url => {
|
|
12
|
+
const { protocol, hostname } = url;
|
|
13
|
+
const proto = protocol.slice(0, -1);
|
|
14
|
+
return {
|
|
15
|
+
'forwarded': `proto=${proto};host=${hostname}`,
|
|
16
|
+
'x-forwarded-proto': proto,
|
|
17
|
+
'x-forwarded-host': hostname,
|
|
18
|
+
};
|
|
19
|
+
})(new URL(proxyOrigin)));
|
|
20
|
+
|
|
21
|
+
const proxy = this;
|
|
22
|
+
for (const verb of httpVerbs) {
|
|
23
|
+
const interceptor = nock(proxyOrigin)
|
|
24
|
+
.persist()
|
|
25
|
+
.intercept(/.*/, verb);
|
|
26
|
+
interceptor.reply(function (path, body, callback) {
|
|
27
|
+
proxy.#forward.call(proxy, this.req, path, body, callback);
|
|
28
|
+
});
|
|
29
|
+
this.#interceptors.push(interceptor);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
clear () {
|
|
34
|
+
for (const interceptor of this.#interceptors)
|
|
35
|
+
nock.removeInterceptor(interceptor);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
modifyUpstreamRequest (req) {
|
|
39
|
+
for (const [ key, value ] of this.headers.entries())
|
|
40
|
+
req.headers.set(key, value);
|
|
41
|
+
return req;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async #forward (req, path, body, callback) {
|
|
45
|
+
try {
|
|
46
|
+
const { method, headers } = req;
|
|
47
|
+
const bereq = this.modifyUpstreamRequest(
|
|
48
|
+
new Request(new URL(path, this.upstreamOrigin), {
|
|
49
|
+
method,
|
|
50
|
+
headers,
|
|
51
|
+
body: body || undefined,
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
const beres = await fetch(bereq);
|
|
55
|
+
callback(null, [
|
|
56
|
+
beres.status,
|
|
57
|
+
await beres.text(),
|
|
58
|
+
Object.fromEntries(beres.headers.entries())
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
callback(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
package/tallahassee.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import Browser from '../browser.js';
|
|
3
|
+
import nock from 'nock';
|
|
4
|
+
import parseFormData from '../../../helpers/parse-form-data.js';
|
|
5
|
+
|
|
6
|
+
describe('Browser', () => {
|
|
7
|
+
before(() => nock.disableNetConnect());
|
|
8
|
+
beforeEach(() => nock.cleanAll());
|
|
9
|
+
|
|
10
|
+
const url = new URL('http://example.com/');
|
|
11
|
+
let browser;
|
|
12
|
+
beforeEach(() => browser = new Browser(url.origin));
|
|
13
|
+
after(() => nock.enableNetConnect());
|
|
14
|
+
|
|
15
|
+
describe('.navigateTo()', () => {
|
|
16
|
+
it('navigates to document with URL', async () => {
|
|
17
|
+
nock(url.origin)
|
|
18
|
+
.get('/')
|
|
19
|
+
.reply(200, '<title>Document from URL');
|
|
20
|
+
|
|
21
|
+
const dom = await browser.navigateTo('/');
|
|
22
|
+
assert(dom.window.document.title, 'Document from URL');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('.fetch()', () => {
|
|
27
|
+
it('fetches resource with url and options', async () => {
|
|
28
|
+
nock(url.origin)
|
|
29
|
+
.post(url.pathname)
|
|
30
|
+
.reply(function (path, body) {
|
|
31
|
+
assert.equal(this.req.headers['req-header'], 'value');
|
|
32
|
+
assert.equal(body, 'request body');
|
|
33
|
+
return [ 200, 'response body', { 'res-header': 'value' } ];
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const response = await browser.fetch(url, {
|
|
37
|
+
method: 'post',
|
|
38
|
+
headers: { 'req-header': 'value' },
|
|
39
|
+
body: 'request body',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.equal(response.status, 200);
|
|
43
|
+
assert.equal(response.headers.get('res-header'), 'value');
|
|
44
|
+
|
|
45
|
+
const responseBody = await response.text();
|
|
46
|
+
assert.equal(responseBody, 'response body');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fetches resource with request', async () => {
|
|
50
|
+
nock(url.origin)
|
|
51
|
+
.post(url.pathname)
|
|
52
|
+
.reply(function (path, body) {
|
|
53
|
+
assert.equal(this.req.headers['req-header'], 'value');
|
|
54
|
+
assert.equal(body, 'request body');
|
|
55
|
+
return [ 200, 'response body', { 'res-header': 'value' } ];
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const request = new Request(url, {
|
|
59
|
+
method: 'post',
|
|
60
|
+
headers: { 'req-header': 'value' },
|
|
61
|
+
body: 'request body',
|
|
62
|
+
});
|
|
63
|
+
const response = await browser.fetch(request);
|
|
64
|
+
|
|
65
|
+
assert.equal(response.status, 200);
|
|
66
|
+
assert.equal(response.headers.get('res-header'), 'value');
|
|
67
|
+
|
|
68
|
+
const responseBody = await response.text();
|
|
69
|
+
assert.equal(responseBody, 'response body');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
[
|
|
73
|
+
[ 301, 'moved-permanently' ],
|
|
74
|
+
[ 302, 'found' ],
|
|
75
|
+
[ 303, 'see-other' ],
|
|
76
|
+
].forEach(([ status, location ]) => {
|
|
77
|
+
it(`follows ${status} redirect`, async () => {
|
|
78
|
+
nock(url.origin)
|
|
79
|
+
.post(url.pathname)
|
|
80
|
+
.reply(status, undefined, { location: `/${location}` })
|
|
81
|
+
.get(`/${location}`)
|
|
82
|
+
.reply(function (path, body) {
|
|
83
|
+
const { headers } = this.req;
|
|
84
|
+
assert.equal(headers['initial-request-header'], undefined);
|
|
85
|
+
assert.equal(body, '');
|
|
86
|
+
return [ 200 ];
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const response = await browser.fetch(url, {
|
|
90
|
+
method: 'post',
|
|
91
|
+
headers: { 'initial-request-header': 'value' },
|
|
92
|
+
body: 'initial request body',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assert.equal(response.status, 200);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
[
|
|
100
|
+
[ 307, 'temporary-redirect' ],
|
|
101
|
+
[ 308, 'permanent-redirect' ],
|
|
102
|
+
].forEach(([ status, location ]) => {
|
|
103
|
+
it(`follows ${status} redirect with method and body unchanged`, async () => {
|
|
104
|
+
nock(url.origin)
|
|
105
|
+
.post(url.pathname)
|
|
106
|
+
.reply(status, undefined, { location: `/${location}` })
|
|
107
|
+
.post(`/${location}`)
|
|
108
|
+
.reply(function (path, body) {
|
|
109
|
+
const { headers } = this.req;
|
|
110
|
+
assert.equal(headers['initial-request-header'], undefined);
|
|
111
|
+
assert.equal(body, 'initial request body');
|
|
112
|
+
return [ 200 ];
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const response = await browser.fetch(url, {
|
|
116
|
+
method: 'post',
|
|
117
|
+
headers: { 'initial-request-header': 'value' },
|
|
118
|
+
body: 'initial request body',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
assert.equal(response.status, 200);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('persists request cookies to cookie jar', async () => {
|
|
126
|
+
nock(url.origin)
|
|
127
|
+
.get('/requires-authentication')
|
|
128
|
+
.times(2)
|
|
129
|
+
.reply(function () {
|
|
130
|
+
assert.equal(this.req.headers.cookie, 'logged-in=1');
|
|
131
|
+
return [ 200, 'OK' ];
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await browser.fetch(
|
|
135
|
+
'/requires-authentication',
|
|
136
|
+
{ headers: { cookie: 'logged-in=1' } }
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const response = await browser.fetch('/requires-authentication');
|
|
140
|
+
assert.equal(response.status, 200);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('persists response cookies to cookie jar', async () => {
|
|
144
|
+
nock(url.origin)
|
|
145
|
+
.post('/login')
|
|
146
|
+
.reply(200, 'OK', { 'set-cookie': 'logged-in=1' })
|
|
147
|
+
.get('/requires-authentication')
|
|
148
|
+
.reply(function () {
|
|
149
|
+
assert.equal(this.req.headers.cookie, 'logged-in=1');
|
|
150
|
+
return [ 200, 'OK' ];
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await browser.fetch('/login', { method: 'post' });
|
|
154
|
+
const response = await browser.fetch('/requires-authentication');
|
|
155
|
+
assert.equal(response.status, 200);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('persists cookies through redirect chain', async () => {
|
|
159
|
+
nock(url.origin)
|
|
160
|
+
.post('/login')
|
|
161
|
+
.reply(function () {
|
|
162
|
+
return this.req.headers.cookie === 'user=zombie' ?
|
|
163
|
+
[ 401, 'Unauthorized' ] :
|
|
164
|
+
[ 302, 'Found', {
|
|
165
|
+
'set-cookie': 'logged-in=1',
|
|
166
|
+
'location': '/requires-authentication'
|
|
167
|
+
} ];
|
|
168
|
+
})
|
|
169
|
+
.get('/requires-authentication')
|
|
170
|
+
.reply(function () {
|
|
171
|
+
assert.equal(this.req.headers.cookie, 'user=person; logged-in=1');
|
|
172
|
+
return [ 200, 'OK' ];
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const response = await browser.fetch('/login', {
|
|
176
|
+
method: 'post',
|
|
177
|
+
headers: { cookie: 'user=person' }
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
assert.equal(response.status, 200);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('.load()', () => {
|
|
185
|
+
it('loads document from string', async () => {
|
|
186
|
+
const dom = await browser.load('<title>Document from string');
|
|
187
|
+
assert(dom.window.document.title, 'Document from string');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('loads document from response', async () => {
|
|
191
|
+
nock(url.origin)
|
|
192
|
+
.post('/secure')
|
|
193
|
+
.reply((path, body) => {
|
|
194
|
+
return body === 'password' ?
|
|
195
|
+
[ 200, '<title>Welcome' ] :
|
|
196
|
+
[ 401, '<title>Get out' ];
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const response = await browser.fetch('/secure', {
|
|
200
|
+
method: 'post',
|
|
201
|
+
body: 'password'
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
assert.equal(response.status, 200);
|
|
205
|
+
|
|
206
|
+
const dom = await browser.load(response);
|
|
207
|
+
assert(dom.window.document.title, 'Welcome');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('loads document with details from response', async () => {
|
|
211
|
+
nock(url.origin)
|
|
212
|
+
.get('/xml-document')
|
|
213
|
+
.reply(
|
|
214
|
+
200,
|
|
215
|
+
'<xml><land><zombie /></land></xml>',
|
|
216
|
+
{ 'content-type': 'application/xml' }
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const pendingResponse = browser.fetch('/xml-document');
|
|
220
|
+
const dom = await browser.load(pendingResponse);
|
|
221
|
+
assert.equal(dom.window.location.href, url.origin + '/xml-document');
|
|
222
|
+
assert.equal(dom.window.document.contentType, 'application/xml');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('.captureNavigation()', () => {
|
|
227
|
+
describe('links', () => {
|
|
228
|
+
let dom;
|
|
229
|
+
beforeEach('a page with a link', async () => {
|
|
230
|
+
nock(url.origin)
|
|
231
|
+
.get('/')
|
|
232
|
+
.reply(200, '<a href="/about">About</a>');
|
|
233
|
+
|
|
234
|
+
dom = await browser.navigateTo('/');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('captures navigation from link', async () => {
|
|
238
|
+
const link = dom.window.document.querySelector('a');
|
|
239
|
+
assert.ok(link, 'expected link');
|
|
240
|
+
|
|
241
|
+
const pendingRequest = browser.captureNavigation(dom);
|
|
242
|
+
assert(pendingRequest instanceof Promise, 'expected promise return value');
|
|
243
|
+
|
|
244
|
+
link.click();
|
|
245
|
+
|
|
246
|
+
const request = await pendingRequest;
|
|
247
|
+
assert(request instanceof Request, 'expected request');
|
|
248
|
+
assert.equal(request.url, link.href);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('captures and follows navigation from link', async () => {
|
|
252
|
+
nock(url.origin)
|
|
253
|
+
.get('/about')
|
|
254
|
+
.reply(200, '<title>About</title>');
|
|
255
|
+
|
|
256
|
+
const link = dom.window.document.querySelector('a');
|
|
257
|
+
assert.ok(link, 'expected link');
|
|
258
|
+
|
|
259
|
+
const pendingResponse = browser.captureNavigation(dom, true);
|
|
260
|
+
assert(pendingResponse instanceof Promise, 'expected promise return value');
|
|
261
|
+
|
|
262
|
+
link.click();
|
|
263
|
+
|
|
264
|
+
const response = await pendingResponse;
|
|
265
|
+
assert(response instanceof Response, 'expected response');
|
|
266
|
+
assert.equal(response.status, 200);
|
|
267
|
+
|
|
268
|
+
const body = await response.text();
|
|
269
|
+
assert.equal(body, '<title>About</title>');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('fails on prevented navigation', async () => {
|
|
273
|
+
const link = dom.window.document.querySelector('a');
|
|
274
|
+
assert.ok(link, 'expected link');
|
|
275
|
+
|
|
276
|
+
link.addEventListener('click', event => event.preventDefault());
|
|
277
|
+
|
|
278
|
+
const pendingRequest = browser.captureNavigation(dom);
|
|
279
|
+
link.click();
|
|
280
|
+
|
|
281
|
+
await assert.rejects(pendingRequest, event => {
|
|
282
|
+
assert.ok(event instanceof dom.window.Event);
|
|
283
|
+
assert.equal(event.type, 'click');
|
|
284
|
+
assert.equal(event.target, link);
|
|
285
|
+
return true;
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('forms', () => {
|
|
291
|
+
let dom;
|
|
292
|
+
beforeEach(async () => {
|
|
293
|
+
nock(url.origin)
|
|
294
|
+
.get('/')
|
|
295
|
+
.times(2)
|
|
296
|
+
.reply(function () {
|
|
297
|
+
if (this.req.headers.cookie === 'logged-in=1') {
|
|
298
|
+
return [ 200, '<title>Welcome</title>' ];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return [ 200, `
|
|
302
|
+
<title>Log in</title>
|
|
303
|
+
<form method="post" action="/login">
|
|
304
|
+
<input type="text" name="username" />
|
|
305
|
+
<input type="password" name="password" />
|
|
306
|
+
<button type="submit">Log in</button>
|
|
307
|
+
</form>
|
|
308
|
+
` ];
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
dom = await browser.navigateTo('/');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('captures navigation from form', async () => {
|
|
315
|
+
assert.equal(dom.window.document.title, 'Log in');
|
|
316
|
+
const [ form ] = dom.window.document.forms;
|
|
317
|
+
assert.ok(form, 'expected form');
|
|
318
|
+
|
|
319
|
+
const [ username, password, submit ] = form.children;
|
|
320
|
+
username.value = 'person';
|
|
321
|
+
password.value = 'password';
|
|
322
|
+
|
|
323
|
+
const pendingRequest = browser.captureNavigation(dom);
|
|
324
|
+
assert(pendingRequest instanceof Promise, 'expected promise return value');
|
|
325
|
+
submit.click();
|
|
326
|
+
|
|
327
|
+
const request = await pendingRequest;
|
|
328
|
+
assert(request instanceof Request, 'expected request');
|
|
329
|
+
assert.equal(request.method, 'POST');
|
|
330
|
+
assert.equal(request.url, url.origin + '/login');
|
|
331
|
+
assert.equal(request.headers.get('content-type'), 'application/x-www-form-urlencoded');
|
|
332
|
+
assert(request.body instanceof ReadableStream);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('captures navigation from form with submitter', async () => {
|
|
336
|
+
assert.equal(dom.window.document.title, 'Log in');
|
|
337
|
+
const [ form ] = dom.window.document.forms;
|
|
338
|
+
assert.ok(form, 'expected form');
|
|
339
|
+
|
|
340
|
+
const [ username, password, submit ] = form.children;
|
|
341
|
+
username.value = 'person';
|
|
342
|
+
password.value = 'password';
|
|
343
|
+
|
|
344
|
+
const { method, action, enctype } = form;
|
|
345
|
+
|
|
346
|
+
form.method = 'get';
|
|
347
|
+
form.action = '/search';
|
|
348
|
+
form.enctype = 'text/plain';
|
|
349
|
+
|
|
350
|
+
submit.formMethod = method;
|
|
351
|
+
submit.formAction = action;
|
|
352
|
+
submit.formEnctype = enctype;
|
|
353
|
+
|
|
354
|
+
const pendingRequest = browser.captureNavigation(dom);
|
|
355
|
+
submit.click();
|
|
356
|
+
|
|
357
|
+
const request = await pendingRequest;
|
|
358
|
+
assert.equal(request.method, 'POST');
|
|
359
|
+
assert.equal(request.url, url.origin + '/login');
|
|
360
|
+
assert.equal(request.headers.get('content-type'), 'application/x-www-form-urlencoded');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('captures navigation from simple form', async () => {
|
|
364
|
+
dom = await browser.load(`
|
|
365
|
+
<form action="/search">
|
|
366
|
+
<input type="search" name="query" />
|
|
367
|
+
<button>Search</button>
|
|
368
|
+
</form>
|
|
369
|
+
`, { url });
|
|
370
|
+
const [ form ] = dom.window.document.forms;
|
|
371
|
+
assert.ok(form, 'expected form');
|
|
372
|
+
|
|
373
|
+
const [ query, submit ] = form.children;
|
|
374
|
+
query.value = 'Twinkies (not snowballs)';
|
|
375
|
+
|
|
376
|
+
const pendingRequest = browser.captureNavigation(dom);
|
|
377
|
+
assert(pendingRequest instanceof Promise, 'expected promise return value');
|
|
378
|
+
submit.click();
|
|
379
|
+
|
|
380
|
+
const request = await pendingRequest;
|
|
381
|
+
assert(request instanceof Request, 'expected request');
|
|
382
|
+
assert.equal(request.method, 'GET');
|
|
383
|
+
assert.equal(request.url, url.origin + '/search?query=Twinkies+%28not+snowballs%29');
|
|
384
|
+
assert.equal(request.headers.get('content-type'), null);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('captures and follows navigation from form', async () => {
|
|
388
|
+
nock(url.origin)
|
|
389
|
+
.post('/login')
|
|
390
|
+
.reply(async function (path, body) {
|
|
391
|
+
const formData = await parseFormData(body, this.req.headers['content-type']);
|
|
392
|
+
const loggedIn = formData.username === 'person' &&
|
|
393
|
+
formData.password === 'password';
|
|
394
|
+
|
|
395
|
+
return [ 303, undefined, {
|
|
396
|
+
'location': '/',
|
|
397
|
+
'set-cookie': 'logged-in=' + Number(loggedIn),
|
|
398
|
+
} ];
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
assert.equal(dom.window.document.title, 'Log in');
|
|
402
|
+
const [ form ] = dom.window.document.forms;
|
|
403
|
+
assert.ok(form, 'expected form');
|
|
404
|
+
|
|
405
|
+
const [ username, password, submit ] = form.children;
|
|
406
|
+
username.value = 'person';
|
|
407
|
+
password.value = 'password';
|
|
408
|
+
|
|
409
|
+
const pendingResponse = browser.captureNavigation(dom, true);
|
|
410
|
+
assert(pendingResponse instanceof Promise, 'expected promise return value');
|
|
411
|
+
submit.click();
|
|
412
|
+
|
|
413
|
+
const response = await pendingResponse;
|
|
414
|
+
assert(response instanceof Response, 'expected response');
|
|
415
|
+
assert.equal(response.status, 200);
|
|
416
|
+
|
|
417
|
+
const body = await response.text();
|
|
418
|
+
assert.equal(body, '<title>Welcome</title>');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('fails on invalid submit', async () => {
|
|
422
|
+
assert.equal(dom.window.document.title, 'Log in');
|
|
423
|
+
const [ form ] = dom.window.document.forms;
|
|
424
|
+
assert.ok(form, 'expected form');
|
|
425
|
+
|
|
426
|
+
form.addEventListener('submit', event => event.preventDefault());
|
|
427
|
+
|
|
428
|
+
const [ username, password, submit ] = form.children;
|
|
429
|
+
username.required = true;
|
|
430
|
+
password.required = true;
|
|
431
|
+
|
|
432
|
+
const pendingRequest = browser.captureNavigation(dom);
|
|
433
|
+
submit.click();
|
|
434
|
+
|
|
435
|
+
await assert.rejects(pendingRequest, event => {
|
|
436
|
+
assert.ok(event instanceof dom.window.Event);
|
|
437
|
+
assert.equal(event.type, 'invalid');
|
|
438
|
+
assert.equal(event.target, username);
|
|
439
|
+
return true;
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('fails on prevented navigation', async () => {
|
|
444
|
+
assert.equal(dom.window.document.title, 'Log in');
|
|
445
|
+
const [ form ] = dom.window.document.forms;
|
|
446
|
+
assert.ok(form, 'expected form');
|
|
447
|
+
|
|
448
|
+
form.addEventListener('submit', event => event.preventDefault());
|
|
449
|
+
|
|
450
|
+
const [ username, password, submit ] = form.children;
|
|
451
|
+
username.value = 'person';
|
|
452
|
+
password.value = 'password';
|
|
453
|
+
|
|
454
|
+
const pendingRequest = browser.captureNavigation(dom);
|
|
455
|
+
submit.click();
|
|
456
|
+
|
|
457
|
+
await assert.rejects(pendingRequest, event => {
|
|
458
|
+
assert.ok(event instanceof dom.window.Event);
|
|
459
|
+
assert.equal(event.type, 'submit');
|
|
460
|
+
assert.equal(event.target, form);
|
|
461
|
+
assert.equal(event.defaultPrevented, true);
|
|
462
|
+
return true;
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import Browser from '../browser.js';
|
|
3
|
+
import nock from 'nock';
|
|
4
|
+
import ReverseProxy from '../reverse-proxy.js';
|
|
5
|
+
|
|
6
|
+
describe('ReverseProxy', () => {
|
|
7
|
+
before(() => nock.disableNetConnect());
|
|
8
|
+
beforeEach(() => nock.cleanAll());
|
|
9
|
+
after(() => nock.enableNetConnect());
|
|
10
|
+
|
|
11
|
+
const proxyOrigin = 'https://tallahassee.zl';
|
|
12
|
+
const upstreamOrigin = 'http://localhost';
|
|
13
|
+
|
|
14
|
+
it('proxies request to upstream origin', async () => {
|
|
15
|
+
nock(upstreamOrigin)
|
|
16
|
+
.get('/resource')
|
|
17
|
+
.reply(200, 'response from upstream');
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line no-new
|
|
20
|
+
new ReverseProxy(proxyOrigin, upstreamOrigin);
|
|
21
|
+
|
|
22
|
+
const response = await fetch(proxyOrigin + '/resource');
|
|
23
|
+
assert.equal(response.status, 200);
|
|
24
|
+
|
|
25
|
+
const responseBody = await response.text();
|
|
26
|
+
assert.equal(responseBody, 'response from upstream');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('proxies request with forwarding headers', async () => {
|
|
30
|
+
nock(upstreamOrigin)
|
|
31
|
+
.get('/resource')
|
|
32
|
+
.reply(function () {
|
|
33
|
+
const { headers } = this.req;
|
|
34
|
+
assert.equal(headers.host, 'tallahassee.zl');
|
|
35
|
+
assert.equal(headers.forwarded, 'proto=https;host=tallahassee.zl');
|
|
36
|
+
assert.equal(headers['x-forwarded-proto'], 'https');
|
|
37
|
+
assert.equal(headers['x-forwarded-host'], 'tallahassee.zl');
|
|
38
|
+
assert.equal(headers['req-header'], 'value');
|
|
39
|
+
return [ 200 ];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line no-new
|
|
43
|
+
new ReverseProxy(proxyOrigin, upstreamOrigin);
|
|
44
|
+
|
|
45
|
+
const response = await fetch(proxyOrigin + '/resource', {
|
|
46
|
+
headers: { 'req-header': 'value' },
|
|
47
|
+
});
|
|
48
|
+
assert.equal(response.status, 200);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('proxies request with additional headers', async () => {
|
|
52
|
+
nock(upstreamOrigin)
|
|
53
|
+
.get('/resource')
|
|
54
|
+
.reply(function () {
|
|
55
|
+
const { headers } = this.req;
|
|
56
|
+
assert.equal(headers.host, 'tallahassee.zl');
|
|
57
|
+
assert.equal(headers.via, '1.1 ZL');
|
|
58
|
+
assert.equal(headers['req-header'], 'value');
|
|
59
|
+
assert.equal(headers.forwarded, undefined);
|
|
60
|
+
assert.equal(headers['x-forwarded-proto'], undefined);
|
|
61
|
+
assert.equal(headers['x-forwarded-host'], undefined);
|
|
62
|
+
return [ 200 ];
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// eslint-disable-next-line no-new
|
|
66
|
+
new ReverseProxy(proxyOrigin, upstreamOrigin, new Headers({
|
|
67
|
+
via: '1.1 ZL'
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const response = await fetch(proxyOrigin + '/resource', {
|
|
71
|
+
headers: { 'req-header': 'value' },
|
|
72
|
+
});
|
|
73
|
+
assert.equal(response.status, 200);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('proxies modified request', async () => {
|
|
77
|
+
nock(upstreamOrigin)
|
|
78
|
+
.get('/resource')
|
|
79
|
+
.reply(function () {
|
|
80
|
+
const { headers } = this.req;
|
|
81
|
+
assert.equal(headers.host, 'tallahassee.zl');
|
|
82
|
+
assert.equal(headers.via, '1.1 ZL');
|
|
83
|
+
assert.equal(headers['req-header'], 'value');
|
|
84
|
+
assert.equal(headers.forwarded, 'for=192.168.0.1;proto=https;host=tallahassee.zl');
|
|
85
|
+
assert.equal(headers['x-forwarded-proto'], undefined);
|
|
86
|
+
assert.equal(headers['x-forwarded-host'], undefined);
|
|
87
|
+
return [ 200 ];
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
class CustomReverseProxy extends ReverseProxy {
|
|
91
|
+
modifyUpstreamRequest (req) {
|
|
92
|
+
assert(!req.headers.get('forwarded'));
|
|
93
|
+
|
|
94
|
+
req = super.modifyUpstreamRequest(req);
|
|
95
|
+
assert(req.headers.get('forwarded'));
|
|
96
|
+
|
|
97
|
+
req.headers.set('via', '1.1 ZL');
|
|
98
|
+
req.headers.set('forwarded', 'for=192.168.0.1;' + req.headers.get('forwarded'));
|
|
99
|
+
req.headers.delete('x-forwarded-proto');
|
|
100
|
+
req.headers.delete('x-forwarded-host');
|
|
101
|
+
return req;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// eslint-disable-next-line no-new
|
|
106
|
+
new CustomReverseProxy(proxyOrigin, upstreamOrigin);
|
|
107
|
+
|
|
108
|
+
const response = await fetch(proxyOrigin + '/resource', {
|
|
109
|
+
headers: { 'req-header': 'value' },
|
|
110
|
+
});
|
|
111
|
+
assert.equal(response.status, 200);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('proxies request with body', async () => {
|
|
115
|
+
nock(upstreamOrigin)
|
|
116
|
+
.post('/resource')
|
|
117
|
+
.reply((path, body) => {
|
|
118
|
+
assert.equal(body, 'request body');
|
|
119
|
+
return [ 200 ];
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// eslint-disable-next-line no-new
|
|
123
|
+
new ReverseProxy(proxyOrigin, upstreamOrigin);
|
|
124
|
+
|
|
125
|
+
const response = await fetch(proxyOrigin + '/resource', {
|
|
126
|
+
method: 'post',
|
|
127
|
+
body: 'request body',
|
|
128
|
+
});
|
|
129
|
+
assert.equal(response.status, 200);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
[
|
|
133
|
+
'DELETE',
|
|
134
|
+
'GET',
|
|
135
|
+
'HEAD',
|
|
136
|
+
'MERGE',
|
|
137
|
+
'OPTIONS',
|
|
138
|
+
'PATCH',
|
|
139
|
+
'POST',
|
|
140
|
+
'PUT'
|
|
141
|
+
].forEach(method => {
|
|
142
|
+
it(`proxies request with method ${method}`, async () => {
|
|
143
|
+
nock(upstreamOrigin)
|
|
144
|
+
.intercept('/resource', method)
|
|
145
|
+
.reply(200);
|
|
146
|
+
|
|
147
|
+
// eslint-disable-next-line no-new
|
|
148
|
+
new ReverseProxy(proxyOrigin, upstreamOrigin);
|
|
149
|
+
|
|
150
|
+
const response = await fetch(proxyOrigin + '/resource', { method });
|
|
151
|
+
assert.equal(response.status, 200);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('proxies requests until cleared', async () => {
|
|
156
|
+
nock(upstreamOrigin)
|
|
157
|
+
.get('/resource')
|
|
158
|
+
.reply(200)
|
|
159
|
+
.persist();
|
|
160
|
+
|
|
161
|
+
const proxy = new ReverseProxy(proxyOrigin, upstreamOrigin);
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < 3; i++) {
|
|
164
|
+
const response = await fetch(proxyOrigin + '/resource');
|
|
165
|
+
assert.equal(response.status, 200);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
proxy.clear();
|
|
169
|
+
|
|
170
|
+
await assert.rejects(fetch(proxyOrigin + '/resource'));
|
|
171
|
+
|
|
172
|
+
const upstreamResponse = await fetch(upstreamOrigin + '/resource');
|
|
173
|
+
assert.equal(upstreamResponse.status, 200);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('proxies requests from browser, document and web page', async () => {
|
|
177
|
+
nock(upstreamOrigin)
|
|
178
|
+
.get('/document')
|
|
179
|
+
.reply(200, `
|
|
180
|
+
<!doctype html>
|
|
181
|
+
<title>Document from upstream</title>
|
|
182
|
+
<iframe src="/sub-document"></iframe>
|
|
183
|
+
<script>
|
|
184
|
+
const req = new XMLHttpRequest();
|
|
185
|
+
req.open("GET", "/data");
|
|
186
|
+
req.addEventListener("load", function () {
|
|
187
|
+
const data = JSON.parse(this.responseText);
|
|
188
|
+
document.title += ', ' + data.title;
|
|
189
|
+
window.dispatchEvent(new Event("fetchresponse"));
|
|
190
|
+
});
|
|
191
|
+
req.send();
|
|
192
|
+
</script>
|
|
193
|
+
`)
|
|
194
|
+
.get('/sub-document')
|
|
195
|
+
.reply(200, `
|
|
196
|
+
<!doctype html>
|
|
197
|
+
<title>Sub-document from upstream</title>
|
|
198
|
+
`)
|
|
199
|
+
.get('/data')
|
|
200
|
+
.reply(200, { title: 'Data from upstream' });
|
|
201
|
+
|
|
202
|
+
// eslint-disable-next-line no-new
|
|
203
|
+
new ReverseProxy(proxyOrigin, upstreamOrigin);
|
|
204
|
+
const browser = new Browser(proxyOrigin);
|
|
205
|
+
const dom = await browser.navigateTo('/document', {}, {
|
|
206
|
+
resources: 'usable',
|
|
207
|
+
runScripts: 'dangerously',
|
|
208
|
+
});
|
|
209
|
+
await Promise.all([
|
|
210
|
+
new Promise(r => dom.window.addEventListener('load', r)),
|
|
211
|
+
new Promise(r => dom.window.addEventListener('fetchresponse', r)),
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
assert.equal(dom.window.location.href, proxyOrigin + '/document');
|
|
215
|
+
assert.equal(dom.window.document.title, 'Document from upstream, Data from upstream');
|
|
216
|
+
assert.equal(dom.window.frames[0].document.title, 'Sub-document from upstream');
|
|
217
|
+
});
|
|
218
|
+
});
|