|
8 | 8 | * caller's own `Accept-Language` first (only when it names a locale in |
9 | 9 | * `AUTH_EMAIL_TEMPLATE_LOCALES`), and the deployment default second. The |
10 | 10 | * 2026-08-13 ruling had made the deployment default the whole answer and |
11 | | - * rejected `Accept-Language` outright. `sys_user.locale` exists since #13881 |
12 | | - * (2026-09-01) but auth mail does not read it yet — that is #14641's rung, |
13 | | - * and this file asserts nothing about it. The ruling text of record lives on |
14 | | - * `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the |
15 | | - * request rung's own cases are the last describe block in this file. |
| 11 | + * rejected `Accept-Language` outright. #14762 then added the rung ABOVE both, |
| 12 | + * per the #14788 option-D ruling of 2026-09-03: the recipient's own |
| 13 | + * `sys_user.locale` (#13881) when the account holds one. Invitations keep the |
| 14 | + * deployment rung — an invitee has no row until acceptance (#14641) — and |
| 15 | + * this file pins that abstention too. The ruling text of record lives on |
| 16 | + * `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` / |
| 17 | + * `emailLocaleArg`; the request rung's own cases and the stored rung's are the |
| 18 | + * last two describe blocks in this file. |
16 | 19 | * |
17 | 20 | * Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`, |
18 | 21 | * so `EmailService`'s ladder always resolved `en-US` and the localized rows |
@@ -456,3 +459,164 @@ describe('#14319 — authEmailLocaleFromRequest', () => { |
456 | 459 | expect(authEmailLocaleFromRequest(hostile)).toBeUndefined(); |
457 | 460 | }); |
458 | 461 | }); |
| 462 | + |
| 463 | +// ── #14762 — the stored rung ─────────────────────────────────────────────── |
| 464 | + |
| 465 | +/** |
| 466 | + * #14788 was ruled option D on 2026-09-03 (maintainer verbatim 「同意」): |
| 467 | + * |
| 468 | + * `sys_user.locale` when set → the request's `Accept-Language` → the |
| 469 | + * deployment default. |
| 470 | + * |
| 471 | + * The recorded reasoning: a value the user chose is stronger evidence of |
| 472 | + * intent than the `Accept-Language` the browser just sent. The case that |
| 473 | + * forces the order is the send where the requester is NOT the recipient — an |
| 474 | + * admin-initiated password reset (`admin-import-users.ts` calls |
| 475 | + * `requestPasswordReset`), where the request rung would otherwise stamp the |
| 476 | + * ADMIN's browser language onto the USER's mail. |
| 477 | + * |
| 478 | + * ⚠️ Every rung below is given a DIFFERENT locale, so each assertion names |
| 479 | + * exactly one rung. A pin that set two rungs to the same tag would pass |
| 480 | + * whichever produced the value. |
| 481 | + */ |
| 482 | +async function driveResetWith(opts: { |
| 483 | + stored?: unknown; |
| 484 | + header?: string; |
| 485 | + deployment?: string; |
| 486 | + engine?: unknown; |
| 487 | +}) { |
| 488 | + const reads: any[] = []; |
| 489 | + const dataEngine = |
| 490 | + opts.engine ?? |
| 491 | + { |
| 492 | + async findOne(object: string, query: any) { |
| 493 | + reads.push({ object, query }); |
| 494 | + return object === 'sys_user' ? { locale: opts.stored } : null; |
| 495 | + }, |
| 496 | + }; |
| 497 | + const { capturedConfig, sent } = await boot(opts.deployment, { dataEngine } as never); |
| 498 | + const request = |
| 499 | + opts.header === undefined |
| 500 | + ? undefined |
| 501 | + : new Request('http://x/any', { headers: { 'accept-language': opts.header } }); |
| 502 | + await capturedConfig.emailAndPassword.sendResetPassword( |
| 503 | + { user: USER, url: 'http://x/reset', token: 't' }, |
| 504 | + request, |
| 505 | + ); |
| 506 | + return { sent, reads }; |
| 507 | +} |
| 508 | + |
| 509 | +describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', () => { |
| 510 | + const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED; |
| 511 | + beforeEach(() => { |
| 512 | + vi.clearAllMocks(); |
| 513 | + process.env.OS_MCP_SERVER_ENABLED = 'false'; |
| 514 | + }); |
| 515 | + afterEach(() => { |
| 516 | + if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED; |
| 517 | + else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv; |
| 518 | + }); |
| 519 | + |
| 520 | + it('the stored column outranks BOTH the request header and the deployment default', async () => { |
| 521 | + // Three rungs, three distinct locales — the pin the card names. |
| 522 | + const { sent } = await driveResetWith({ |
| 523 | + stored: 'ja-JP', |
| 524 | + header: 'zh-CN', |
| 525 | + deployment: 'es-ES', |
| 526 | + }); |
| 527 | + expect(sent).toHaveLength(1); |
| 528 | + expect(sent[0].locale).toBe('ja-JP'); |
| 529 | + }); |
| 530 | + |
| 531 | + it('with no stored column the request rung answers — #14319 intact', async () => { |
| 532 | + const { sent } = await driveResetWith({ stored: null, header: 'zh-CN', deployment: 'es-ES' }); |
| 533 | + expect(sent[0].locale).toBe('zh-CN'); |
| 534 | + }); |
| 535 | + |
| 536 | + it('with neither stored nor request, the deployment rung answers — #8195 intact', async () => { |
| 537 | + const { sent } = await driveResetWith({ stored: null, deployment: 'es-ES' }); |
| 538 | + expect(sent[0].locale).toBe('es-ES'); |
| 539 | + }); |
| 540 | + |
| 541 | + it('with nothing at all, NO locale is named and the documented en-US floor applies', async () => { |
| 542 | + const { sent } = await driveResetWith({ stored: null }); |
| 543 | + expect(sent[0].locale).toBeUndefined(); |
| 544 | + // The ladder's contract is written against an ABSENT key, not an explicit |
| 545 | + // `undefined` — see the #8195 case above. |
| 546 | + expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false); |
| 547 | + }); |
| 548 | + |
| 549 | + it('reads the column off the recipient row by id, projected, under a system context', async () => { |
| 550 | + // Establishes which rung produced the value above. |
| 551 | + const { reads } = await driveResetWith({ stored: 'ja-JP', header: 'zh-CN' }); |
| 552 | + const userRead = reads.find((r) => r.object === 'sys_user'); |
| 553 | + expect(userRead, 'no sys_user read happened').toBeTruthy(); |
| 554 | + expect(userRead.query.where).toEqual({ id: 'u1' }); |
| 555 | + expect(userRead.query.fields).toEqual(['locale']); |
| 556 | + expect(userRead.query.context?.isSystem).toBe(true); |
| 557 | + }); |
| 558 | + |
| 559 | + it('maps a stored catalog language onto the row spelling', async () => { |
| 560 | + // `zh` is a legal BCP-47 tag and a legal value of the column; auth rows |
| 561 | + // are keyed `zh-CN` and matched exactly. |
| 562 | + const { sent } = await driveResetWith({ stored: 'zh', deployment: 'es-ES' }); |
| 563 | + expect(sent[0].locale).toBe('zh-CN'); |
| 564 | + }); |
| 565 | + |
| 566 | + it('passes a stored tag we ship no row for through, unlike the request rung', async () => { |
| 567 | + // The asymmetry is deliberate: the request rung REQUIRES a hit in |
| 568 | + // AUTH_EMAIL_TEMPLATE_LOCALES because "a per-request header is a weaker |
| 569 | + // claim than a deployment's declaration". A column the user set for |
| 570 | + // themselves is not that weak claim — a tenant overlaying en-GB rows must |
| 571 | + // be able to ask for them. |
| 572 | + const { sent } = await driveResetWith({ stored: 'en-GB', header: 'zh-CN', deployment: 'es-ES' }); |
| 573 | + expect(sent[0].locale).toBe('en-GB'); |
| 574 | + // Same tag through the request rung is refused, unchanged. |
| 575 | + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en-GB' } })).toBeUndefined(); |
| 576 | + }); |
| 577 | + |
| 578 | + it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => { |
| 579 | + // hotcrm's measured dead-letter shape. `normalizeRecipientLocale` — the |
| 580 | + // messaging seam's normalizer, reused rather than re-written — refuses it. |
| 581 | + for (const junk of ['undefined', 'null', '', ' ', 42, {}]) { |
| 582 | + const { sent } = await driveResetWith({ stored: junk, deployment: 'es-ES' }); |
| 583 | + expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('es-ES'); |
| 584 | + } |
| 585 | + }); |
| 586 | + |
| 587 | + it('a failing recipient read never blocks the mail', async () => { |
| 588 | + const { sent } = await driveResetWith({ |
| 589 | + engine: { async findOne() { throw new Error('sys_user unavailable'); } }, |
| 590 | + header: 'zh-CN', |
| 591 | + deployment: 'es-ES', |
| 592 | + }); |
| 593 | + expect(sent).toHaveLength(1); |
| 594 | + expect(sent[0].locale).toBe('zh-CN'); |
| 595 | + }); |
| 596 | + |
| 597 | + it('an auth manager with no data engine keeps exactly the two-rung behaviour', async () => { |
| 598 | + const { capturedConfig, sent } = await boot('es-ES'); |
| 599 | + await capturedConfig.emailAndPassword.sendResetPassword( |
| 600 | + { user: USER, url: 'http://x/reset', token: 't' }, |
| 601 | + new Request('http://x/any', { headers: { 'accept-language': 'zh-CN' } }), |
| 602 | + ); |
| 603 | + expect(sent[0].locale).toBe('zh-CN'); |
| 604 | + }); |
| 605 | + |
| 606 | + it('the INVITATION send is untouched — its rung is #14641\'s', async () => { |
| 607 | + // Scope fence, asserted rather than described: an invitee has no sys_user |
| 608 | + // row until acceptance, so this send still names the deployment rung even |
| 609 | + // when a row for that address would have carried a locale. |
| 610 | + const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } }; |
| 611 | + const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never); |
| 612 | + const org = capturedConfig.plugins.find((p: any) => p.id === 'organization'); |
| 613 | + await org._opts.sendInvitationEmail({ |
| 614 | + email: 'invitee@example.com', |
| 615 | + invitation: { id: 'inv1', organizationId: 'o1', role: 'member' }, |
| 616 | + organization: { name: 'Northwind' }, |
| 617 | + inviter: { user: { email: 'dana@example.com', name: 'Dana' } }, |
| 618 | + }); |
| 619 | + expect(sent[0].template).toBe('auth.invitation'); |
| 620 | + expect(sent[0].locale).toBe('es-ES'); |
| 621 | + }); |
| 622 | +}); |
0 commit comments