// model/emailConfig — the credential store. Same guarantees as before the Gmail // removal (ciphertext at rest, never returned, blank means "leave it alone"), // now over one transport-shaped blob instead of a single refresh-token column. process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, after, beforeEach } = require('node:test') const assert = require('node:assert/strict') const emailConfig = require('../src/model/emailConfig/emailConfig.model') const emailDb = require('../src/model/emailConfig/emailConfig.db') const secretBox = require('../src/utils/secretBox') const db = require('../src/utils/db') after(() => db.close()) const CRED = { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec-abc' } // In-memory stand-in for the singleton row so the model never touches MariaDB. let store beforeEach(() => { store = null emailDb.get = async () => store emailDb.upsert = async (fields) => { store = { ...(store || { id: 1, transport: 'smtp' }), ...fields } return store } }) const decrypted = () => JSON.parse(secretBox.decrypt(store.credential_enc)) test('save encrypts the credential (ciphertext at rest, decryptable)', async () => { await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true }) assert.ok(store.credential_enc) assert.ok(!String(store.credential_enc).includes('sec-abc')) assert.deepEqual(decrypted(), CRED) const withSecret = await emailConfig.getWithSecret() assert.equal(withSecret.credentialSecret.password, 'sec-abc') }) test('getSafe never leaks a secret field, but says which are set', async () => { await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true }) const safe = await emailConfig.getSafe() assert.deepEqual(safe.credential, { host: 'relay.example.com', port: 587, secure: false, user: 'apikey' }) assert.equal('password' in safe.credential, false) assert.deepEqual(safe.secretsSet, { password: true }) assert.equal(safe.hasCredential, true) assert.equal(safe.senderEmail, 'mail@shard.example.com') assert.equal('credentialSecret' in safe, false) assert.equal('credential_enc' in safe, false) }) test('a blank secret leaves the stored one unchanged; other fields still save', async () => { await emailConfig.save({ credential: CRED }) const cipherBefore = store.credential_enc await emailConfig.save({ senderName: 'UOMysticmoon', credential: { ...CRED, password: '' } }) assert.equal(store.sender_name, 'UOMysticmoon') assert.equal(decrypted().password, 'sec-abc') assert.notEqual(store.credential_enc, undefined) assert.ok(cipherBefore) }) test('undeclared keys are dropped — a client cannot smuggle fields into the blob', async () => { await emailConfig.save({ credential: { ...CRED, evil: 'x', proxy: 'http://attacker' } }) assert.deepEqual(Object.keys(decrypted()).sort(), ['host', 'password', 'port', 'secure', 'user']) }) test('changing transport does not carry the old credential across', async () => { await emailConfig.save({ credential: CRED }) // An unregistered target still clears rather than merging: leaving an SMTP // password inside another transport's blob would be a stored secret nobody can // see and nothing will ever use. await emailConfig.save({ transport: 'mailgun', credential: { domain: 'x' } }) assert.equal(store.transport, 'mailgun') assert.equal(store.credential_enc, null) }) test('an incomplete credential is stored but is not "complete"', async () => { // A username with no password authenticates as nobody. await emailConfig.save({ credential: { host: 'relay.example.com', port: 587, user: 'apikey' } }) const safe = await emailConfig.getSafe() assert.equal(safe.hasCredential, false) assert.deepEqual(safe.secretsSet, { password: false }) }) test('an unreadable blob reads as absent, never as an error', async () => { // The rotated-SECRET_ENC_KEY case. It must land the admin on a screen that says // "unconfigured", not a 500 that takes the contact form down with it. store = { id: 1, transport: 'smtp', credential_enc: 'not-ciphertext', enabled: 1 } const safe = await emailConfig.getSafe() assert.equal(safe.hasCredential, false) assert.deepEqual(safe.credential, {}) }) test('disconnect clears the credential, the legacy token and the enabled flag', async () => { store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 } await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true }) const safe = await emailConfig.disconnect(7) assert.equal(store.credential_enc, null) assert.equal(store.refresh_token_enc, null) assert.equal(store.enabled, 0) assert.equal(safe.hasCredential, false) assert.equal(safe.hadLegacyConnection, false) assert.equal(safe.status, 'unconfigured') }) test('hadLegacyConnection is the G22 warning condition, and nothing else', async () => { // Present token + no replacement credential: this deployment's mail just // stopped and it has to be told (ENGAGEMENT.md §1.2a consequence 3). store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 } let safe = await emailConfig.getSafe() assert.equal(safe.hadLegacyConnection, true) assert.equal(safe.hasCredential, false) // Once SMTP is configured the pair stops matching, so the warning goes away // without anything having to clear the deprecated column. await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED }) safe = await emailConfig.getSafe() assert.equal(safe.hadLegacyConnection, true) assert.equal(safe.hasCredential, true) }) test('a fresh install is unconfigured, on the default transport, and warns nobody', async () => { const safe = await emailConfig.getSafe() assert.equal(safe.enabled, false) assert.equal(safe.transport, 'smtp') assert.equal(safe.hasCredential, false) assert.equal(safe.hadLegacyConnection, false) assert.equal(safe.status, 'unconfigured') assert.equal(safe.senderEmail, null) })