blob: 1d671eb4cf04aaf1dfe9f6543b1e6b3c202d1ca9 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { readFileSync } from 'node:fs';
import {
createServer,
type AddressInfo,
type Server,
type Socket
} from 'node:net';
import {
createServer as createTlsServer,
type TLSSocket
} from 'node:tls';
import { describe, it, before, after } from 'node:test';
import { ProtocolFrameError } from './client.frame.js';
import { IggyConnection } from './client.connection.js';
import type { ClientConfig } from './client.type.js';
import { Command, HEADER_SIZE, REPLY_OFFSET } from '../wire/vsr/header.js';
const FRAME_LIMIT = 2 * HEADER_SIZE;
const TLS_CERTIFICATE = readFileSync(
new URL('../../../../core/certs/iggy_cert.pem', import.meta.url)
);
const TLS_KEY = readFileSync(
new URL('../../../../core/certs/iggy_key.pem', import.meta.url)
);
const TLS_CA_CERTIFICATE = readFileSync(
new URL('../../../../core/certs/iggy_ca_cert.pem', import.meta.url)
);
const startTlsServer = async (): Promise<Server> => {
const server = createTlsServer({
cert: TLS_CERTIFICATE,
key: TLS_KEY
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return server;
};
const startServer = async (): Promise<Server> => {
const server = createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return server;
};
const connectionConfig = (server: Server): ClientConfig => ({
transport: 'TCP',
options: {
host: '127.0.0.1',
port: (server.address() as AddressInfo).port
},
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: false, interval: 0, maxRetries: 0 },
maxResponseFrameSize: FRAME_LIMIT
});
const replyFrame = (body: Buffer): Buffer => {
const frame = Buffer.alloc(HEADER_SIZE + body.length);
frame.writeUInt32LE(frame.length, REPLY_OFFSET.size);
frame.writeUInt8(Command.Reply, REPLY_OFFSET.command);
body.copy(frame, HEADER_SIZE);
return frame;
};
const closeConnection = async (
connection: IggyConnection,
server: Server
): Promise<void> => {
connection._destroy();
if (!connection.socket.destroyed)
await once(connection.socket, 'close');
await new Promise<void>((resolve) => server.close(() => resolve()));
};
let keepAlive: NodeJS.Timeout;
describe('IggyConnection', () => {
// Note:
// before node v24 Timeout.unref() would let eventloop exit before test end
// (tested against 22.x 23.x -> fail vs 24.x 26.x -> pass)
// this timeout prevent eventloop exit before this test end
before(() => { keepAlive = setInterval(() => {}, 10000) });
it('recognizes a connection established before connect is called',
async () => {
const server = await startServer();
const accepted = once(server, 'connection');
const connection = new IggyConnection(connectionConfig(server));
try {
await accepted;
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(await connection.connect(), connection);
assert.equal(connection.connected, true);
} finally {
await closeConnection(connection, server);
}
}
);
it('shares connection attempts, recognizes endpoints, and writes frames',
async () => {
const server = await startServer();
const received = new Promise<Buffer>((resolve) => {
server.once('connection', (socket) => {
socket.once('data', (data) => resolve(Buffer.from(data)));
});
});
const connection = new IggyConnection(connectionConfig(server));
try {
const first = connection.connect();
assert.equal(connection.connect(), first);
await first;
assert.equal(await connection.connect(), connection);
assert.equal(
connection.isConnectedTo(
'localhost',
(server.address() as AddressInfo).port
),
true
);
connection.config.options = {
...connection.config.options,
host: 'broker.example'
};
assert.equal(
connection.isConnectedTo(
'broker.example',
(server.address() as AddressInfo).port
),
true
);
const frame = replyFrame(Buffer.from('payload'));
connection.writeFrame(frame);
assert.deepEqual(await received, frame);
} finally {
await closeConnection(connection, server);
}
}
);
it('emits complete buffered responses and rejects malformed frames',
async () => {
const server = await startServer();
const connection = new IggyConnection(connectionConfig(server));
try {
await connection.connect();
const frame = replyFrame(Buffer.from('response'));
const response = once(connection, 'response');
connection._onData(frame.subarray(0, 6));
connection._onData(frame.subarray(6));
assert.deepEqual((await response)[0], frame);
const malformed = replyFrame(Buffer.alloc(0));
malformed.writeUInt32LE(FRAME_LIMIT + 1, REPLY_OFFSET.size);
const error = once(connection, 'error');
connection._onData(malformed);
assert.ok((await error)[0] instanceof ProtocolFrameError);
} finally {
await closeConnection(connection, server);
}
}
);
it('suppresses expected reset errors during intentional shutdown',
async () => {
const server = await startServer();
const connection = new IggyConnection(connectionConfig(server));
try {
await connection.connect();
let emitted = false;
connection.on('error', () => { emitted = true; });
connection.ending = true;
connection.socket.emit(
'error',
Object.assign(new Error('reset'), { code: 'ECONNRESET' })
);
assert.equal(emitted, false);
} finally {
await closeConnection(connection, server);
}
}
);
it('does not reconnect or reopen after destruction', async () => {
const server = await startServer();
const config = {
...connectionConfig(server),
reconnect: { enabled: true, interval: 20, maxRetries: 1 }
};
let connections = 0;
let serverSocket: Socket | undefined;
server.on('connection', (socket) => {
connections += 1;
serverSocket = socket;
});
const connection = new IggyConnection(config);
try {
await connection.connect();
const disconnected = once(connection, 'disconnected');
serverSocket?.destroy();
await disconnected;
connection._destroy();
await assert.rejects(
() => connection.connect(),
/connection is closed/
);
await new Promise<void>((resolve) => setTimeout(resolve, 40));
assert.equal(connections, 1);
} finally {
await closeConnection(connection, server);
}
});
it('shares reconnect backoff with callers',
async () => {
const server = await startServer();
const config = {
...connectionConfig(server),
reconnect: { enabled: true, interval: 10, maxRetries: 2 }
};
let serverSocket: Socket | undefined;
let connections = 0;
server.on('connection', (socket) => {
connections += 1;
serverSocket = socket;
});
const connection = new IggyConnection(config);
connection.on('error', () => undefined);
try {
await connection.connect();
const oldSocket = connection.socket;
connection._onData(Buffer.alloc(3));
const disconnected = once(connection, 'disconnected');
serverSocket?.destroy();
await disconnected;
const first = connection.connect();
assert.equal(connection.connect(), first);
assert.equal(await first, connection);
assert.equal(connection.connected, true);
assert.equal(connections, 2);
const frame = replyFrame(Buffer.from('fresh'));
const response = once(connection, 'response');
oldSocket.emit('data', Buffer.alloc(8));
connection._onData(frame);
assert.deepEqual((await response)[0], frame);
} finally {
await closeConnection(connection, server);
}
}
);
it('keeps the seed endpoint when a redirect fails', async () => {
const server = await startServer();
const unavailable = await startServer();
const unavailablePort = (unavailable.address() as AddressInfo).port;
await new Promise<void>((resolve) => unavailable.close(() => resolve()));
const connection = new IggyConnection({
...connectionConfig(server),
reconnect: { enabled: true, interval: 10, maxRetries: 1 }
});
connection.on('error', () => undefined);
try {
await connection.connect();
const seedPort = (server.address() as AddressInfo).port;
const reconnected = new Promise<void>((resolve) => {
connection.once('connect', () => resolve());
});
await assert.rejects(
() => connection.redirect('127.0.0.1', unavailablePort)
);
await reconnected;
assert.equal(connection.config.options.host, '127.0.0.1');
assert.equal(connection.config.options.port, seedPort);
assert.equal(connection.connected, true);
assert.equal(
connection.isConnectedTo('127.0.0.1', unavailablePort),
false
);
} finally {
await closeConnection(connection, server);
}
});
it('stops a pending reconnect after a redirect replaces the socket',
async () => {
const seed = await startServer();
const target = await startServer();
const targetPort = (target.address() as AddressInfo).port;
let seedConnections = 0;
let seedSocket: Socket | undefined;
seed.on('connection', (socket) => {
seedConnections += 1;
seedSocket = socket;
});
let targetConnections = 0;
target.on('connection', () => {
targetConnections += 1;
});
const connection = new IggyConnection({
...connectionConfig(seed),
reconnect: { enabled: true, interval: 50, maxRetries: 3 }
});
connection.on('error', () => undefined);
try {
await connection.connect();
const disconnected = once(connection, 'disconnected');
seedSocket?.destroy();
await disconnected;
await connection.redirect('127.0.0.1', targetPort);
assert.equal(connection.connected, true);
await new Promise<void>((resolve) => setTimeout(resolve, 150));
assert.equal(connection.connected, true);
assert.equal(connection.isConnectedTo('127.0.0.1', targetPort), true);
assert.equal(seedConnections, 1);
assert.equal(targetConnections, 1);
} finally {
connection._destroy();
await new Promise<void>((resolve) => seed.close(() => resolve()));
await new Promise<void>((resolve) => target.close(() => resolve()));
}
}
);
it('exhausts consecutive failed retries without unhandled rejections',
async () => {
const server = await startServer();
let serverSocket: Socket | undefined;
server.on('connection', (socket) => {
serverSocket = socket;
});
const rejections: unknown[] = [];
const onUnhandled = (reason: unknown) => rejections.push(reason);
process.on('unhandledRejection', onUnhandled);
const connection = new IggyConnection({
...connectionConfig(server),
reconnect: { enabled: true, interval: 10, maxRetries: 3 }
});
try {
await connection.connect();
const exhausted = new Promise<Error>((resolve) => {
const onError = (error: Error) => {
if (!error.message.includes('reconnect maxRetries exceeded'))
return;
connection.removeListener('error', onError);
resolve(error);
};
connection.on('error', onError);
});
const closed = new Promise<void>(
(resolve) => server.close(() => resolve())
);
serverSocket?.destroy();
const retryStartedAt = Date.now();
await closed;
const error = await exhausted;
// Three retries at a 10 ms interval must spend at least 30 ms in
// backoff; a broken wait would redial back to back.
assert.ok(
Date.now() - retryStartedAt >= 30,
'reconnect backoff did not elapse between retries'
);
assert.match(error.message, /reconnect maxRetries exceeded/);
await new Promise<void>((resolve) => setTimeout(resolve, 20));
assert.deepEqual(rejections, []);
} finally {
process.removeListener('unhandledRejection', onUnhandled);
connection._destroy();
}
}
);
it('falls back to the seed endpoint when a redirected leader dies',
async () => {
const seed = await startServer();
const seedPort = (seed.address() as AddressInfo).port;
const target = await startServer();
const targetPort = (target.address() as AddressInfo).port;
let targetSocket: Socket | undefined;
target.on('connection', (socket) => {
targetSocket = socket;
});
const connection = new IggyConnection({
...connectionConfig(seed),
reconnect: { enabled: true, interval: 10, maxRetries: 4 }
});
connection.on('error', () => undefined);
try {
await connection.connect();
await connection.redirect('127.0.0.1', targetPort);
assert.equal(connection.config.options.port, targetPort);
const reconnected = new Promise<void>((resolve) => {
connection.once('connect', () => resolve());
});
const targetClosed = new Promise<void>(
(resolve) => target.close(() => resolve())
);
targetSocket?.destroy();
await targetClosed;
await reconnected;
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(connection.connected, true);
assert.equal(connection.isConnectedTo('127.0.0.1', seedPort), true);
assert.equal(connection.config.options.port, seedPort);
} finally {
await closeConnection(connection, seed);
}
}
);
it('rotates a redial through the roster it learned while connected',
async () => {
const seed = await startServer();
const seedPort = (seed.address() as AddressInfo).port;
const connection = new IggyConnection(connectionConfig(seed));
connection.on('error', () => undefined);
try {
connection.rememberRoster([
{ host: '127.0.0.1', port: seedPort },
{ host: '127.0.0.1', port: seedPort + 1 },
{ host: '127.0.0.1', port: seedPort + 2 }
]);
// The endpoint the client is on leads, the roster follows, and the
// roster's copy of that endpoint does not earn a second attempt.
assert.deepEqual(
connection._redialCandidates().map((options) => options.port),
[seedPort, seedPort + 1, seedPort + 2]
);
} finally {
connection._destroy();
await new Promise<void>((resolve) => seed.close(() => resolve()));
}
}
);
it('walks each roster endpoint once per request', async () => {
const seed = await startServer();
const seedPort = (seed.address() as AddressInfo).port;
const connection = new IggyConnection(connectionConfig(seed));
connection.on('error', () => undefined);
try {
await connection.connect();
connection.rememberRoster([
{ host: '127.0.0.1', port: seedPort },
{ host: '127.0.0.1', port: seedPort + 1 },
{ host: '127.0.0.1', port: seedPort + 2 }
]);
const visited = new Set<string>();
assert.deepEqual(connection.nextRosterEndpoint(visited), {
host: '127.0.0.1', port: seedPort + 1
});
assert.deepEqual(connection.nextRosterEndpoint(visited), {
host: '127.0.0.1', port: seedPort + 2
});
assert.equal(connection.nextRosterEndpoint(visited), undefined);
assert.equal(visited.size, 3);
} finally {
await closeConnection(connection, seed);
}
});
it('dials the endpoint it is on, then the seed, then the roster',
async () => {
const seed = await startServer();
const seedPort = (seed.address() as AddressInfo).port;
const connection = new IggyConnection(connectionConfig(seed));
connection.on('error', () => undefined);
try {
// A redirect moves the client off its seed; the seed is still the one
// endpoint the caller vouched for, so it comes before a roster the
// cluster may have reshaped since.
connection.config.options = {
...connection.config.options,
port: seedPort + 9
};
connection.rememberRoster([{ host: '127.0.0.1', port: seedPort + 5 }]);
assert.deepEqual(
connection._redialCandidates().map((options) => options.port),
[seedPort + 9, seedPort, seedPort + 5]
);
} finally {
connection._destroy();
await new Promise<void>((resolve) => seed.close(() => resolve()));
}
}
);
it('counts endpoints that only differ in spelling once',
async () => {
const seed = await startServer();
const seedPort = (seed.address() as AddressInfo).port;
const connection = new IggyConnection(connectionConfig(seed));
connection.on('error', () => undefined);
try {
// The loopback aliases and an IPv4-mapped address all name the endpoint
// the client is already on, so none of them earns a dial of its own.
connection.rememberRoster([
{ host: 'localhost', port: seedPort },
{ host: '::1', port: seedPort },
{ host: '::ffff:127.0.0.1', port: seedPort },
{ host: '127.0.0.1', port: seedPort + 1 }
]);
assert.deepEqual(
connection._redialCandidates().map((options) => options.port),
[seedPort, seedPort + 1]
);
} finally {
connection._destroy();
await new Promise<void>((resolve) => seed.close(() => resolve()));
}
}
);
it('does not redial at all when reconnection is disabled',
async () => {
// `enabled: false` is what a caller says to opt out. The retry budget is
// whatever the defaults hold, so a loop that reads it without checking
// this flag would run every one of those passes - and with the backoff
// gated on the same flag, back to back.
//
// The endpoint accepts and hangs up, so the drop that would start a
// redial happens and every dial of it is counted.
const hangup = await startServer();
const hangupPort = (hangup.address() as AddressInfo).port;
let accepted = 0;
hangup.on('connection', (socket) => {
accepted += 1;
socket.destroy();
});
const connection = new IggyConnection({
transport: 'TCP',
options: { host: '127.0.0.1', port: hangupPort },
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: false, interval: 10, maxRetries: 12 },
maxResponseFrameSize: FRAME_LIMIT
});
connection.on('error', () => undefined);
try {
await connection.connect().catch(() => undefined);
await new Promise<void>((resolve) => setTimeout(resolve, 300));
assert.equal(accepted, 1,
'a client that turned reconnection off redialed anyway'
);
assert.equal(connection.connected, false);
} finally {
connection._destroy();
await new Promise<void>((resolve) => hangup.close(() => resolve()));
}
}
);
it('sweeps the endpoints it knows once when reconnection is disabled',
async () => {
// Opting out of retries is not opting out of the endpoints: with more
// than one known, they get exactly one pass and no backoff, as in the
// other SDKs.
const dead = await startServer();
const deadPort = (dead.address() as AddressInfo).port;
await new Promise<void>((resolve) => dead.close(() => resolve()));
const live = await startServer();
const livePort = (live.address() as AddressInfo).port;
let accepted = 0;
live.on('connection', () => { accepted += 1; });
const connection = new IggyConnection({
transport: 'TCP',
options: { host: '127.0.0.1', port: deadPort },
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: false, interval: 10, maxRetries: 12 },
maxResponseFrameSize: FRAME_LIMIT
});
connection.on('error', () => undefined);
try {
connection.rememberRoster([{ host: '127.0.0.1', port: livePort }]);
await connection.connect().catch(() => undefined);
await new Promise<void>((resolve) => setTimeout(resolve, 200));
assert.equal(accepted, 1,
'the known endpoints got either no pass or more than one'
);
} finally {
connection._destroy();
await new Promise<void>((resolve) => live.close(() => resolve()));
}
}
);
it('makes one pass when every endpoint is down and reconnection is disabled',
async () => {
// One pass, not the whole retry budget: with the budget read but the
// flag ignored, a client that opted out of retries dials every endpoint
// once per pass for all of them -- and with the backoff gated on the same
// flag, back to back.
//
// Plain TCP behind a TLS client, closed at once: the dial fails, so the
// pass moves on, and every dial is counted where it lands.
const first = await startServer();
const firstPort = (first.address() as AddressInfo).port;
let firstDials = 0;
first.on('connection', (socket) => {
firstDials += 1;
socket.destroy();
});
const second = await startServer();
const secondPort = (second.address() as AddressInfo).port;
let secondDials = 0;
second.on('connection', (socket) => {
secondDials += 1;
socket.destroy();
});
const connection = new IggyConnection({
transport: 'TLS',
options: {
host: '127.0.0.1',
port: firstPort,
rejectUnauthorized: false
},
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: false, interval: 10, maxRetries: 12 },
maxResponseFrameSize: FRAME_LIMIT
});
connection.on('error', () => undefined);
try {
connection.rememberRoster([{ host: '127.0.0.1', port: secondPort }]);
await connection.connect().catch(() => undefined);
await new Promise<void>((resolve) => setTimeout(resolve, 300));
assert.equal(connection.connected, false);
// The connect's own dial of the configured endpoint, then one pass over
// both: the endpoint the client starts on is dialed twice, the one
// behind it once.
assert.deepEqual([firstDials, secondDials], [2, 1],
'a client that opted out of retries swept more than once'
);
} finally {
connection._destroy();
await new Promise<void>((resolve) => first.close(() => resolve()));
await new Promise<void>((resolve) => second.close(() => resolve()));
}
}
);
it('skips the first backoff when another endpoint is known',
async () => {
// The endpoint the client is on is dead and a live one sits behind it in
// the roster: waiting out the interval before the first pass would push
// the failover past what the caller waits for, and the node just lost may
// be gone for good.
const dead = await startServer();
const deadPort = (dead.address() as AddressInfo).port;
await new Promise<void>((resolve) => dead.close(() => resolve()));
const live = await startServer();
const livePort = (live.address() as AddressInfo).port;
const interval = 3000;
const connection = new IggyConnection({
transport: 'TCP',
options: { host: '127.0.0.1', port: deadPort },
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: true, interval, maxRetries: 3 },
maxResponseFrameSize: FRAME_LIMIT
});
connection.on('error', () => undefined);
try {
connection.rememberRoster([{ host: '127.0.0.1', port: livePort }]);
const dialed = once(live, 'connection');
const started = Date.now();
void connection.connect().catch(() => undefined);
await dialed;
assert.ok(Date.now() - started < interval,
'the failover waited out the backoff before its first pass'
);
} finally {
connection._destroy();
await new Promise<void>((resolve) => live.close(() => resolve()));
}
}
);
it('bounds a dial that never becomes usable when others are queued behind it',
async () => {
// Plain TCP behind a TLS client: the socket connects, so only a bound on
// the handshake ends the attempt. The endpoint behind it is dead, so the
// pass has to end on its own rather than hang on the first one.
const silent = await startServer();
const silentPort = (silent.address() as AddressInfo).port;
const held: Socket[] = [];
silent.on('connection', (socket) => { held.push(socket); });
const dead = await startServer();
const deadPort = (dead.address() as AddressInfo).port;
await new Promise<void>((resolve) => dead.close(() => resolve()));
const connection = new IggyConnection({
transport: 'TLS',
options: {
host: '127.0.0.1',
port: silentPort,
rejectUnauthorized: false
},
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: true, interval: 10, maxRetries: 3 },
maxResponseFrameSize: FRAME_LIMIT
});
connection.on('error', () => undefined);
try {
connection.rememberRoster([{ host: '127.0.0.1', port: deadPort }]);
void connection.connect().catch(() => undefined);
// Unbounded, the first dial never ends and this endpoint is dialed
// exactly once, forever.
const deadline = Date.now() + 8_000;
while (held.length < 2 && Date.now() < deadline)
await new Promise<void>((resolve) => setTimeout(resolve, 50));
assert.ok(held.length >= 2,
'a dial that never became usable held the pass'
);
assert.equal(connection.connected, false);
} finally {
connection._destroy();
held.forEach((socket) => socket.destroy());
await new Promise<void>((resolve) => silent.close(() => resolve()));
}
}
);
it('stops a redial pass that is destroyed part-way through',
async () => {
// The endpoint the client is on is dead, so every dial to it is refused
// - and the live roster endpoint behind it is what the pass would reach
// next, unless the destroy in between stops the pass.
const dead = await startServer();
const deadPort = (dead.address() as AddressInfo).port;
await new Promise<void>((resolve) => dead.close(() => resolve()));
const live = await startServer();
const livePort = (live.address() as AddressInfo).port;
let accepted = 0;
live.on('connection', () => { accepted += 1; });
const connection = new IggyConnection({
transport: 'TCP',
options: { host: '127.0.0.1', port: deadPort },
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: true, interval: 10, maxRetries: 3 },
maxResponseFrameSize: FRAME_LIMIT
});
let destroyed = false;
let connectsAfterDestroy = 0;
connection.on('connect', () => {
if (destroyed)
connectsAfterDestroy += 1;
});
try {
connection.rememberRoster([{ host: '127.0.0.1', port: livePort }]);
// The first failure is the initial connect, which is what starts the
// redial pass; the next one is that pass's first candidate, so
// destroying there lands between two candidates rather than before the
// pass.
const destroyedMidPass = new Promise<void>((resolve) => {
let failures = 0;
connection.on('error', () => {
failures += 1;
if (failures < 2 || destroyed)
return;
connection._destroy();
destroyed = true;
resolve();
});
});
await connection.connect().catch(() => undefined);
await destroyedMidPass;
await new Promise<void>((resolve) => setTimeout(resolve, 100));
assert.equal(accepted, 0,
'a destroyed connection must not keep dialing the rest of the pass'
);
assert.equal(connectsAfterDestroy, 0,
'a destroyed connection must not announce a connection'
);
assert.equal(connection.connected, false);
} finally {
connection._destroy();
await new Promise<void>((resolve) => live.close(() => resolve()));
}
}
);
it('settles a dial in flight when a redirect replaces the socket',
async () => {
const seed = await startServer();
const target = await startServer();
const targetPort = (target.address() as AddressInfo).port;
const connection = new IggyConnection(connectionConfig(seed));
connection.on('error', () => undefined);
try {
const pending = connection.connect();
const redirected = connection.redirect('127.0.0.1', targetPort);
await assert.rejects(
() => pending,
/connection closed before it was established/
);
await redirected;
assert.equal(connection.connected, true);
assert.equal(connection.isConnectedTo('127.0.0.1', targetPort), true);
} finally {
connection._destroy();
await new Promise<void>((resolve) => seed.close(() => resolve()));
await new Promise<void>((resolve) => target.close(() => resolve()));
}
}
);
it('shares the redirect dial with a disconnected listener',
async () => {
const seed = await startServer();
const target = await startServer();
const targetPort = (target.address() as AddressInfo).port;
let seedConnections = 0;
let targetConnections = 0;
seed.on('connection', () => {
seedConnections += 1;
});
target.on('connection', () => {
targetConnections += 1;
});
const connection = new IggyConnection(connectionConfig(seed));
connection.on('error', () => undefined);
let listenerConnection: Promise<IggyConnection> | undefined;
try {
await connection.connect();
connection.once('disconnected', () => {
listenerConnection = connection.connect();
});
await connection.redirect('127.0.0.1', targetPort);
assert.ok(listenerConnection);
assert.equal(await listenerConnection, connection);
assert.equal(connection.connected, true);
assert.equal(connection.isConnectedTo('127.0.0.1', targetPort), true);
assert.equal(seedConnections, 1);
assert.equal(targetConnections, 1);
} finally {
connection._destroy();
await new Promise<void>((resolve) => seed.close(() => resolve()));
await new Promise<void>((resolve) => target.close(() => resolve()));
}
}
);
it('rejects an unreadable tls_ca_file with a TypeError at socket creation',
() => {
assert.throws(
() =>
new IggyConnection({
transport: 'TLS',
options: {
host: '127.0.0.1',
port: 8090,
caFile: '/does/not/exist.pem'
},
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: false, interval: 0, maxRetries: 0 }
}),
/cannot read tls_ca_file/
);
}
);
it('sends a DNS host as the SNI server name when none is set',
async () => {
const server = await startTlsServer();
const secureConnection =
once(server, 'secureConnection') as Promise<[TLSSocket]>;
const connection = new IggyConnection({
transport: 'TLS',
options: {
host: 'localhost',
port: (server.address() as AddressInfo).port,
ca: TLS_CA_CERTIFICATE
},
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: false, interval: 0, maxRetries: 0 }
});
try {
await connection.connect();
assert.equal(connection.connected, true);
const [tlsSocket] = await secureConnection;
assert.equal(tlsSocket.servername, 'localhost');
} finally {
await closeConnection(connection, server);
}
}
);
it('omits SNI for IP literal hosts', async () => {
const server = await startTlsServer();
const secureConnection =
once(server, 'secureConnection') as Promise<[TLSSocket]>;
const connection = new IggyConnection({
transport: 'TLS',
options: {
host: '127.0.0.1',
port: (server.address() as AddressInfo).port,
rejectUnauthorized: false
},
credentials: { username: 'iggy', password: 'iggy' },
reconnect: { enabled: false, interval: 0, maxRetries: 0 }
});
try {
await connection.connect();
assert.equal(connection.connected, true);
const [tlsSocket] = await secureConnection;
// Node reports a missing SNI name as false on the server side.
assert.ok(!tlsSocket.servername);
} finally {
await closeConnection(connection, server);
}
});
after(() => clearInterval(keepAlive));
});