Files
website/server/test/checkNoExternalHosts.test.js
Claude 47c8b37d45 feat(email): remove Gmail OAuth2, put SMTP behind a transport registry
Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A
subtraction and a replacement in one commit, because leaving the OAuth2
flow half-wired across a release is worse than either end state.

Deleted, per the §1.2a inventory: GET /admin/email/connect/start and
/connect/callback, the connectStart/connectCallback controllers with the
email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing,
the https://mail.google.com/ scope, the borrowed `google` auth-providers
client, the OAuth2 nodemailer transport with its smtp.gmail.com:465
literals, the refresh-token decrypt in the model, and the client's
Connect Gmail button, redirect banner and six Gmail error strings.
`provider` and `refresh_token_enc` stay as columns under the
additive-only discipline, unread.

Added: a mail transport registry (server/src/engagement/transports) with
`smtp` as the sole registration. `credentialFields` is the single
declaration the admin form renders, the sanitizer filters against, and
the "is it secret" answer comes from, so adding a transport is a
registration rather than four edits. email_config gains transport /
credential_enc (one encrypted JSON blob, since the field list is the
transport's to declare) / reply_to.

All six call sites keep their exact failure contracts: the contact
form's mailto fallback, the invite's copyable link, the reset's generic
200, and sendTeamNotification's never-throws. One deliberate behaviour
change: `enabled` now gates every sender rather than only isConfigured()
— the connect flow used to set it as a side effect, and with a credential
form the toggle has to mean what it says.

Send-test becomes the real verification. Under OAuth2 the sender came
back from Google and was guaranteed to belong to the credential;
operator-typed, it can be refused, so failures name the sender and the
SPF/DMARC reason (§1.2a consequence 2).

G22, the silent degradation: an upgraded deployment backfills to smtp
with no credentials and every sink politely does nothing. The admin
dashboard now warns when the deprecated Gmail token is present and no
replacement credential is, so the one deployment this happens to is told.
A fresh install has never had mail and is not nagged.

Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own
self-test, wired into pr-checks before the install; routes.manifest and
routes.guards regenerated (-2 routes).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:52:55 -05:00

99 lines
4.2 KiB
JavaScript

// Self-test for scripts/checkNoExternalHosts.js — ENGAGEMENT.md §3.2 rule 4.
//
// The same discipline checkModuleIdentifiers.test.js established: feed the
// checker code it MUST reject and code it MUST accept, because a check that
// silently stops checking is worse than no check. The rejection cases below are
// the exact shape of the literal this phase deleted.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { checkFile, maskComments, isAllowed, run } = require('../../scripts/checkNoExternalHosts')
const hostsIn = (src) => checkFile('fake.js', src).map((h) => h.host)
test('catches the literal this phase deleted', () => {
const src = `
const transport = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
})
`
assert.deepEqual(hostsIn(src), ['smtp.gmail.com'])
})
test('catches an API base url, whatever the scheme', () => {
assert.deepEqual(hostsIn(`const BASE = 'https://api.mailgun.net/v3'`), ['api.mailgun.net'])
assert.deepEqual(hostsIn(`const relay = "smtps://mail.somewhere.io:465"`), ['mail.somewhere.io'])
})
test('catches a default sender address', () => {
assert.deepEqual(hostsIn(`const FROM = 'noreply@runicgateway.com'`), ['runicgateway.com'])
})
test('catches a host in a template literal', () => {
assert.deepEqual(hostsIn('const url = `https://api.postmarkapp.com/email`'), ['api.postmarkapp.com'])
})
test('reports the line the literal is on', () => {
const src = ['// a comment', '', "const h = 'smtp.sendgrid.net'"].join('\n')
assert.deepEqual(checkFile('fake.js', src), [
{ file: 'fake.js', line: 3, literal: 'smtp.sendgrid.net', host: 'smtp.sendgrid.net' },
])
})
// ── the accept cases: the whole reason it reads code, not prose ─────────────
test('a host named in a line comment is fine — that is the documentation this phase owes', () => {
assert.deepEqual(hostsIn(`// Gmail still works as plain SMTP: smtp.gmail.com:587 with an app password\nconst x = 1`), [])
})
test('a host named in a block comment is fine', () => {
assert.deepEqual(hostsIn(`/*\n * See https://mailgun.com/docs for the relay posture.\n */\nconst x = 1`), [])
})
test('example.com placeholders are allowed — a form hint is not a destination', () => {
assert.deepEqual(hostsIn(`const f = { placeholder: 'smtp.example.com' }`), [])
assert.deepEqual(hostsIn(`const f = { placeholder: 'noreply@example.com' }`), [])
})
test('loopback is allowed', () => {
assert.deepEqual(hostsIn(`const dev = 'http://127.0.0.1:3000'`), [])
assert.deepEqual(hostsIn(`const dev = 'http://localhost:1025'`), [])
})
test('a module path is not a hostname', () => {
assert.deepEqual(hostsIn(`const m = require('../model/emailConfig/emailConfig.model')`), [])
assert.deepEqual(hostsIn(`const n = require('nodemailer')`), [])
assert.deepEqual(hostsIn(`import x from './transports/smtp.js'`), [])
})
test('an ordinary sentence with a full stop is not a hostname', () => {
assert.deepEqual(hostsIn(`const msg = 'Send failed. Check the host and port.'`), [])
})
// ── the pieces, directly ────────────────────────────────────────────────────
test('maskComments blanks comments but keeps string bodies and line count', () => {
const src = "// smtp.gmail.com\nconst h = 'smtp.relay.net'\n"
const masked = maskComments(src)
assert.equal(masked.split('\n').length, src.split('\n').length)
assert.ok(!masked.includes('smtp.gmail.com'))
assert.ok(masked.includes('smtp.relay.net'))
})
test('isAllowed covers the reserved documentation names and nothing else', () => {
assert.equal(isAllowed('example.com'), true)
assert.equal(isAllowed('mail.example.org'), true)
assert.equal(isAllowed('localhost'), true)
assert.equal(isAllowed('smtp.gmail.com'), false)
assert.equal(isAllowed('api.postmarkapp.com'), false)
})
// ── and the real tree ───────────────────────────────────────────────────────
test('the shipped engagement tree is clean', () => {
assert.deepEqual(run(), [])
})