mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
5de4b9206b
Reject numeric trust-proxy values that Fastify no longer honors and retain explicit boolean or address-based configuration. Bridge Nest and cookie type augmentation differences so the pre-push typecheck succeeds under TypeScript 6.
584 lines
20 KiB
TypeScript
584 lines
20 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import fastifyHelmet from '@fastify/helmet';
|
|
import { request } from 'http';
|
|
import type { AddressInfo } from 'net';
|
|
import {
|
|
parseBooleanEnv,
|
|
parseTrustProxy,
|
|
buildCspDirectives,
|
|
buildHelmetOptions,
|
|
buildEmptyJsonBodyStream,
|
|
registerEmptyBodyContentTypeParser,
|
|
shouldInjectEmptyJsonBody,
|
|
isSecureProtocol,
|
|
applyConditionalHsts,
|
|
registerConditionalHsts,
|
|
isStaticAssetPath,
|
|
shouldServeSpaFallback,
|
|
} from './bootstrap.utils';
|
|
|
|
describe('SPA fallback routing', () => {
|
|
it.each([
|
|
'/assets/index-BMAlyH9T.js',
|
|
'/assets/index-D5CelejF.css',
|
|
'/sw.js',
|
|
'/manifest.webmanifest',
|
|
'/favicon.ico',
|
|
'/pwa-192x192.png',
|
|
'/assets/foliate/reader.js?v=2',
|
|
])('treats %s as a static asset so a missing file 404s instead of returning index.html', (url) => {
|
|
expect(isStaticAssetPath(url)).toBe(true);
|
|
expect(shouldServeSpaFallback(url)).toBe(false);
|
|
});
|
|
|
|
it.each(['/', '/dashboard', '/book/123/files', '/library/12', '/settings/admin/audit-log', '/authors/42', '/read/1/2', '/collections?sort=name'])(
|
|
'serves the SPA shell for client route %s',
|
|
(url) => {
|
|
expect(shouldServeSpaFallback(url)).toBe(true);
|
|
},
|
|
);
|
|
|
|
it('ignores the query string and hash when deciding', () => {
|
|
expect(shouldServeSpaFallback('/assets/app-abc123.js?import&t=1')).toBe(false);
|
|
expect(shouldServeSpaFallback('/dashboard?tab=recent#top')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('parseBooleanEnv', () => {
|
|
it('returns fallback when value is undefined', () => {
|
|
expect(parseBooleanEnv(undefined)).toBe(false);
|
|
expect(parseBooleanEnv(undefined, true)).toBe(true);
|
|
});
|
|
|
|
it('returns fallback when value is empty string', () => {
|
|
expect(parseBooleanEnv('')).toBe(false);
|
|
expect(parseBooleanEnv('', true)).toBe(true);
|
|
});
|
|
|
|
it('returns fallback when value is whitespace only', () => {
|
|
expect(parseBooleanEnv(' ')).toBe(false);
|
|
expect(parseBooleanEnv(' ', true)).toBe(true);
|
|
});
|
|
|
|
it.each(['false', 'FALSE', 'False', '0', 'no', 'NO', 'off', 'OFF'])('returns false for falsy string %s', (val) => {
|
|
expect(parseBooleanEnv(val)).toBe(false);
|
|
expect(parseBooleanEnv(val, true)).toBe(false);
|
|
});
|
|
|
|
it.each(['true', 'TRUE', 'True', '1', 'yes', 'YES', 'on', 'ON'])('returns true for truthy string %s', (val) => {
|
|
expect(parseBooleanEnv(val)).toBe(true);
|
|
expect(parseBooleanEnv(val, false)).toBe(true);
|
|
});
|
|
|
|
it('returns fallback for unrecognised value', () => {
|
|
expect(parseBooleanEnv('maybe')).toBe(false);
|
|
expect(parseBooleanEnv('maybe', true)).toBe(true);
|
|
expect(parseBooleanEnv('enabled')).toBe(false);
|
|
});
|
|
|
|
it('trims surrounding whitespace before evaluating', () => {
|
|
expect(parseBooleanEnv(' true ')).toBe(true);
|
|
expect(parseBooleanEnv(' false ')).toBe(false);
|
|
expect(parseBooleanEnv(' 1 ')).toBe(true);
|
|
expect(parseBooleanEnv(' 0 ')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseTrustProxy', () => {
|
|
it('returns the default loopback string when value is undefined', () => {
|
|
expect(parseTrustProxy(undefined)).toBe('loopback,linklocal,uniquelocal');
|
|
});
|
|
|
|
it('returns the default loopback string when value is empty string', () => {
|
|
expect(parseTrustProxy('')).toBe('loopback,linklocal,uniquelocal');
|
|
});
|
|
|
|
it('returns the default loopback string when value is whitespace only', () => {
|
|
expect(parseTrustProxy(' ')).toBe('loopback,linklocal,uniquelocal');
|
|
});
|
|
|
|
it.each(['false', 'no', 'off'])('returns false for falsy string %s', (val) => {
|
|
expect(parseTrustProxy(val)).toBe(false);
|
|
});
|
|
|
|
it.each(['true', 'yes', 'on'])('returns true for truthy string %s', (val) => {
|
|
expect(parseTrustProxy(val)).toBe(true);
|
|
});
|
|
|
|
it('returns the raw string for arbitrary proxy values', () => {
|
|
expect(parseTrustProxy('127.0.0.1')).toBe('127.0.0.1');
|
|
expect(parseTrustProxy('10.0.0.0/8')).toBe('10.0.0.0/8');
|
|
expect(parseTrustProxy('loopback')).toBe('loopback');
|
|
});
|
|
|
|
it.each(['0', '1', '2', '10', '1.5', '2.9', '-1', '-5', '1e2'])('rejects numeric hop-count value %s', (value) => {
|
|
expect(() => parseTrustProxy(value)).toThrow('Numeric TRUST_PROXY hop counts are not supported; configure trusted proxy IP or CIDR ranges');
|
|
});
|
|
|
|
it('trims surrounding whitespace before evaluating', () => {
|
|
expect(parseTrustProxy(' true ')).toBe(true);
|
|
expect(parseTrustProxy(' false ')).toBe(false);
|
|
expect(parseTrustProxy(' 127.0.0.1 ')).toBe('127.0.0.1');
|
|
});
|
|
});
|
|
|
|
describe('buildCspDirectives', () => {
|
|
it('returns directives with required sources when Cloudflare Insights is disabled', () => {
|
|
const directives = buildCspDirectives({ allowCloudflareInsights: false });
|
|
|
|
expect(directives.defaultSrc).toEqual(["'self'"]);
|
|
expect(directives.scriptSrc).toContain("'self'");
|
|
expect(directives.scriptSrc).toContain("'wasm-unsafe-eval'");
|
|
expect(directives.objectSrc).toEqual(["'none'"]);
|
|
expect(directives.upgradeInsecureRequests).toBeNull();
|
|
});
|
|
|
|
it('uses defaults when called with no arguments', () => {
|
|
const directives = buildCspDirectives();
|
|
|
|
expect(directives.scriptSrc).toContain("'self'");
|
|
expect(directives.scriptSrc).toContain("'wasm-unsafe-eval'");
|
|
expect(directives.connectSrc).not.toContain('https://cloudflareinsights.com');
|
|
});
|
|
|
|
describe('styleSrc', () => {
|
|
it("includes 'self', 'unsafe-inline', blob:, and Google Fonts stylesheet domain", () => {
|
|
const { styleSrc } = buildCspDirectives();
|
|
|
|
expect(styleSrc).toContain("'self'");
|
|
expect(styleSrc).toContain("'unsafe-inline'");
|
|
expect(styleSrc).toContain('blob:');
|
|
expect(styleSrc).toContain('https://fonts.googleapis.com');
|
|
});
|
|
});
|
|
|
|
describe('fontSrc', () => {
|
|
it("includes 'self', data:, blob:, and Google Fonts gstatic domain", () => {
|
|
const { fontSrc } = buildCspDirectives();
|
|
|
|
expect(fontSrc).toContain("'self'");
|
|
expect(fontSrc).toContain('data:');
|
|
expect(fontSrc).toContain('blob:');
|
|
expect(fontSrc).toContain('https://fonts.gstatic.com');
|
|
});
|
|
});
|
|
|
|
describe('workerSrc', () => {
|
|
it("includes 'self' and blob: to allow PDF web workers", () => {
|
|
const { workerSrc } = buildCspDirectives();
|
|
|
|
expect(workerSrc).toContain("'self'");
|
|
expect(workerSrc).toContain('blob:');
|
|
});
|
|
});
|
|
|
|
describe('connectSrc', () => {
|
|
it('always includes jsDelivr CDN for embedpdf stamps manifest', () => {
|
|
const { connectSrc } = buildCspDirectives({ allowCloudflareInsights: false });
|
|
|
|
expect(connectSrc).toContain("'self'");
|
|
expect(connectSrc).toContain('ws:');
|
|
expect(connectSrc).toContain('wss:');
|
|
expect(connectSrc).toContain('https://cdn.jsdelivr.net');
|
|
});
|
|
|
|
it('does not include Cloudflare Insights connect endpoint when disabled', () => {
|
|
const { connectSrc } = buildCspDirectives({ allowCloudflareInsights: false });
|
|
|
|
expect(connectSrc).not.toContain('https://cloudflareinsights.com');
|
|
});
|
|
|
|
it('includes dictionary lookup endpoints used by the epub reader', () => {
|
|
const { connectSrc } = buildCspDirectives();
|
|
|
|
expect(connectSrc).toContain('https://api.dictionaryapi.dev');
|
|
expect(connectSrc).toContain('https://*.wiktionary.org');
|
|
});
|
|
|
|
it('includes the Google Translate endpoint used by the epub reader regardless of Cloudflare Insights setting', () => {
|
|
expect(buildCspDirectives({ allowCloudflareInsights: false }).connectSrc).toContain('https://translate.googleapis.com');
|
|
expect(buildCspDirectives({ allowCloudflareInsights: true }).connectSrc).toContain('https://translate.googleapis.com');
|
|
expect(buildCspDirectives().connectSrc).toContain('https://translate.googleapis.com');
|
|
});
|
|
});
|
|
|
|
describe('scriptSrc with Cloudflare Insights', () => {
|
|
it('adds Cloudflare Insights script domain when enabled', () => {
|
|
const { scriptSrc } = buildCspDirectives({ allowCloudflareInsights: true });
|
|
|
|
expect(scriptSrc).toContain("'self'");
|
|
expect(scriptSrc).toContain("'wasm-unsafe-eval'");
|
|
expect(scriptSrc).toContain('https://static.cloudflareinsights.com');
|
|
});
|
|
|
|
it('adds Cloudflare Insights connect domain when enabled', () => {
|
|
const { connectSrc } = buildCspDirectives({ allowCloudflareInsights: true });
|
|
|
|
expect(connectSrc).toContain('https://cdn.jsdelivr.net');
|
|
expect(connectSrc).toContain('https://cloudflareinsights.com');
|
|
});
|
|
|
|
it('does not add Cloudflare domains when disabled', () => {
|
|
const { scriptSrc, connectSrc } = buildCspDirectives({ allowCloudflareInsights: false });
|
|
|
|
expect(scriptSrc).not.toContain('https://static.cloudflareinsights.com');
|
|
expect(connectSrc).not.toContain('https://cloudflareinsights.com');
|
|
});
|
|
});
|
|
|
|
describe('scriptSrc WASM', () => {
|
|
it("always includes 'wasm-unsafe-eval' to allow WebAssembly compilation", () => {
|
|
expect(buildCspDirectives({ allowCloudflareInsights: false }).scriptSrc).toContain("'wasm-unsafe-eval'");
|
|
expect(buildCspDirectives({ allowCloudflareInsights: true }).scriptSrc).toContain("'wasm-unsafe-eval'");
|
|
});
|
|
});
|
|
|
|
describe('static directives', () => {
|
|
it('imgSrc allows self, data, blob, and https (for admin-configured OIDC provider icons)', () => {
|
|
const { imgSrc } = buildCspDirectives();
|
|
expect(imgSrc).toEqual(["'self'", 'data:', 'blob:', 'https:']);
|
|
});
|
|
|
|
it('imgSrc does not allow plain http to keep mixed-content protection', () => {
|
|
const { imgSrc } = buildCspDirectives();
|
|
expect(imgSrc).not.toContain('http:');
|
|
expect(imgSrc).not.toContain('*');
|
|
});
|
|
|
|
it('mediaSrc allows self, data, and blob', () => {
|
|
const { mediaSrc } = buildCspDirectives();
|
|
expect(mediaSrc).toEqual(["'self'", 'data:', 'blob:']);
|
|
});
|
|
|
|
it('frameSrc allows self and blob', () => {
|
|
const { frameSrc } = buildCspDirectives();
|
|
expect(frameSrc).toEqual(["'self'", 'blob:']);
|
|
});
|
|
|
|
it('frameAncestors restricts to self only', () => {
|
|
const { frameAncestors } = buildCspDirectives();
|
|
expect(frameAncestors).toEqual(["'self'"]);
|
|
});
|
|
|
|
it('objectSrc is none', () => {
|
|
const { objectSrc } = buildCspDirectives();
|
|
expect(objectSrc).toEqual(["'none'"]);
|
|
});
|
|
|
|
it('upgradeInsecureRequests is null', () => {
|
|
const { upgradeInsecureRequests } = buildCspDirectives();
|
|
expect(upgradeInsecureRequests).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildHelmetOptions (integration)', () => {
|
|
it('emits a Content-Security-Policy header whose connect-src allows the Google Translate endpoint', async () => {
|
|
await withCspApp(async (app) => {
|
|
const response = await app.inject({ method: 'GET', url: '/ping' });
|
|
|
|
const csp = response.headers['content-security-policy'];
|
|
expect(csp).toBeDefined();
|
|
|
|
const connectSrcDirective = String(csp)
|
|
.split(';')
|
|
.map((directive) => directive.trim())
|
|
.find((directive) => directive.startsWith('connect-src'));
|
|
|
|
expect(connectSrcDirective).toContain('https://translate.googleapis.com');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('empty request body bootstrap handling', () => {
|
|
it('identifies empty JSON body requests that need parser-safe replacement', () => {
|
|
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'application/json', 'content-length': '0' })).toBe(true);
|
|
expect(shouldInjectEmptyJsonBody('DELETE', { 'content-type': 'application/json' })).toBe(true);
|
|
expect(shouldInjectEmptyJsonBody('PATCH', { 'content-type': 'application/json; charset=utf-8', 'content-length': '0' })).toBe(true);
|
|
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'Application/JSON', 'content-length': ' 0 ' })).toBe(true);
|
|
|
|
expect(shouldInjectEmptyJsonBody('GET', { 'content-type': 'application/json', 'content-length': '0' })).toBe(false);
|
|
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'text/plain', 'content-length': '0' })).toBe(false);
|
|
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'application/json', 'content-length': '9' })).toBe(false);
|
|
});
|
|
|
|
it('builds a parser-safe empty JSON stream with a matching content length', async () => {
|
|
const headers = { 'content-length': '0' };
|
|
const stream = buildEmptyJsonBodyStream(headers);
|
|
const chunks: Buffer[] = [];
|
|
|
|
for await (const chunk of stream) {
|
|
expect(Buffer.isBuffer(chunk)).toBe(true);
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
|
|
expect(headers['content-length']).toBe('2');
|
|
expect(Buffer.concat(chunks).toString('utf8')).toBe('{}');
|
|
});
|
|
|
|
it('accepts empty unsupported content types as an empty object', async () => {
|
|
await withParserApp(async (app) => {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/body',
|
|
headers: {
|
|
'content-type': 'application/octet-stream',
|
|
'content-length': '0',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ body: {} });
|
|
});
|
|
});
|
|
|
|
it('accepts empty chunked requests without a content type as an empty object', async () => {
|
|
await withParserApp(async (app) => {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/body',
|
|
headers: {
|
|
'transfer-encoding': 'chunked',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ body: {} });
|
|
});
|
|
});
|
|
|
|
it('still rejects non-empty unsupported content types', async () => {
|
|
await withParserApp(async (app) => {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/body',
|
|
headers: {
|
|
'content-type': 'application/octet-stream',
|
|
},
|
|
payload: 'not json',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(415);
|
|
expect(response.json()).toMatchObject({
|
|
message: 'Unsupported Media Type',
|
|
statusCode: 415,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('still rejects non-empty bodies without a content type', async () => {
|
|
await withParserApp(async (app) => {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/body',
|
|
payload: 'not json',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(415);
|
|
expect(response.json()).toMatchObject({
|
|
message: 'Unsupported Media Type',
|
|
statusCode: 415,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('does not interfere with Fastify JSON parsing', async () => {
|
|
await withParserApp(async (app) => {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/body',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
payload: JSON.stringify({ ok: true }),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ body: { ok: true } });
|
|
});
|
|
});
|
|
|
|
it('feeds injected empty JSON bodies as buffers for Nest-style Fastify parsers', async () => {
|
|
await withNestBufferParserApp(async (app) => {
|
|
const response = await requestApp(app, {
|
|
method: 'POST',
|
|
path: '/body',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'content-length': '0',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(JSON.parse(response.body)).toEqual({ body: {} });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isSecureProtocol', () => {
|
|
it('returns true only for https', () => {
|
|
expect(isSecureProtocol('https')).toBe(true);
|
|
});
|
|
|
|
it('returns false for http', () => {
|
|
expect(isSecureProtocol('http')).toBe(false);
|
|
});
|
|
|
|
it('returns false for undefined', () => {
|
|
expect(isSecureProtocol(undefined)).toBe(false);
|
|
});
|
|
|
|
it('returns false for unexpected values', () => {
|
|
expect(isSecureProtocol('HTTPS')).toBe(false);
|
|
expect(isSecureProtocol('')).toBe(false);
|
|
expect(isSecureProtocol('ftp')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('applyConditionalHsts', () => {
|
|
it('sets the Strict-Transport-Security header for secure requests', () => {
|
|
const reply = { header: vi.fn() };
|
|
|
|
applyConditionalHsts({ protocol: 'https' }, reply);
|
|
|
|
expect(reply.header).toHaveBeenCalledTimes(1);
|
|
expect(reply.header).toHaveBeenCalledWith('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
});
|
|
|
|
it('does not set the header for plain HTTP requests', () => {
|
|
const reply = { header: vi.fn() };
|
|
|
|
applyConditionalHsts({ protocol: 'http' }, reply);
|
|
|
|
expect(reply.header).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('registerConditionalHsts (integration)', () => {
|
|
it('sends HSTS when a trusted proxy forwards a secure request', async () => {
|
|
await withHstsApp({ trustProxy: true }, async (app) => {
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/ping',
|
|
headers: { 'x-forwarded-proto': 'https' },
|
|
});
|
|
|
|
expect(response.headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains');
|
|
});
|
|
});
|
|
|
|
it('does not send HSTS for a plain HTTP request with no proxy involved', async () => {
|
|
await withHstsApp({ trustProxy: true }, async (app) => {
|
|
const response = await app.inject({ method: 'GET', url: '/ping' });
|
|
|
|
expect(response.headers['strict-transport-security']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('does not trust a forwarded-proto header when the proxy is not configured as trusted', async () => {
|
|
await withHstsApp({ trustProxy: false }, async (app) => {
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/ping',
|
|
headers: { 'x-forwarded-proto': 'https' },
|
|
});
|
|
|
|
expect(response.headers['strict-transport-security']).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
async function withHstsApp(options: { trustProxy: boolean }, run: (app: FastifyInstance) => Promise<void>): Promise<void> {
|
|
const app = Fastify({ logger: false, trustProxy: options.trustProxy });
|
|
await app.register(fastifyHelmet, buildHelmetOptions());
|
|
registerConditionalHsts(app);
|
|
app.get('/ping', () => ({ ok: true }));
|
|
|
|
try {
|
|
await app.ready();
|
|
await run(app);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}
|
|
|
|
async function withCspApp(run: (app: FastifyInstance) => Promise<void>): Promise<void> {
|
|
const app = Fastify({ logger: false });
|
|
await app.register(fastifyHelmet, buildHelmetOptions());
|
|
app.get('/ping', () => ({ ok: true }));
|
|
|
|
try {
|
|
await app.ready();
|
|
await run(app);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}
|
|
|
|
async function withParserApp(run: (app: FastifyInstance) => Promise<void>): Promise<void> {
|
|
const app = Fastify({ logger: false });
|
|
registerEmptyBodyContentTypeParser(app);
|
|
app.post('/body', (request) => ({ body: request.body ?? null }));
|
|
|
|
try {
|
|
await app.ready();
|
|
await run(app);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}
|
|
|
|
async function withNestBufferParserApp(run: (app: FastifyInstance) => Promise<void>): Promise<void> {
|
|
const app = Fastify({ logger: false });
|
|
app.addHook('preParsing', (request, _reply, payload, done) => {
|
|
if (shouldInjectEmptyJsonBody(request.method, request.headers)) {
|
|
done(null, buildEmptyJsonBodyStream(request.headers));
|
|
return;
|
|
}
|
|
done(null, payload);
|
|
});
|
|
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (_request, body: Buffer, done) => {
|
|
done(null, JSON.parse(body.toString('utf8')));
|
|
});
|
|
app.post('/body', (request) => ({ body: request.body ?? null }));
|
|
|
|
try {
|
|
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
await run(app);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}
|
|
|
|
async function requestApp(
|
|
app: FastifyInstance,
|
|
options: { method: string; path: string; headers: Record<string, string> },
|
|
): Promise<{ statusCode: number; body: string }> {
|
|
const address = app.server.address() as AddressInfo;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const req = request(
|
|
{
|
|
method: options.method,
|
|
host: address.address,
|
|
port: address.port,
|
|
path: options.path,
|
|
headers: options.headers,
|
|
},
|
|
(res) => {
|
|
let body = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk) => {
|
|
body += chunk;
|
|
});
|
|
res.on('end', () => {
|
|
resolve({ statusCode: res.statusCode ?? 0, body });
|
|
});
|
|
},
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|