diff --git a/.release b/.release index aeba3f7..f18c599 160000 --- a/.release +++ b/.release @@ -1 +1 @@ -Subproject commit aeba3f7c18d0d1e2a54d647b03a0e3c4378c10ae +Subproject commit f18c599497f7cb1c3b68e9555fb14e8075c4866b diff --git a/CHANGELOG.md b/CHANGELOG.md index f16b42a..e7fa28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased -### [1.3.6] - 2024-04-09 +### [1.3.6] - 2024-04-14 +- transaction: sync with haraka/Haraka +- connection: import more from haraka/Haraka/connection +- test(conn): expect more connection properties - doc(CONTRIBUTING): added +- doc(README): added example setup ### [1.3.5] - 2024-04-07 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 9b85317..887121b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,9 +1,8 @@ - # Contributors This handcrafted artisinal software is brought to you by: -|
msimerson (60)|
kcberg (1)|
baudehlo (1)|
smfreegard (1)|
lnedry (1)| -| :---: | :---: | :---: | :---: | :---: | +|
msimerson (61) |
kcberg (1) |
baudehlo (1) |
smfreegard (1) |
lnedry (1) | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | this file is maintained by [.release](https://github.com/msimerson/.release) diff --git a/README.md b/README.md index 1201365..e52bf93 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,30 @@ Fixtures for testing Haraka and plugins -# Usage +## Usage -`const fixtures = require('haraka-test-fixtures');` +```js +const fixtures = require('haraka-test-fixtures') +``` -# Exports the following fixture types: +### A common pattern + +```js +beforeEach(() => { + this.plugin = new fixtures.plugin('pluginName') + + this.connection = fixtures.connection.createConnection() + this.connection.init_transaction() +}) + +describe('pluginName', () => { + it('registers', () => { + this.plugin.register() + }) +}) +``` + +## Exports the following fixture types: - connection - line_socket diff --git a/lib/connection.js b/lib/connection.js index fc26c0b..2e2ecac 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -1,76 +1,256 @@ const config = require('haraka-config') +const constants = require('haraka-constants') const Notes = require('haraka-notes') const ResultStore = require('haraka-results') +const utils = require('haraka-utils') const logger = require('./logger') const transaction = require('./transaction') -const cfg = config.get('smtp.ini', { - booleans: [ - '+main.smtputf8', - '+headers.add_received', - '+headers.clean_auth_results', - ], -}) +const states = constants.connection.state class Connection { - constructor(client, server) { + constructor(client, server, cfg) { this.client = client this.server = server - this.relaying = false - this.local = {} + this.cfg = cfg + + this.local = { + ip: null, + port: null, + host: 'haraka-test.example.com', + info: 'Haraka', + } this.remote = { ip: '127.0.0.1', + port: null, + host: null, + info: null, + closed: false, + is_private: false, + is_local: false, + } + this.hello = { + host: null, + verb: null, + } + this.tls = { + enabled: false, + advertised: false, + verified: false, + cipher: {}, } - this.tls = {} - this.hello = {} + this.proxy = { + allowed: false, + ip: null, + type: null, + timer: null, + } + this.set('tls', 'enabled', !!server.has_tls) + + this.current_data = null + this.current_line = null + this.state = states.PAUSE + this.encoding = 'utf8' + this.prev_state = null + this.loop_code = null + this.loop_msg = null + this.uuid = utils.uuid() this.notes = new Notes() + this.transaction = null + this.tran_count = 0 + this.capabilities = null + this.ehlo_hello_message = 'Haraka Test is at your service.' + this.connection_close_message = 'closing test connection.' + this.banner_includes_uuid = true + this.deny_includes_uuid = true + this.early_talker = false + this.pipelining = false + this._relaying = false + this.esmtp = false + this.last_response = null + this.hooks_to_run = [] + this.start_time = Date.now() + this.last_reject = '' + this.max_bytes = 0 + this.max_mime_parts = 1000 + this.totalbytes = 0 + this.rcpt_count = { + accept: 0, + tempfail: 0, + reject: 0, + } + this.msg_count = { + accept: 0, + tempfail: 0, + reject: 0, + } + this.max_line_length = 512 + this.max_data_line_length = 992 this.results = new ResultStore(this) + this.errors = 0 + this.last_rcpt_msg = null + this.hook = null + logger.add_log_methods(this, 'mock-connection') + Connection.setupClient(this) } - auth_results(message) {} - respond(code, msg, func) { - return func() - } - init_transaction(done) { - this.transaction = new transaction.createTransaction(null, cfg) - this.transaction.results = new ResultStore(this) - if (done) done() + + static setupClient(self) { + if (Object.keys(self.client).length === 0) return + const ip = self.client.remoteAddress + if (!ip) { + self.logdebug('setupClient got no IP address for this connection!') + self.client.destroy() + return + } + + const local_addr = self.server.address() + self.set( + 'local', + 'ip', + ipaddr.process(self.client.localAddress || local_addr.address).toString(), + ) + self.set('local', 'port', self.client.localPort || local_addr.port) + self.results.add({ name: 'local' }, self.local) + + self.set('remote', 'ip', ipaddr.process(ip).toString()) + self.set('remote', 'port', self.client.remotePort) + self.results.add({ name: 'remote' }, self.remote) + + self.lognotice('connect', { + ip: self.remote.ip, + port: self.remote.port, + local_ip: self.local.ip, + local_port: self.local.port, + }) + + if (!self.client.on) return + + const log_data = { ip: self.remote.ip } + if (self.remote.host) log_data.host = self.remote.host + + self.client.on('end', () => { + if (self.state >= states.DISCONNECTING) return + self.remote.closed = true + self.loginfo('client half closed connection', log_data) + self.fail() + }) + + self.client.on('close', (has_error) => { + if (self.state >= states.DISCONNECTING) return + self.remote.closed = true + self.loginfo('client dropped connection', log_data) + self.fail() + }) + + self.client.on('error', (err) => { + if (self.state >= states.DISCONNECTING) return + self.loginfo(`client connection error: ${err}`, log_data) + self.fail() + }) + + self.client.on('timeout', () => { + // FIN has sent, when timeout just destroy socket + if (self.state >= states.DISCONNECTED) { + self.client.destroy() + self.loginfo(`timeout, destroy socket (state:${self.state})`) + return + } + if (self.state >= states.DISCONNECTING) return + self.respond(421, 'timeout', () => { + self.fail('client connection timed out', log_data) + }) + }) + + self.client.on('data', (data) => { + self.process_data(data) + }) + + plugins.run_hooks('connect_init', self) } - reset_transaction(done) { - if (this.transaction && this.transaction.resetting === false) { - this.transaction.resetting = true - } else { - this.transaction = null + + setTLS(obj) { + this.set('hello', 'host', undefined) + this.set('tls', 'enabled', true) + for (const t of ['cipher', 'verified', 'verifyError', 'peerCertificate']) { + if (obj[t] === undefined) return + this.set('tls', t, obj[t]) } - if (done) done() } + set(prop_str, val) { if (arguments.length === 3) { prop_str = `${arguments[0]}.${arguments[1]}` val = arguments[2] } - const segments = prop_str.split('.') - let dest = this - while (segments.length > 1) { - if (!dest[segments[0]]) dest[segments[0]] = {} - dest = dest[segments.shift()] + const path_parts = prop_str.split('.') + let loc = this + for (let i = 0; i < path_parts.length; i++) { + const part = path_parts[i] + if (part === '__proto__' || part === 'constructor') continue + + // while another part remains + if (i < path_parts.length - 1) { + if (loc[part] === undefined) loc[part] = {} // initialize + loc = loc[part] // descend + continue + } + + // last part, so assign the value + loc[part] = val } - dest[segments[0]] = val } + get(prop_str) { return prop_str.split('.').reduce((prev, curr) => { return prev ? prev[curr] : undefined }, this) } + + set relaying(val) { + if (this.transaction) { + this.transaction._relaying = val + } else { + this._relaying = val + } + } + get relaying() { + if (this.transaction && '_relaying' in this.transaction) + return this.transaction._relaying + return this._relaying + } + auth_results(message) {} + respond(code, msg, func) { + return func() + } + init_transaction(done) { + this.transaction = new transaction.createTransaction(null, this.cfg) + this.transaction.results = new ResultStore(this) + if (done) done() + } + reset_transaction(done) { + if (this.transaction && this.transaction.resetting === false) { + this.transaction.resetting = true + } else { + this.transaction = null + } + if (done) done() + } } exports.Connection = Connection -exports.createConnection = function (client, server) { - if (typeof client === 'undefined') client = {} - if (typeof server === 'undefined') server = {} - - return new Connection(client, server) +exports.createConnection = function (client = {}, server = {}, cfg = {}) { + if (!cfg || Object.keys(cfg).length === 0) { + cfg = config.get('smtp.ini', { + booleans: [ + '+main.smtputf8', + '+headers.add_received', + '+headers.clean_auth_results', + ], + }) + } + return new Connection(client, server, cfg) } diff --git a/lib/transaction.js b/lib/transaction.js index b702afc..fdc3aeb 100644 --- a/lib/transaction.js +++ b/lib/transaction.js @@ -1,7 +1,9 @@ // A mock SMTP Transaction +const util = require('util') + const Notes = require('haraka-notes') -const ResultStore = require('haraka-results') +const utils = require('haraka-utils') const message = require('haraka-email-message') const logger = require('./logger') @@ -42,37 +44,107 @@ class Transaction { this.data_post_delay = 0 this.encoding = 'utf8' this.mime_part_count = 0 - this.results = new ResultStore(this) - logger.add_log_methods(this, 'mock-connection') } ensure_body() { if (this.body) return this.body = new message.Body(this.header) - this.attachment_start_hooks.forEach((h) => { - this.body.on('attachment_start', h) - }) + for (const hook of this.attachment_start_hooks) { + this.body.on('attachment_start', hook) + } + if (this.banner) this.body.set_banner(this.banner) - this.body_filters.forEach((o) => { + for (const o of this.body_filters) { this.body.add_filter((ct, enc, buf) => { - if ( - (o.ct_match instanceof RegExp && o.ct_match.test(ct.toLowerCase())) || + const re_match = + util.types.isRegExp(o.ct_match) && o.ct_match.test(ct.toLowerCase()) + const ct_begins = ct.toLowerCase().indexOf(String(o.ct_match).toLowerCase()) === 0 - ) { - return o.filter(ct, enc, buf) - } + if (re_match || ct_begins) return o.filter(ct, enc, buf) }) - }) + } + } + + // Removes the CR of a CRLF newline at the end of the buffer. + remove_final_cr(data) { + if (data.length < 2) return data + if (!Buffer.isBuffer(data)) data = Buffer.from(data) + + if (data[data.length - 2] === 0x0d && data[data.length - 1] === 0x0a) { + data[data.length - 2] = 0x0a + return data.slice(0, data.length - 1) + } + return data + } + + // Duplicates any '.' chars at the beginning of a line (dot-stuffing) and + // ensures all newlines are CRLF. + add_dot_stuffing_and_ensure_crlf_newlines(data) { + if (!data.length) return data + if (!Buffer.isBuffer(data)) data = Buffer.from(data) + + // Make a new buffer big enough to hold two bytes for every one input + // byte. At most, we add one extra character per input byte, so this + // is always big enough. We allocate it "unsafe" (i.e. no memset) for + // speed because we're about to fill it with data, and the remainder of + // the space we don't fill will be sliced away before we return this. + const output = Buffer.allocUnsafe(data.length * 2) + let output_pos = 0 + + let input_pos = 0 + let next_dot = data.indexOf(0x2e) + let next_lf = data.indexOf(0x0a) + while (next_dot !== -1 || next_lf !== -1) { + const run_end = + next_dot !== -1 && (next_lf === -1 || next_dot < next_lf) + ? next_dot + : next_lf + + // Copy up till whichever comes first, '.' or '\n' (but don't + // copy the '.' or '\n' itself). + data.copy(output, output_pos, input_pos, run_end) + output_pos += run_end - input_pos + + if ( + data[run_end] === 0x2e && + (run_end === 0 || data[run_end - 1] === 0x0a) + ) { + // Replace /^\./ with '..' + output[output_pos++] = 0x2e + } else if ( + data[run_end] === 0x0a && + (run_end === 0 || data[run_end - 1] !== 0x0d) + ) { + // Replace /\r?\n/ with '\r\n' + output[output_pos++] = 0x0d + } + output[output_pos++] = data[run_end] + + input_pos = run_end + 1 + + if (run_end === next_dot) { + next_dot = data.indexOf(0x2e, input_pos) + } else { + next_lf = data.indexOf(0x0a, input_pos) + } + } + + if (input_pos < data.length) { + data.copy(output, output_pos, input_pos) + output_pos += data.length - input_pos + } + + return output.slice(0, output_pos) } add_data(line) { if (typeof line === 'string') { // This shouldn't ever happen... - line = Buffer.from(line, 'binary') + line = Buffer.from(line, this.encoding) } - // check if this is the end of headers line + // is this the end of headers line? if ( this.header_pos === 0 && (line[0] === 0x0a || (line[0] === 0x0d && line[1] === 0x0a)) @@ -87,23 +159,18 @@ class Transaction { // Build up headers if (this.header_lines.length < this.cfg.headers.max_lines) { if (line[0] === 0x2e) line = line.slice(1) // Strip leading "." - this.header_lines.push(line.toString('binary').replace(/\r\n$/, '\n')) + this.header_lines.push( + line.toString(this.encoding).replace(/\r\n$/, '\n'), + ) } } else if (this.parse_body) { - if (line[0] === 0x2e) line = line.slice(1) // Strip leading "." - let new_line = this.body.parse_more( - line.toString('binary').replace(/\r\n$/, '\n'), - ) + let new_line = line + if (new_line[0] === 0x2e) new_line = new_line.slice(1) // Strip leading "." - if (!new_line.length) { - return // buffering for banners - } - - new_line = new_line - .toString('binary') - .replace(/^\./gm, '..') - .replace(/\r?\n/gm, '\r\n') - line = Buffer.from(new_line, 'binary') + line = this.add_dot_stuffing_and_ensure_crlf_newlines( + this.body.parse_more(this.remove_final_cr(new_line)), + ) + if (!line.length) return // buffering for banners } if (!this.discard_data) this.message_stream.add_line(line) @@ -127,28 +194,26 @@ class Transaction { this.header_pos = header_pos if (this.parse_body) { this.ensure_body() - for (let j = 0; j < body_lines.length; j++) { - this.body.parse_more(body_lines[j]) + for (const bodyLine of body_lines) { + this.body.parse_more(bodyLine) } } } if (this.header_pos && this.parse_body) { - let data = this.body.parse_end() - if (data.length) { - data = data - .toString('binary') - .replace(/^\./gm, '..') - .replace(/\r?\n/gm, '\r\n') - const line = Buffer.from(data, 'binary') + const line = this.add_dot_stuffing_and_ensure_crlf_newlines( + this.body.parse_end(), + ) + if (line.length) { + this.body.force_end() if (!this.discard_data) this.message_stream.add_line(line) } } - if (!this.discard_data) { - this.message_stream.add_line_end(cb) - } else { + if (this.discard_data) { cb() + } else { + this.message_stream.add_line_end(cb) } } @@ -194,7 +259,8 @@ class Transaction { exports.Transaction = Transaction -exports.createTransaction = function (uuid, cfg = {}) { - if (!cfg.main) cfg.main = {} - return new Transaction(uuid, cfg) +exports.createTransaction = function (uuid, cfg) { + const t = new Transaction(uuid, cfg) + logger.add_log_methods(t, 'mock-connection') + return t } diff --git a/lib/vm_harness.js b/lib/vm_harness.js index e8f050b..eac1078 100644 --- a/lib/vm_harness.js +++ b/lib/vm_harness.js @@ -49,9 +49,9 @@ function make_test(module_path, test_path, additional_sandbox) { exports: {}, test, } - Object.keys(additional_sandbox).forEach(function (k) { + for (const k of Object.keys(additional_sandbox)) { sandbox[k] = additional_sandbox[k] - }) + } vm.runInNewContext(code, sandbox) } } diff --git a/package.json b/package.json index e8720b3..87b8b1c 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "files": [ "CHANGELOG.md", "lib", - "config", - "CONTRIBUTORS.md" + "config" ], "engines": { "node": ">=12" @@ -24,7 +23,8 @@ "haraka-constants": "^1.0.6", "haraka-email-message": "^1.2.1", "haraka-notes": "^1.0.6", - "haraka-results": "^2.2.3" + "haraka-results": "^2.2.3", + "haraka-utils": "^1.1.2" }, "devDependencies": { "@haraka/eslint-config": "^1.1.2" @@ -39,7 +39,7 @@ "prettier": "npx prettier . --check", "prettier:fix": "npx prettier . --write --log-level=warn", "test": "npx mocha@^10", - "versions": "npx @msimerson/dependency-version-checker check", - "versions:fix": "npx @msimerson/dependency-version-checker update" + "versions": "npx dependency-version-checker check", + "versions:fix": "npx dependency-version-checker update" } } diff --git a/test/connection.js b/test/connection.js index 0f4ed9a..705bb37 100644 --- a/test/connection.js +++ b/test/connection.js @@ -4,13 +4,12 @@ const connection = require('../lib/connection') // console.log(connection); describe('basic', function () { - it('is a function', (done) => { + it('is a function', () => { assert.equal(typeof connection.Connection, 'function') - done() }) - it('createConnection', (done) => { + + it('createConnection', () => { assert.equal(typeof connection.createConnection, 'function') - done() }) }) @@ -19,38 +18,64 @@ describe('connection', function () { this.connection = connection.createConnection() done() }) - it('creates a new connection', (done) => { + + it('creates a new connection', () => { assert.ok(this.connection) - done() }) - it('creates a new transaction', (done) => { + + it('creates a new transaction', () => { this.connection.init_transaction() assert.ok(this.connection.transaction) - done() }) - it('remote.ip', (done) => { - assert.equal(this.connection.remote.ip, '127.0.0.1') - done() + + it('remote', () => { + assert.deepEqual(this.connection.remote, { + ip: '127.0.0.1', + port: null, + closed: false, + host: null, + info: null, + is_local: false, + is_private: false, + }) }) - it('local', (done) => { - assert.deepEqual(this.connection.local, {}) - done() + + it('local', () => { + assert.deepEqual(this.connection.local, { + host: 'haraka-test.example.com', + info: 'Haraka', + ip: null, + port: null, + }) }) - it('hello', (done) => { - assert.deepEqual(this.connection.hello, {}) - done() + + it('hello', () => { + assert.deepEqual(this.connection.hello, { + host: null, + verb: null, + }) }) - it('tls', (done) => { - assert.deepEqual(this.connection.tls, {}) - done() + + it('tls', () => { + assert.deepEqual(this.connection.tls, { + advertised: false, + cipher: {}, + enabled: false, + verified: false, + }) }) - it('notes', (done) => { + + it('notes', () => { assert.deepEqual(this.connection.notes, {}) - done() }) - it('set', (done) => { + + it('set', () => { this.connection.set('remote', 'ip', '192.168.1.1') assert.deepEqual(this.connection.remote.ip, '192.168.1.1') - done() + }) + + it('get', () => { + this.connection.set('remote', 'ip', '192.168.1.1') + assert.equal(this.connection.get('remote.ip'), '192.168.1.1') }) }) diff --git a/test/index.js b/test/index.js index 53d9df6..56e3bd0 100644 --- a/test/index.js +++ b/test/index.js @@ -4,43 +4,42 @@ const path = require('path') const fixtures = require('../index') describe('test-fixtures', function () { - it('stub', (done) => { + it('stub', () => { assert.equal('function', typeof fixtures.stub.stub) - done() }) - it('logger', (done) => { + + it('logger', () => { assert.equal('function', typeof fixtures.logger.loginfo) - done() }) - it('connection', (done) => { + + it('connection', () => { assert.equal('function', typeof fixtures.connection.createConnection) - done() }) - it('transaction', (done) => { + + it('transaction', () => { assert.equal('function', typeof fixtures.transaction.createTransaction) - done() }) - it('line_socket', (done) => { + + it('line_socket', () => { assert.equal('function', typeof fixtures.line_socket.connect) - done() }) - it('plugin', (done) => { + + it('plugin', () => { const p = new fixtures.plugin(path.join('test', 'fixtures', 'mock-plugin')) assert.equal('function', typeof p.load_plugin) - done() }) - it('result_store', (done) => { + + it('result_store', () => { const rs = new fixtures.result_store() // console.log(rs); assert.equal('function', typeof rs.add) - done() }) - it('util_hmailitem', (done) => { + + it('util_hmailitem', () => { assert.equal('function', typeof fixtures.util_hmailitem.newMockHMailItem) - done() }) - it('vm_harness', (done) => { + + it('vm_harness', () => { assert.equal('function', typeof fixtures.vm_harness.sandbox_require) - done() }) }) diff --git a/test/logger.js b/test/logger.js index 9721f23..110c45d 100644 --- a/test/logger.js +++ b/test/logger.js @@ -4,23 +4,19 @@ const logger = require('../lib/logger') const plugin = { name: 'mock_plugin' } -// console.log(logger); describe('logger', function () { - it('exports logging functions', (done) => { + it('exports logging functions', () => { assert.equal(typeof logger.loginfo, 'function') assert.equal(typeof logger.logwarn, 'function') assert.equal(typeof logger.logerror, 'function') assert.equal(typeof logger.log, 'function') - done() }) - it('log', (done) => { + it('log', () => { assert.ok(logger.log('info', '_test log()_')) - done() }) - it('loginfo', (done) => { + it('loginfo', () => { assert.ok(logger.loginfo(plugin, '_test loginfo()_')) - done() }) }) diff --git a/test/plugin.js b/test/plugin.js index f39840f..8926964 100644 --- a/test/plugin.js +++ b/test/plugin.js @@ -5,60 +5,49 @@ const path = require('path') const Plugin = require('../lib/plugin') describe('plugin', function () { - it('exports a Plugin function', (done) => { + it('exports a Plugin function', () => { assert.equal(typeof Plugin, 'function') - done() }) - it('creates a new Plugin from .js', (done) => { + it('creates a new Plugin from .js', () => { const newPlugin = new Plugin(path.join('test', 'fixtures', 'mock-plugin')) - // console.log(newPlugin); assert.ok(newPlugin) - done() }) - it('creates a new Plugin from dir', (done) => { + it('creates a new Plugin from dir', () => { const newPlugin = new Plugin( path.join('test', 'fixtures', 'mock-plugin-dir'), ) - // console.log(newPlugin); assert.ok(newPlugin) - done() }) describe('register', function () { beforeEach((done) => { - // console.log(Plugin); this.plugin = new Plugin(path.join('test', 'fixtures', 'mock-plugin-dir')) done() }) - it('register exists', (done) => { - // console.log(this.plugin); + it('register exists', () => { assert.equal(typeof this.plugin.register, 'function') - done() }) - it('register runs', (done) => { + it('register runs', () => { this.plugin.register() assert.ok(true) // register() didn't throw - done() }) }) - it('can register plugin with ineritance', (done) => { + it('can register plugin with ineritance', () => { const pi = new Plugin(path.join('test', 'fixtures', 'mock-plugin')) assert.equal(typeof pi.register, 'function') pi.register() assert.ok(Object.keys(pi.base)) - done() }) - it('plugin name remains the same after a plugin inherits', (done) => { + it('plugin name remains the same after a plugin inherits', () => { const pi = new Plugin(path.join('test', 'fixtures', 'mock-plugin')) assert.equal(typeof pi.register, 'function') pi.register() assert.equal(pi.name, path.join('test', 'fixtures', 'mock-plugin')) - done() }) })