|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #14788 — `GET /auth/me/localization` is the ONE read face for the signed-in |
| 4 | +// user's language (maintainer ruling 2026-09-03, option D, which also retired |
| 5 | +// the never-produced `SessionUser.language` from the session contract). |
| 6 | +// `locale` resolves, in order: |
| 7 | +// |
| 8 | +// 1. the user's own `sys_user.locale` when set — accepted only when it passes |
| 9 | +// the column's OWN shape rule (`locale_bcp47_shape`, read off the |
| 10 | +// registered object; never a second parser here); |
| 11 | +// 2. the request's `Accept-Language` preference (`preferredLocaleFromHeader`, |
| 12 | +// the same parse the dispatcher feeds `execCtx.locale` from); |
| 13 | +// 3. the deployment default (`resolveLocalizationContext` — the |
| 14 | +// `localization.locale` cascade, floor `en-US`). |
| 15 | +// |
| 16 | +// Every case below pins one rung's precedence over the rungs beneath it by |
| 17 | +// supplying ALL the lower rungs at once — a case that only supplied the rung |
| 18 | +// under test would pass in a world where the other rungs were never consulted. |
| 19 | +// The malformed-column case is the ruling's "malformed ⇒ next rung, never |
| 20 | +// served" clause; the narrowed-rule case is the "no second parser" clause |
| 21 | +// (the answer moves with the registry's rule, not with a built-in regex). |
| 22 | +// |
| 23 | +// Before this card the resolver behind these endpoints assembled NO |
| 24 | +// localization at all — `execCtx.locale` here was always `undefined` and the |
| 25 | +// endpoint answered `locale: null` for every authenticated caller — so the |
| 26 | +// rung-3 cases are also the first pins that this surface answers a language |
| 27 | +// at all. `currency` / `timezone` are deliberately untouched by the ruling and |
| 28 | +// still come off that resolver (i.e. `null` in these fixtures); the response |
| 29 | +// SHAPE objectui reads is pinned unchanged. |
| 30 | + |
| 31 | +import { describe, it, expect } from 'vitest'; |
| 32 | +import { Hono } from 'hono'; |
| 33 | +import { registerCurrentUserEndpoints } from './current-user-endpoints'; |
| 34 | + |
| 35 | +const ME_LOCALIZATION = '/api/v1/auth/me/localization'; |
| 36 | +const USER = 'usr_lang'; |
| 37 | +const ORG = 'org_lang'; |
| 38 | + |
| 39 | +type Row = Record<string, any>; |
| 40 | + |
| 41 | +/** |
| 42 | + * The column's own rule, as the registry hands it to the endpoint — the |
| 43 | + * `validations[]` entry `sys-user.object.ts` declares (`SYS_USER_LOCALE_TAG_ |
| 44 | + * PATTERN`; byte-parity with service-messaging's read-side regex is pinned in |
| 45 | + * `recipient-locale-shape-parity.test.ts`). Fixture data here: what is under |
| 46 | + * test is that the endpoint EVALUATES whatever rule the registry declares. |
| 47 | + */ |
| 48 | +const LOCALE_SHAPE_RULE: Row = { |
| 49 | + type: 'format', |
| 50 | + name: 'locale_bcp47_shape', |
| 51 | + field: 'locale', |
| 52 | + regex: '^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$', |
| 53 | + severity: 'error', |
| 54 | + message: 'Locale must be a BCP-47 language tag, such as zh-CN or ja-JP.', |
| 55 | +}; |
| 56 | + |
| 57 | +interface MountOptions { |
| 58 | + /** `sys_user.locale` on the caller's row; `undefined` = column unset. */ |
| 59 | + storedLocale?: unknown; |
| 60 | + /** The `sys_user` object's `validations[]` as the registry reports them; `null` = no schema at all. */ |
| 61 | + rules?: Row[] | null; |
| 62 | + /** Tenant-scoped `localization.locale` `sys_setting` row value (rung 3). */ |
| 63 | + settingLocale?: string; |
| 64 | + /** Whether a session resolves at all. */ |
| 65 | + authenticated?: boolean; |
| 66 | + /** |
| 67 | + * Make the ENDPOINT's own `sys_user` read throw (the courtesy-never-fails- |
| 68 | + * the-answer case). The session resolver reads the same row first, once, |
| 69 | + * through core's fail-LOUD `tryFind` (#13279) — a throw there is a |
| 70 | + * different contract (the whole answer is refused), so only the read |
| 71 | + * after it fails here. |
| 72 | + */ |
| 73 | + failUserRead?: boolean; |
| 74 | +} |
| 75 | + |
| 76 | +function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, authenticated = true, failUserRead = false }: MountOptions = {}) { |
| 77 | + const reads: Array<{ object: string; opts: any }> = []; |
| 78 | + let sysUserReads = 0; |
| 79 | + const ql = { |
| 80 | + find: async (object: string, opts: any) => { |
| 81 | + reads.push({ object, opts }); |
| 82 | + if (object === 'sys_user') { |
| 83 | + if (failUserRead && ++sysUserReads > 1) throw new Error('sys_user unavailable'); |
| 84 | + return opts?.where?.id === USER ? [{ id: USER, email: 'lang@example.com', locale: storedLocale }] : []; |
| 85 | + } |
| 86 | + if (object === 'sys_setting' && settingLocale !== undefined) { |
| 87 | + return [{ namespace: 'localization', key: 'locale', value: settingLocale, scope: 'tenant' }]; |
| 88 | + } |
| 89 | + return []; |
| 90 | + }, |
| 91 | + // The registry view the endpoint reads the column's rule off. |
| 92 | + getSchema: (name: string) => (name === 'sys_user' && rules !== null ? { name: 'sys_user', validations: rules } : undefined), |
| 93 | + registry: { getAllApps: () => [], getAllObjects: () => [] }, |
| 94 | + }; |
| 95 | + const services: Record<string, unknown> = { |
| 96 | + auth: { |
| 97 | + api: { |
| 98 | + getSession: async () => (authenticated |
| 99 | + ? { user: { id: USER }, session: { activeOrganizationId: ORG } } |
| 100 | + : null), |
| 101 | + }, |
| 102 | + }, |
| 103 | + objectql: ql, |
| 104 | + metadata: { list: async () => [] }, |
| 105 | + }; |
| 106 | + const app = new Hono(); |
| 107 | + registerCurrentUserEndpoints({ |
| 108 | + rawApp: app, |
| 109 | + ctx: { |
| 110 | + logger: { debug() {}, warn() {} }, |
| 111 | + // Throws for an unclaimed slot, like the real kernel locator. |
| 112 | + getService: <T,>(name: string): T => { |
| 113 | + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); |
| 114 | + return services[name] as T; |
| 115 | + }, |
| 116 | + }, |
| 117 | + }); |
| 118 | + const get = async (acceptLanguage?: string) => { |
| 119 | + const res = await app.request(`http://localhost${ME_LOCALIZATION}`, { |
| 120 | + headers: acceptLanguage === undefined ? {} : { 'accept-language': acceptLanguage }, |
| 121 | + }); |
| 122 | + return { status: res.status, body: await res.json() as any }; |
| 123 | + }; |
| 124 | + return { get, reads }; |
| 125 | +} |
| 126 | + |
| 127 | +describe('/auth/me/localization — the signed-in user\'s language, three rungs (#14788)', () => { |
| 128 | + it('rung 1: the user\'s own sys_user.locale wins over the request AND the deployment default', async () => { |
| 129 | + const { get, reads } = mount({ storedLocale: 'zh-CN', settingLocale: 'fr-FR' }); |
| 130 | + const { status, body } = await get('ja-JP,ja;q=0.9,en;q=0.8'); |
| 131 | + expect(status).toBe(200); |
| 132 | + // The SHAPE objectui reads (`json?.locale`, plus `currency`) — unchanged. |
| 133 | + expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null }); |
| 134 | + // The identity row is read under a SYSTEM context by the caller's own |
| 135 | + // id (the `tryFind` shape core uses for the same row) — never routed |
| 136 | + // through the caller's own RLS wall. |
| 137 | + const userReads = reads.filter((r) => r.object === 'sys_user'); |
| 138 | + expect(userReads.length).toBeGreaterThan(0); |
| 139 | + for (const r of userReads) { |
| 140 | + expect(r.opts?.context?.isSystem).toBe(true); |
| 141 | + expect(r.opts?.where?.id).toBe(USER); |
| 142 | + } |
| 143 | + }); |
| 144 | + |
| 145 | + it('rung 2: with the column unset, the request\'s Accept-Language preference wins over the deployment default', async () => { |
| 146 | + const { get } = mount({ storedLocale: undefined, settingLocale: 'fr-FR' }); |
| 147 | + expect((await get('ja-JP,ja;q=0.9,en;q=0.8')).body.locale).toBe('ja-JP'); |
| 148 | + // An EMPTY column is "unset", not a preference for the empty string. |
| 149 | + const blank = mount({ storedLocale: ' ', settingLocale: 'fr-FR' }); |
| 150 | + expect((await blank.get('ja-JP')).body.locale).toBe('ja-JP'); |
| 151 | + }); |
| 152 | + |
| 153 | + it('rung 3: with neither, the deployment default answers — and it has a floor', async () => { |
| 154 | + const { get } = mount({ storedLocale: undefined, settingLocale: 'fr-FR' }); |
| 155 | + expect((await get()).body.locale).toBe('fr-FR'); |
| 156 | + // `*` is "any language" — no preference expressed, so rung 3 again. |
| 157 | + expect((await get('*')).body.locale).toBe('fr-FR'); |
| 158 | + // Nothing configured anywhere: the cascade's own floor, never `null`. |
| 159 | + const bare = mount({ storedLocale: undefined }); |
| 160 | + expect((await bare.get()).body.locale).toBe('en-US'); |
| 161 | + }); |
| 162 | + |
| 163 | + it('a malformed column value falls to the next rung — it is never served', async () => { |
| 164 | + // The shape a lossy producer leaves at rest (the hotcrm dead-letter |
| 165 | + // shape service-messaging refuses on the delivery side); and a value |
| 166 | + // a user typed before the write rule existed. |
| 167 | + for (const stored of ['Chinese (Simplified)', 'undefined', 'zh_CN!', 42]) { |
| 168 | + const { get } = mount({ storedLocale: stored, settingLocale: 'fr-FR' }); |
| 169 | + expect((await get('ja-JP')).body.locale, `stored=${String(stored)}`).toBe('ja-JP'); |
| 170 | + expect((await get()).body.locale, `stored=${String(stored)}, no header`).toBe('fr-FR'); |
| 171 | + } |
| 172 | + }); |
| 173 | + |
| 174 | + it('the column\'s OWN rule is what governs — narrow the registry\'s rule and the answer moves with it', async () => { |
| 175 | + // A second parser hard-coded here would keep accepting `zh-CN`. The |
| 176 | + // endpoint evaluates the rule the registry declares, so a narrower |
| 177 | + // rule refuses what the real rule accepts, and vice versa. |
| 178 | + const narrow = { ...LOCALE_SHAPE_RULE, regex: '^[a-z]{2}$' }; |
| 179 | + const refused = mount({ storedLocale: 'zh-CN', rules: [narrow] }); |
| 180 | + expect((await refused.get('ja-JP')).body.locale).toBe('ja-JP'); |
| 181 | + const accepted = mount({ storedLocale: 'zh', rules: [narrow] }); |
| 182 | + expect((await accepted.get('ja-JP')).body.locale).toBe('zh'); |
| 183 | + // A rule objectql would skip as malformed is skipped here too, which |
| 184 | + // leaves NO usable rule — the unverifiable case below, not a bypass. |
| 185 | + const broken = mount({ storedLocale: 'zh-CN', rules: [{ ...LOCALE_SHAPE_RULE, regex: '[' }] }); |
| 186 | + expect((await broken.get('ja-JP')).body.locale).toBe('ja-JP'); |
| 187 | + }); |
| 188 | + |
| 189 | + it('an unverifiable column (no shape rule declared) is not trusted — it falls through', async () => { |
| 190 | + // Fail direction pinned on purpose: a registry that declares no |
| 191 | + // `format` rule for `locale` cannot vouch for the stored value, and an |
| 192 | + // unvouched value reads as "unset", the same as a malformed one. |
| 193 | + const noRule = mount({ storedLocale: 'zh-CN', rules: [] }); |
| 194 | + expect((await noRule.get('ja-JP')).body.locale).toBe('ja-JP'); |
| 195 | + const noSchema = mount({ storedLocale: 'zh-CN', rules: null }); |
| 196 | + expect((await noSchema.get('ja-JP')).body.locale).toBe('ja-JP'); |
| 197 | + }); |
| 198 | + |
| 199 | + it('a failed identity read is a courtesy lost, never a failed answer', async () => { |
| 200 | + const { get, reads } = mount({ storedLocale: 'zh-CN', settingLocale: 'fr-FR', failUserRead: true }); |
| 201 | + const { status, body } = await get('ja-JP'); |
| 202 | + expect(status).toBe(200); |
| 203 | + expect(body.authenticated).toBe(true); |
| 204 | + expect(body.locale).toBe('ja-JP'); |
| 205 | + // Anti-vacuity: the endpoint's own read really was issued (and threw). |
| 206 | + expect(reads.filter((r) => r.object === 'sys_user').length).toBeGreaterThan(1); |
| 207 | + }); |
| 208 | + |
| 209 | + it('the unauthenticated answer is unchanged', async () => { |
| 210 | + const { get, reads } = mount({ authenticated: false, storedLocale: 'zh-CN' }); |
| 211 | + const { status, body } = await get('ja-JP'); |
| 212 | + expect(status).toBe(200); |
| 213 | + expect(body).toEqual({ authenticated: false }); |
| 214 | + // No identity row is read for an anonymous caller. |
| 215 | + expect(reads.filter((r) => r.object === 'sys_user')).toEqual([]); |
| 216 | + }); |
| 217 | +}); |
0 commit comments