|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #29891 — `POST /api/v1/email/send` answers 400 `VALIDATION_FAILED`, never |
| 5 | + * 500 `EMAIL_SEND_FAILED`, when a caller-supplied address (`to` / `cc` / |
| 6 | + * `bcc` / `replyTo`) carries a display name that is not a string. |
| 7 | + * |
| 8 | + * The refusal itself was already right (`formatCallerAddress`, #29152 / PR |
| 9 | + * #29250); what turned it into a 500 was the REJECTION ROW's writer |
| 10 | + * (`rawAddressText` inside `EmailService.recordRejectedMessage`), which |
| 11 | + * called `name.trim()` on the caller's raw value after the 400 was raised. |
| 12 | + * `email-send-door-bounds.test.ts` drives this door over a mail double, so it |
| 13 | + * never reaches that writer — this file drives it over the REAL |
| 14 | + * `EmailServicePlugin` (log provider) with `sys_email` persistence wired, the |
| 15 | + * stock showcase boot's shape, through the real `HonoHttpServer` transport. |
| 16 | + */ |
| 17 | + |
| 18 | +import { describe, it, expect, vi } from 'vitest'; |
| 19 | +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; |
| 20 | +import { EmailServicePlugin } from '@objectstack/plugin-email'; |
| 21 | +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; |
| 22 | +import type { IEmailService } from '@objectstack/spec/contracts'; |
| 23 | +import { RestServer } from './rest-server'; |
| 24 | +import { EMAIL_SEND_CAPABILITY } from './email-routes'; |
| 25 | + |
| 26 | +const ADMIN = { userId: 'usr_admin', systemPermissions: ['setup.access', EMAIL_SEND_CAPABILITY] }; |
| 27 | + |
| 28 | +/** |
| 29 | + * The slice of ObjectQL the plugin's `sys_email` persistence touches. `update` |
| 30 | + * routes through `assertEngineUpdateDispatch`, so the double cannot accept a |
| 31 | + * dispatch the real engine would refuse. |
| 32 | + */ |
| 33 | +function fakeEngine() { |
| 34 | + const rows: Array<{ object: string; row: any }> = []; |
| 35 | + return { |
| 36 | + rows, |
| 37 | + async insert(object: string, row: any) { rows.push({ object, row }); return { id: row.id }; }, |
| 38 | + async update(object: string, data: any, options?: any) { |
| 39 | + const dispatch = assertEngineUpdateDispatch(data, options); |
| 40 | + if (dispatch.kind !== 'by-id') throw new Error(`unexpected update dispatch: ${dispatch.kind}`); |
| 41 | + const target = rows.find((r) => r.object === object && r.row.id === dispatch.id); |
| 42 | + if (target) Object.assign(target.row, data); |
| 43 | + return { affected: target ? 1 : 0 }; |
| 44 | + }, |
| 45 | + async find() { return []; }, |
| 46 | + }; |
| 47 | +} |
| 48 | + |
| 49 | +async function bootEmail() { |
| 50 | + const engine = fakeEngine(); |
| 51 | + const services: Record<string, unknown> = { manifest: { register: () => {} }, objectql: engine }; |
| 52 | + const hooks: Record<string, Array<() => Promise<void> | void>> = {}; |
| 53 | + const ctx = { |
| 54 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 55 | + getService: <T,>(name: string): T => { |
| 56 | + if (!(name in services)) throw new Error(`service '${name}' not registered`); |
| 57 | + return services[name] as T; |
| 58 | + }, |
| 59 | + registerService: (name: string, svc: unknown) => { services[name] = svc; }, |
| 60 | + hook: (name: string, fn: () => Promise<void> | void) => { (hooks[name] ??= []).push(fn); }, |
| 61 | + }; |
| 62 | + const plugin = new EmailServicePlugin({ provider: 'log', defaultFrom: 'no-reply@acme.example' }); |
| 63 | + await plugin.init(ctx as never); |
| 64 | + await plugin.start(ctx as never); |
| 65 | + for (const fn of hooks['kernel:ready'] ?? []) await fn(); |
| 66 | + return { service: services.email as IEmailService, rows: engine.rows }; |
| 67 | +} |
| 68 | + |
| 69 | +async function boot() { |
| 70 | + const { service, rows } = await bootEmail(); |
| 71 | + const server = new HonoHttpServer(0); |
| 72 | + const rest = new RestServer( |
| 73 | + server as any, |
| 74 | + {} as any, |
| 75 | + { api: { requireAuth: false } } as any, |
| 76 | + undefined, // kernelManager |
| 77 | + undefined, // envRegistry |
| 78 | + undefined, // defaultEnvironmentIdProvider |
| 79 | + undefined, // authServiceProvider |
| 80 | + undefined, // objectQLProvider |
| 81 | + async () => service, // emailServiceProvider |
| 82 | + ); |
| 83 | + (rest as any).resolveExecCtx = async () => ADMIN; |
| 84 | + rest.registerRoutes(); |
| 85 | + server.installNotFoundSeam(); |
| 86 | + const post = async (body: unknown) => { |
| 87 | + const res: Response = await server.getRawApp().fetch(new Request('http://local/api/v1/email/send', { |
| 88 | + method: 'POST', |
| 89 | + headers: { 'content-type': 'application/json' }, |
| 90 | + body: JSON.stringify(body), |
| 91 | + })); |
| 92 | + return { status: res.status, body: (await res.json()) as any }; |
| 93 | + }; |
| 94 | + return { post, rows }; |
| 95 | +} |
| 96 | + |
| 97 | +const OK = 'ok@example.com'; |
| 98 | +const BASE = { subject: 'x', text: 'x' }; |
| 99 | + |
| 100 | +/** Every non-string display-name shape a JSON body can carry. */ |
| 101 | +const SHAPES: Array<[string, unknown]> = [ |
| 102 | + ['a number', 42], |
| 103 | + ['an object', { x: 1 }], |
| 104 | + ['an array', ['Ada']], |
| 105 | + ['a boolean', true], |
| 106 | +]; |
| 107 | + |
| 108 | +describe('POST /api/v1/email/send — a non-string recipient display name is the caller\'s 400 (#29891)', () => { |
| 109 | + for (const [what, name] of SHAPES) { |
| 110 | + for (const field of ['to', 'cc', 'bcc', 'replyTo'] as const) { |
| 111 | + it(`\`${field}\` with ${what} as the display name: 400 VALIDATION_FAILED naming the field, and a rejection row`, async () => { |
| 112 | + const { post, rows } = await boot(); |
| 113 | + const recipient = { address: OK, name }; |
| 114 | + const body = field === 'to' ? { ...BASE, to: recipient } : { ...BASE, to: 'someone@example.org', [field]: recipient }; |
| 115 | + const out = await post(body); |
| 116 | + |
| 117 | + expect(out.status).toBe(400); |
| 118 | + expect(out.body.code).toBe('VALIDATION_FAILED'); |
| 119 | + const what2 = field === 'replyTo' ? 'reply-to address' : 'recipient address'; |
| 120 | + expect(out.body.error).toBe(`invalid ${what2} in \`${field}\`: the display name of ${OK} must be a string`); |
| 121 | + |
| 122 | + // The rejection is still recorded — the writer no longer throws — and |
| 123 | + // records the bare address (the non-string name is left out; the |
| 124 | + // `error` column names it). |
| 125 | + expect(rows).toHaveLength(1); |
| 126 | + const row = rows[0].row; |
| 127 | + expect(row.status).toBe('failed'); |
| 128 | + expect(row.error).toBe(`rejected before delivery: VALIDATION_FAILED: ${out.body.error}`); |
| 129 | + const column = { to: 'to_addresses', cc: 'cc_addresses', bcc: 'bcc_addresses', replyTo: 'reply_to' }[field]; |
| 130 | + expect(row[column]).toBe(OK); |
| 131 | + }); |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + it('a `to` LIST entry with an object display name: 400, the row lists both entries', async () => { |
| 136 | + const { post, rows } = await boot(); |
| 137 | + const out = await post({ ...BASE, to: ['first@example.org', { address: OK, name: { x: 1 } }] }); |
| 138 | + |
| 139 | + expect(out.status).toBe(400); |
| 140 | + expect(out.body).toEqual({ |
| 141 | + code: 'VALIDATION_FAILED', |
| 142 | + error: `invalid recipient address in \`to\`: the display name of ${OK} must be a string`, |
| 143 | + }); |
| 144 | + expect(rows[0].row.to_addresses).toBe(`first@example.org, ${OK}`); |
| 145 | + }); |
| 146 | + |
| 147 | + it('a `null` display name is "no name": the send goes through (200), as an absent one does', async () => { |
| 148 | + const { post } = await boot(); |
| 149 | + const out = await post({ ...BASE, to: { address: OK, name: null } }); |
| 150 | + |
| 151 | + expect(out.status).toBe(200); |
| 152 | + expect(out.body.status).toBe('sent'); |
| 153 | + }); |
| 154 | + |
| 155 | + it('a string display name still sends (200) — the guard refuses only the non-string shapes', async () => { |
| 156 | + const { post } = await boot(); |
| 157 | + const out = await post({ ...BASE, to: { address: OK, name: 'Ada' } }); |
| 158 | + |
| 159 | + expect(out.status).toBe(200); |
| 160 | + expect(out.body.status).toBe('sent'); |
| 161 | + }); |
| 162 | +}); |
0 commit comments