|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Setup → Email offers the provider `log` as "None (log only — no real |
| 4 | +// delivery)". Saving it after a boot with SMTP used to keep the SMTP transport |
| 5 | +// live: the re-apply logged "transport unchanged — … never delivered" while |
| 6 | +// every message kept going out over SMTP. These pins hold the four readings of |
| 7 | +// one save to one answer — the live transport, the page's live-status |
| 8 | +// statement, the re-apply's log line and the "Send test email" answer — after |
| 9 | +// a boot with SMTP from the settings page, from the boot options |
| 10 | +// (`OS_EMAIL_SMTP_*` reaches the plugin as `providerOptions`), and from the |
| 11 | +// host. The CONTROL is a boot with no SMTP at all. |
| 12 | + |
| 13 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 14 | +import { EmailServicePlugin, type EmailServicePluginOptions } from './email-plugin.js'; |
| 15 | +import { EmailService, LogTransport } from './email-service.js'; |
| 16 | +import { SmtpTransport } from './transports/smtp.js'; |
| 17 | +import type { LiveMailTransportStatus } from './live-transport-status.js'; |
| 18 | + |
| 19 | +const nm = vi.hoisted(() => ({ createTransport: vi.fn(), sendMail: vi.fn() })); |
| 20 | +vi.mock('nodemailer', () => ({ |
| 21 | + default: { createTransport: nm.createTransport }, |
| 22 | + createTransport: nm.createTransport, |
| 23 | +})); |
| 24 | + |
| 25 | +interface Resolved { value: unknown; source?: string } |
| 26 | + |
| 27 | +function fakeSettings(values: Record<string, Resolved>) { |
| 28 | + const listeners: Array<() => void> = []; |
| 29 | + const actions = new Map<string, (a: any) => Promise<any>>(); |
| 30 | + let liveStatus: (() => Promise<LiveMailTransportStatus | undefined>) | undefined; |
| 31 | + return { |
| 32 | + values, |
| 33 | + createClient: () => ({}), |
| 34 | + getNamespace: async () => ({ values }), |
| 35 | + subscribe: (_ns: string, cb: () => void) => { listeners.push(cb); }, |
| 36 | + registerAction: (ns: string, id: string, fn: (a: any) => Promise<any>) => { actions.set(`${ns}/${id}`, fn); }, |
| 37 | + registerLiveStatus: (ns: string, fn: () => Promise<LiveMailTransportStatus | undefined>) => { |
| 38 | + if (ns === 'mail') liveStatus = fn; |
| 39 | + }, |
| 40 | + /** Patch the snapshot and emit settings:changed, as a page save does. */ |
| 41 | + save(patch: Record<string, Resolved>) { |
| 42 | + Object.assign(values, patch); |
| 43 | + for (const l of listeners) l(); |
| 44 | + }, |
| 45 | + /** What the page states, as the settings GET route asks for it (waits for a running re-apply). */ |
| 46 | + statement: () => liveStatus!(), |
| 47 | + /** The "Send test email" answer for the stored values, with nothing edited on the form. */ |
| 48 | + sendTest: () => actions.get('mail/test')!({ |
| 49 | + values: Object.fromEntries(Object.entries(values).map(([k, v]) => [k, v.value])), |
| 50 | + payload: { to: 'admin@example.test' }, |
| 51 | + }), |
| 52 | + }; |
| 53 | +} |
| 54 | + |
| 55 | +async function boot(mail: Record<string, Resolved>, opts: EmailServicePluginOptions = {}) { |
| 56 | + const settings = fakeSettings({ |
| 57 | + provider: { value: 'smtp', source: 'default' }, |
| 58 | + smtp_port: { value: 587, source: 'default' }, |
| 59 | + from_email: { value: 'no-reply@example.test', source: 'global' }, |
| 60 | + ...mail, |
| 61 | + }); |
| 62 | + // The settings binding waits for an engine; nothing here writes a row |
| 63 | + // (`persist: false` below), so it answers reads only. |
| 64 | + const engine = { async find() { return []; } }; |
| 65 | + const services: Record<string, unknown> = { manifest: { register: () => {} }, objectql: engine, settings }; |
| 66 | + const hooks: Record<string, Array<() => Promise<void> | void>> = {}; |
| 67 | + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; |
| 68 | + const ctx = { |
| 69 | + logger, |
| 70 | + getService: <T,>(name: string): T => { |
| 71 | + if (!(name in services)) throw new Error(`service '${name}' not registered`); |
| 72 | + return services[name] as T; |
| 73 | + }, |
| 74 | + registerService: (name: string, svc: unknown) => { services[name] = svc; }, |
| 75 | + hook: (name: string, fn: () => Promise<void> | void) => { (hooks[name] ??= []).push(fn); }, |
| 76 | + }; |
| 77 | + const plugin = new EmailServicePlugin({ persist: false, ...opts }); |
| 78 | + await plugin.init(ctx as never); |
| 79 | + await plugin.start(ctx as never); |
| 80 | + for (const fn of hooks['kernel:ready'] ?? []) await fn(); |
| 81 | + return { settings, logger, service: services.email as EmailService }; |
| 82 | +} |
| 83 | + |
| 84 | +/** The re-apply's line for a `log` save — the last info line naming provider=log. */ |
| 85 | +function logLine(logger: { info: ReturnType<typeof vi.fn> }): string { |
| 86 | + const lines = logger.info.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('provider=log')); |
| 87 | + expect(lines.length).toBeGreaterThan(0); |
| 88 | + return lines[lines.length - 1]; |
| 89 | +} |
| 90 | + |
| 91 | +const SAVE_LOG = { provider: { value: 'log', source: 'global' } }; |
| 92 | + |
| 93 | +/** Every reading of a `log` save that replaced a delivering transport says mail stopped — and it did. */ |
| 94 | +async function expectMailStopped(h: Awaited<ReturnType<typeof boot>>) { |
| 95 | + const statement = await h.settings.statement(); |
| 96 | + // The transport. |
| 97 | + expect(h.service.options.transport).toBeInstanceOf(LogTransport); |
| 98 | + expect(h.service.isConfigured()).toBe(false); |
| 99 | + // The page. |
| 100 | + expect(statement?.label).toBe('No mail leaves this machine'); |
| 101 | + expect(statement?.bannerSeverity).toBe('warning'); |
| 102 | + // The log line. |
| 103 | + const line = logLine(h.logger); |
| 104 | + expect(line).toMatch(/replaces the previous live transport/); |
| 105 | + expect(line).toMatch(/never delivered/); |
| 106 | + // The answer. |
| 107 | + nm.sendMail.mockClear(); |
| 108 | + const answer = await h.settings.sendTest(); |
| 109 | + expect(answer).toMatchObject({ ok: false, severity: 'warning' }); |
| 110 | + expect(answer.message).toMatch(/only logged/); |
| 111 | + expect(nm.sendMail).not.toHaveBeenCalled(); |
| 112 | +} |
| 113 | + |
| 114 | +beforeEach(() => { |
| 115 | + nm.createTransport.mockReset(); |
| 116 | + nm.sendMail.mockReset(); |
| 117 | + nm.createTransport.mockImplementation(() => ({ sendMail: nm.sendMail, close: vi.fn() })); |
| 118 | + nm.sendMail.mockResolvedValue({ messageId: '<wire-1@smtp>', response: '250 2.0.0 Ok' }); |
| 119 | +}); |
| 120 | + |
| 121 | +describe('saving provider "log" after a boot with SMTP stops delivery', () => { |
| 122 | + it('SMTP saved on the settings page: the save installs the LogTransport', async () => { |
| 123 | + const h = await boot({ |
| 124 | + provider: { value: 'smtp', source: 'global' }, |
| 125 | + smtp_host: { value: 'smtp.saved.test', source: 'global' }, |
| 126 | + }); |
| 127 | + expect(h.service.options.transport).toBeInstanceOf(SmtpTransport); |
| 128 | + expect((await h.settings.statement())?.label).toBe('Mail is delivered through SMTP (smtp.saved.test:587)'); |
| 129 | + |
| 130 | + h.settings.save(SAVE_LOG); |
| 131 | + await expectMailStopped(h); |
| 132 | + expect(logLine(h.logger)).toMatch(/was: "Mail is delivered through SMTP \(smtp\.saved\.test:587\)"/); |
| 133 | + }); |
| 134 | + |
| 135 | + it('SMTP built at boot from the boot options (OS_EMAIL_SMTP_*): the save installs the LogTransport', async () => { |
| 136 | + const h = await boot({}, { provider: 'smtp', providerOptions: { host: 'smtp.env.test', port: 2525 } }); |
| 137 | + // The out-of-the-box namespace (no host) keeps the boot-built SMTP. |
| 138 | + expect(h.service.options.transport).toBeInstanceOf(SmtpTransport); |
| 139 | + expect((await h.settings.statement())?.label).toBe('Mail is delivered through SMTP (smtp.env.test:2525)'); |
| 140 | + |
| 141 | + h.settings.save(SAVE_LOG); |
| 142 | + await expectMailStopped(h); |
| 143 | + }); |
| 144 | + |
| 145 | + it('a transport the host supplied: the save installs the LogTransport', async () => { |
| 146 | + const send = vi.fn(async () => ({ messageId: '<host@x>' })); |
| 147 | + const h = await boot({}, { transport: { send } as never }); |
| 148 | + expect(h.service.isConfigured()).toBe(true); |
| 149 | + |
| 150 | + h.settings.save(SAVE_LOG); |
| 151 | + await expectMailStopped(h); |
| 152 | + expect(send).not.toHaveBeenCalled(); |
| 153 | + }); |
| 154 | + |
| 155 | + it('a restart with "log" stored and SMTP in the boot options boots onto the LogTransport', async () => { |
| 156 | + const h = await boot(SAVE_LOG, { provider: 'smtp', providerOptions: { host: 'smtp.env.test' } }); |
| 157 | + await expectMailStopped(h); |
| 158 | + }); |
| 159 | +}); |
| 160 | + |
| 161 | +describe('CONTROL — saving provider "log" after a boot with no SMTP', () => { |
| 162 | + it('the LogTransport stays live, and every reading says nothing is delivered', async () => { |
| 163 | + const h = await boot({}); |
| 164 | + const bootTransport = h.service.options.transport; |
| 165 | + expect(bootTransport).toBeInstanceOf(LogTransport); |
| 166 | + |
| 167 | + h.settings.save(SAVE_LOG); |
| 168 | + const statement = await h.settings.statement(); |
| 169 | + expect(h.service.options.transport).toBe(bootTransport); |
| 170 | + expect(h.service.isConfigured()).toBe(false); |
| 171 | + expect(statement?.label).toBe('No mail leaves this machine'); |
| 172 | + const line = logLine(h.logger); |
| 173 | + expect(line).toMatch(/the LogTransport stays live/); |
| 174 | + expect(line).toMatch(/never delivered/); |
| 175 | + const answer = await h.settings.sendTest(); |
| 176 | + expect(answer).toMatchObject({ ok: false, severity: 'warning' }); |
| 177 | + expect(nm.sendMail).not.toHaveBeenCalled(); |
| 178 | + }); |
| 179 | +}); |
0 commit comments