|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Who "Send test email" sends to, driven through the SERVED door: a real |
| 4 | +// `SettingsService` holding the real `mail` manifest, its real REST routes, and |
| 5 | +// the real `mail/test` handler that `EmailServicePlugin` registers at |
| 6 | +// `kernel:ready`. |
| 7 | +// |
| 8 | +// The served form has no recipient input, so the console's request body is its |
| 9 | +// unsaved edits — `{}` on a page nobody has edited — and carries no `to`. The |
| 10 | +// handler used to fall back to `from_email`, the sender address: a "success" |
| 11 | +// mailed to a no-reply box, or, with From empty, a refusal asking for a field |
| 12 | +// the page does not have. The pins: with no `to`, the test goes to the signed-in |
| 13 | +// admin's own address, read from their own `sys_user` row; an explicit `to` |
| 14 | +// still wins; and every refusal that remains carries the ADR-0112 envelope. |
| 15 | + |
| 16 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 17 | +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; |
| 18 | +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; |
| 19 | +import { SettingsService, mailSettingsManifest, registerSettingsRoutes } from '@objectstack/service-settings'; |
| 20 | +import { EmailServicePlugin } from './email-plugin.js'; |
| 21 | + |
| 22 | +const nm = vi.hoisted(() => ({ createTransport: vi.fn(), sendMail: vi.fn() })); |
| 23 | +vi.mock('nodemailer', () => ({ |
| 24 | + default: { createTransport: nm.createTransport }, |
| 25 | + createTransport: nm.createTransport, |
| 26 | +})); |
| 27 | + |
| 28 | +class MockHttp implements IHttpServer { |
| 29 | + routes = new Map<string, RouteHandler>(); |
| 30 | + private add(method: string, path: string, handler: RouteHandler) { |
| 31 | + this.routes.set(`${method} ${path}`, handler); |
| 32 | + } |
| 33 | + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } |
| 34 | + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } |
| 35 | + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } |
| 36 | + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } |
| 37 | + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } |
| 38 | + use() { return this as any; } |
| 39 | + listen() { return Promise.resolve(); } |
| 40 | + close() { return Promise.resolve(); } |
| 41 | + getInstance() { return null; } |
| 42 | +} |
| 43 | + |
| 44 | +const ADMIN_ID = 'usr_admin'; |
| 45 | +const ADMIN_EMAIL = 'admin@acme.test'; |
| 46 | +const FROM_EMAIL = 'no-reply@acme.test'; |
| 47 | + |
| 48 | +type UserRow = { id: string; email?: string | null }; |
| 49 | + |
| 50 | +/** The engine double: `update` and `findOne` held to ObjectQL's own dispatch contract. */ |
| 51 | +function fakeEngine(users: UserRow[], opts: { userReadFails?: boolean } = {}) { |
| 52 | + return { |
| 53 | + insert: vi.fn(async (_object: string, row: any) => ({ id: row?.id })), |
| 54 | + update: vi.fn(async (_object: string, data: any, options?: any) => { |
| 55 | + assertEngineUpdateDispatch(data, options); |
| 56 | + return undefined; |
| 57 | + }), |
| 58 | + find: vi.fn(async () => []), |
| 59 | + findOne: vi.fn(async (object: string, query: any) => { |
| 60 | + assertEngineFindOnePredicate(object, query); |
| 61 | + if (object !== 'sys_user') return null; |
| 62 | + if (opts.userReadFails) throw new Error('sys_user is unavailable'); |
| 63 | + return users.find((u) => u.id === query?.where?.id) ?? null; |
| 64 | + }), |
| 65 | + }; |
| 66 | +} |
| 67 | + |
| 68 | +function fakeCtx(services: Record<string, unknown>) { |
| 69 | + const hooks: Record<string, Array<() => Promise<void> | void>> = {}; |
| 70 | + return { |
| 71 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 72 | + getService: <T,>(name: string): T => { |
| 73 | + if (!(name in services)) throw new Error(`service '${name}' not registered`); |
| 74 | + return services[name] as T; |
| 75 | + }, |
| 76 | + registerService: (name: string, svc: unknown) => { services[name] = svc; }, |
| 77 | + hook: (name: string, fn: () => Promise<void> | void) => { (hooks[name] ??= []).push(fn); }, |
| 78 | + fire: async (name: string) => { for (const fn of hooks[name] ?? []) await fn(); }, |
| 79 | + }; |
| 80 | +} |
| 81 | + |
| 82 | +/** Boot the served `mail/test` door: real settings service + routes, the plugin's real action. */ |
| 83 | +async function serve(users: UserRow[], opts: { userReadFails?: boolean } = {}) { |
| 84 | + const settings = new SettingsService({ env: {} }); |
| 85 | + settings.registerManifest(mailSettingsManifest); |
| 86 | + await settings.setMany('mail', { provider: 'smtp', smtp_host: 'smtp.acme.test', from_email: FROM_EMAIL }); |
| 87 | + const engine = fakeEngine(users, opts); |
| 88 | + const services: Record<string, unknown> = { manifest: { register: () => {} }, objectql: engine, settings }; |
| 89 | + const ctx = fakeCtx(services); |
| 90 | + const plugin = new EmailServicePlugin(); |
| 91 | + await plugin.init(ctx as never); |
| 92 | + await plugin.start(ctx as never); |
| 93 | + await ctx.fire('kernel:ready'); |
| 94 | + |
| 95 | + const http = new MockHttp(); |
| 96 | + registerSettingsRoutes(http, settings, { |
| 97 | + contextFromRequest: () => ({ |
| 98 | + enforced: true, |
| 99 | + permissions: ['manage_platform_settings'], |
| 100 | + userId: ADMIN_ID, |
| 101 | + }), |
| 102 | + }); |
| 103 | + |
| 104 | + /** POST /api/settings/mail/test with the body the console sends. */ |
| 105 | + const pressSendTest = async (body: unknown) => { |
| 106 | + const handler = http.routes.get('POST /api/settings/:namespace/:actionId'); |
| 107 | + if (!handler) throw new Error('the settings action route is not mounted'); |
| 108 | + const captured: { status: number; body: any } = { status: 200, body: undefined }; |
| 109 | + const res: IHttpResponse = { |
| 110 | + json: vi.fn((data: any) => { captured.body = data; }) as any, |
| 111 | + send: vi.fn() as any, |
| 112 | + status: vi.fn((code: number) => { captured.status = code; return res; }) as any, |
| 113 | + header: vi.fn(() => res) as any, |
| 114 | + }; |
| 115 | + const req: IHttpRequest = { |
| 116 | + params: { namespace: 'mail', actionId: 'test' }, |
| 117 | + query: {}, |
| 118 | + body, |
| 119 | + headers: {}, |
| 120 | + method: 'POST', |
| 121 | + path: '/api/settings/mail/test', |
| 122 | + }; |
| 123 | + await handler(req, res); |
| 124 | + return captured; |
| 125 | + }; |
| 126 | + return { engine, settings, pressSendTest }; |
| 127 | +} |
| 128 | + |
| 129 | +/** The `to` of the one message nodemailer was handed, as text. */ |
| 130 | +const sentTo = () => JSON.stringify(nm.sendMail.mock.calls[0]?.[0]?.to); |
| 131 | + |
| 132 | +beforeEach(() => { |
| 133 | + nm.createTransport.mockReset(); |
| 134 | + nm.sendMail.mockReset(); |
| 135 | + nm.createTransport.mockImplementation(() => ({ sendMail: nm.sendMail, close: vi.fn() })); |
| 136 | + nm.sendMail.mockResolvedValue({ messageId: '<wire-1@smtp>', response: '250 2.0.0 Ok' }); |
| 137 | +}); |
| 138 | + |
| 139 | +describe('served mail/test — the recipient', () => { |
| 140 | + it('with no "to", sends to the signed-in admin\'s own address, not to From', async () => { |
| 141 | + const { engine, pressSendTest } = await serve([{ id: ADMIN_ID, email: ADMIN_EMAIL }]); |
| 142 | + |
| 143 | + const answer = await pressSendTest({}); |
| 144 | + |
| 145 | + expect(answer.status).toBe(200); |
| 146 | + expect(answer.body?.data).toMatchObject({ ok: true }); |
| 147 | + expect(nm.sendMail).toHaveBeenCalledTimes(1); |
| 148 | + expect(sentTo()).toContain(ADMIN_EMAIL); |
| 149 | + expect(sentTo()).not.toContain(FROM_EMAIL); |
| 150 | + // The read is the caller's OWN row, projected to the address alone. |
| 151 | + expect(engine.findOne).toHaveBeenCalledWith( |
| 152 | + 'sys_user', |
| 153 | + { where: { id: ADMIN_ID }, fields: ['email'] }, |
| 154 | + { context: expect.objectContaining({ isSystem: true }) }, |
| 155 | + ); |
| 156 | + }); |
| 157 | + |
| 158 | + it('control: an explicit "to" still wins, and no account is read', async () => { |
| 159 | + const { engine, pressSendTest } = await serve([{ id: ADMIN_ID, email: ADMIN_EMAIL }]); |
| 160 | + |
| 161 | + const answer = await pressSendTest({ to: 'ops@acme.test' }); |
| 162 | + |
| 163 | + expect(answer.status).toBe(200); |
| 164 | + expect(nm.sendMail).toHaveBeenCalledTimes(1); |
| 165 | + expect(sentTo()).toContain('ops@acme.test'); |
| 166 | + expect(sentTo()).not.toContain(ADMIN_EMAIL); |
| 167 | + expect(engine.findOne).not.toHaveBeenCalledWith('sys_user', expect.anything(), expect.anything()); |
| 168 | + }); |
| 169 | + |
| 170 | + it.each([ |
| 171 | + ['an account with no email address', [{ id: ADMIN_ID, email: null }], {}], |
| 172 | + ['an account row that is missing', [], {}], |
| 173 | + ['a failed account read', [{ id: ADMIN_ID, email: ADMIN_EMAIL }], { userReadFails: true }], |
| 174 | + ] as const)('refuses, sending nothing, for %s — ADR-0112 envelope', async (_name, users, opts) => { |
| 175 | + const { pressSendTest } = await serve([...users], opts); |
| 176 | + |
| 177 | + const answer = await pressSendTest({}); |
| 178 | + |
| 179 | + expect(answer.status).toBe(400); |
| 180 | + expect(answer.body?.success).toBe(false); |
| 181 | + expect(answer.body?.error?.code).toBe('SETTINGS_ACTION_FAILED'); |
| 182 | + expect(answer.body?.error?.details).toMatchObject({ ok: false, severity: 'error' }); |
| 183 | + expect(nm.sendMail).not.toHaveBeenCalled(); |
| 184 | + }); |
| 185 | + |
| 186 | + it('an in-process call with no "to" and no user refuses rather than mailing From', async () => { |
| 187 | + // Not a public door — the routes refuse an anonymous caller before any |
| 188 | + // action runs — but the trusted in-process path reaches the same handler. |
| 189 | + const { settings } = await serve([{ id: ADMIN_ID, email: ADMIN_EMAIL }]); |
| 190 | + |
| 191 | + const result = await settings.runAction('mail', 'test', {}, {}); |
| 192 | + |
| 193 | + expect(result).toMatchObject({ ok: false, severity: 'error' }); |
| 194 | + expect(nm.sendMail).not.toHaveBeenCalled(); |
| 195 | + }); |
| 196 | +}); |
0 commit comments