screentinker/server/test/email-nodemailer-live.test.js
screentinker 955a691bcd
Clear the high-severity advisories that reach production (#276)
npm audit reports 8 high findings. Four of them reach production; the other four are
dev-only and cannot, because prod installs with --omit=dev. Verified rather than
assumed: puppeteer-core, extract-zip, @puppeteer/browsers and js-yaml are all absent
from prod's node_modules.

Three of the four are transitive, and the fix is a patch or minor inside the range
package.json already declares — no API moves, and package.json is untouched by them:

  brace-expansion    2.1.2  -> 2.1.4    (archiver -> glob/minimatch)
  ip-address         10.2.0 -> 10.5.0   (express-rate-limit)
  socket.io-parser   4.2.6  -> 4.2.7    (socket.io)

socket.io-parser was the one worth checking, because a parser change that altered the
wire format would break every deployed player at once rather than fail a test. It does
not: socket.io stays at 4.8.3, engine.io at 6.6.9, and the parser's protocol constant
is still 5. Nothing a player speaks changes.

The fourth is a real bump — nodemailer 6.10.1 -> 9.0.5, across three majors, closing
eight advisories including SMTP command injection and header injection. Our surface is
about as small as it gets: createTransport({host, port, secure, auth}) and sendMail with
from/to/subject/text/html. Engine requirements are unchanged (>=6.0.0), and the entry
point is the same.

The existing email tests mock nodemailer through require.cache, so they would have
stayed green through any breaking change in the library itself — proven, not guessed:
with sendMail patched to throw, those 15 tests still pass. So this adds a test that
drives the REAL library over a loopback SMTP server and asserts on the conversation,
using messages built by our own buildSmtpMessage rather than hand-written ones. That
test does fail against the broken build.

Left alone: extract-zip under puppeteer-core, now with no fix available. It is a
devDependency used only by smoke-ui.js, which already no-ops when it is missing, and the
advisory is symlink traversal while unpacking a downloaded browser — puppeteer-core with
an explicit executablePath never downloads or extracts one.

Production audit goes from 4 high to 0. 1671/1671 pass.
2026-08-14 09:47:08 -05:00

95 lines
4.4 KiB
JavaScript

// The rest of the email tests mock nodemailer through require.cache, which proves our code
// calls sendMail correctly but says nothing about whether nodemailer still ACCEPTS what we
// hand it. That gap is exactly where a dependency bump breaks sending: the suite stays green
// while mail stops leaving the building.
//
// This drives the real library against a throwaway SMTP server on a loopback port, using the
// option and message shapes services/email.js actually builds. No network, no credentials.
const test = require('node:test');
const assert = require('node:assert');
const net = require('node:net');
const nodemailer = require('nodemailer');
const { buildSmtpMessage } = require('../services/email');
// A minimal SMTP server that answers enough of the protocol to accept one message and
// records the conversation, so assertions can be made about what went over the wire.
function startFakeSmtp() {
const seen = { commands: [], data: '' };
return new Promise((resolve) => {
const srv = net.createServer((sock) => {
let inData = false;
sock.write('220 fake.local ESMTP\r\n');
sock.on('data', (buf) => {
const chunk = buf.toString();
if (inData) {
seen.data += chunk;
if (/\r\n\.\r\n/.test(seen.data)) { inData = false; sock.write('250 OK queued\r\n'); }
return;
}
for (const line of chunk.split('\r\n').filter(Boolean)) {
seen.commands.push(line);
const cmd = line.split(' ')[0].toUpperCase();
if (cmd === 'EHLO' || cmd === 'HELO') sock.write('250-fake.local\r\n250 SIZE 10485760\r\n');
else if (cmd === 'MAIL' || cmd === 'RCPT') sock.write('250 OK\r\n');
else if (cmd === 'DATA') { inData = true; sock.write('354 send it\r\n'); }
else if (cmd === 'QUIT') { sock.write('221 bye\r\n'); sock.end(); }
else sock.write('250 OK\r\n');
}
});
sock.on('error', () => {}); // a client hanging up mid-conversation is not a test failure
});
srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen }));
});
}
test('the installed nodemailer accepts the options and messages we build', async () => {
const { srv, port, seen } = await startFakeSmtp();
try {
// The shape getSmtpTransporter() constructs.
const transporter = nodemailer.createTransport({
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
});
// The object `from` form, used when a display name overrides the configured one.
await transporter.sendMail({
from: { name: 'ScreenTinker', address: 'noreply@example.com' },
to: 'user@x.com',
subject: '[ScreenTinker] Hello',
html: '<p>hi there</p>',
text: 'hi there',
});
assert.ok(seen.commands.some(c => /^EHLO/i.test(c)), 'greets the server');
assert.ok(seen.commands.some(c => /^MAIL FROM:<noreply@example\.com>/i.test(c)),
'envelope sender is the configured address, not the display name');
assert.ok(seen.commands.some(c => /^RCPT TO:<user@x\.com>/i.test(c)), 'envelope recipient');
assert.match(seen.data, /Subject: \[ScreenTinker\] Hello/, 'subject and its prefix survive encoding');
assert.match(seen.data, /From: ScreenTinker <noreply@example\.com>/, 'display-name form still renders');
assert.match(seen.data, /Content-Type: multipart\/alternative/, 'text and html sent as alternatives');
// The string `from` form, used when there is no override.
await transporter.sendMail({
from: 'ScreenTinker <noreply@example.com>', to: 'user@x.com', subject: 'Welcome', html: '<p>x</p>',
});
transporter.close();
} finally {
srv.close();
}
});
test('buildSmtpMessage output is something the installed nodemailer can send', async () => {
const { srv, port, seen } = await startFakeSmtp();
try {
const transporter = nodemailer.createTransport({
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
});
// Built by our own code rather than hand-written here, so the two cannot drift apart.
await transporter.sendMail(buildSmtpMessage('to@x.com', 'Subj', 'plain', '<p>rich</p>', 'Sender Name'));
assert.ok(seen.commands.some(c => /^RCPT TO:<to@x\.com>/i.test(c)), 'recipient reached the wire');
assert.match(seen.data, /Subject: Subj/, 'subject reached the wire');
transporter.close();
} finally {
srv.close();
}
});