diff --git a/src/backend/services/cms_service.ts b/src/backend/services/cms_service.ts index 0d2bdd86..c7c02181 100644 --- a/src/backend/services/cms_service.ts +++ b/src/backend/services/cms_service.ts @@ -1,5 +1,4 @@ import { errors, HttpContext } from '@adonisjs/core/http'; -import config from '@adonisjs/core/services/config'; import type { CmsConfig, Version, @@ -9,6 +8,7 @@ import type { AppUserInterface, UiConfig, } from '../../types'; +import { isValidLanguageTag } from '../../shared/language_helpers.js'; import { defineConfig } from '../define_config.js'; import { PreferenceService } from './preference_service.js'; import Config from '../models/config.js'; @@ -49,7 +49,8 @@ export class CmsService { active.data = newConfig; await active.save(); - const trackedConfig = config.get('cms') || {}; + const { default: adonisConfig } = await import('@adonisjs/core/services/config'); + const trackedConfig = adonisConfig.get('cms') || {}; // make sure we do not clobber the tracked config this.#config = { ...newConfig, ...trackedConfig }; @@ -76,7 +77,7 @@ export class CmsService { } public localeFromQuery(ctx: HttpContext): string { - return ctx.request.qs()['locale'] || this.sourceLocale; + return this.localeFromQueryParam(ctx.request.qs()['locale']); } public versionFromPath(ctx: HttpContext): Version { @@ -93,10 +94,22 @@ export class CmsService { public versionFromQuery(ctx: HttpContext): Version { return { apiVersion: 1, - locale: ctx.request.qs()['locale'] || this.sourceLocale, + locale: this.localeFromQueryParam(ctx.request.qs()['locale']), }; } + private localeFromQueryParam(queryLocale: string | undefined): string { + if (!queryLocale) return this.sourceLocale; + + // Query-param locales are not auth-gated like path locales (localeFromPath), but + // must still be valid BCP 47 tags. Underscore variants (e.g. zh_Hans) pass i18n + // string lookup yet crash IntlMessageFormat during formatting → 500 on RSS/API. + // Reject malformed tags here so downstream i18n/ICU never sees them. + if (!isValidLanguageTag(queryLocale)) throw errors.E_ROUTE_NOT_FOUND; + + return queryLocale; + } + protected getLanguage(locale: string | null): LanguageSpecification { const found = this.#config.languages.find( diff --git a/src/shared/language_helpers.ts b/src/shared/language_helpers.ts index 1f48f99d..d2343f2a 100644 --- a/src/shared/language_helpers.ts +++ b/src/shared/language_helpers.ts @@ -2,6 +2,16 @@ import type { LanguageSpecification, LocaleItem } from '../types.js'; export const LANGUAGE_LABEL_SEPARATOR = /\s*-\s*|\s*\|\s*/; +/** Whether tag is a well-formed IETF BCP 47 language tag (hyphen-separated). */ +export function isValidLanguageTag(tag: string): boolean { + try { + new Intl.Locale(tag); + return true; + } catch { + return false; + } +} + /** Name, native name, and locale from a language specification. */ export function parseLanguageSpecification(spec: LanguageSpecification): LocaleItem { const { language, locale, languageDirection } = spec; diff --git a/tests/unit/audioRule.spec.ts b/tests/unit/audioRule.spec.ts index 8e7efe85..018f6208 100644 --- a/tests/unit/audioRule.spec.ts +++ b/tests/unit/audioRule.spec.ts @@ -4,6 +4,10 @@ import audioRule from '../../src/backend/validators/audio_rule.js'; import { Audio } from '../../src/types'; test.describe('Audio Rule Validator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test('should validate valid audio objects with .mp3 extension', async () => { const validAudios: Audio[] = [ { url: 'https://example.com/audio.mp3', length: 120 }, diff --git a/tests/unit/bundleService.spec.ts b/tests/unit/bundleService.spec.ts index 2fd72fa8..a7fca5c1 100644 --- a/tests/unit/bundleService.spec.ts +++ b/tests/unit/bundleService.spec.ts @@ -4,6 +4,10 @@ import { complexFields, nestedFields, simpleFields } from '../mocks.js'; import vine from '@vinejs/vine'; test.describe('Bundle builder', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test('Identify junk', () => { // arrange const service = new BundleService(simpleFields); @@ -66,6 +70,10 @@ test.describe('Bundle builder', () => { }); test.describe('Bundle updater', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test('updates bundle with new changes', async () => { // arrange const service = new BundleService(simpleFields); @@ -228,6 +236,10 @@ test.describe('Bundle updater', () => { }); test.describe('Bundle validator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test('it can validate a complex object', async () => { // arrange const objectSpec = [ diff --git a/tests/unit/cms_service.spec.ts b/tests/unit/cms_service.spec.ts new file mode 100644 index 00000000..cb3a65d9 --- /dev/null +++ b/tests/unit/cms_service.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import type { HttpContext } from '@adonisjs/core/http'; + +import { CmsService } from '../../src/backend/services/cms_service'; + +function mockContext(query: Record): HttpContext { + return { + request: { + qs: () => query, + }, + } as HttpContext; +} + +test.describe('CmsService query locale', () => { + const cms = CmsService.default(); + + test('localeFromQuery returns sourceLocale when param is absent', () => { + expect(cms.localeFromQuery(mockContext({}))).toBe('en'); + expect(cms.localeFromQuery(mockContext({ locale: undefined }))).toBe('en'); + }); + + test('localeFromQuery returns valid tags as-is', () => { + expect(cms.localeFromQuery(mockContext({ locale: 'en' }))).toBe('en'); + expect(cms.localeFromQuery(mockContext({ locale: 'zh' }))).toBe('zh'); + expect(cms.localeFromQuery(mockContext({ locale: 'zh-Hans' }))).toBe('zh-Hans'); + expect(cms.localeFromQuery(mockContext({ locale: 'en-US' }))).toBe('en-US'); + }); + + test('localeFromQuery throws E_ROUTE_NOT_FOUND for invalid tags', () => { + for (const locale of ['zh_Hans', 'zh_CN']) { + expect(() => cms.localeFromQuery(mockContext({ locale }))).toThrow(); + try { + cms.localeFromQuery(mockContext({ locale })); + } catch (error) { + expect((error as { code: string; status: number }).code).toBe('E_ROUTE_NOT_FOUND'); + expect((error as { code: string; status: number }).status).toBe(404); + } + } + }); + + test('versionFromQuery mirrors localeFromQuery behavior', () => { + expect(cms.versionFromQuery(mockContext({}))).toEqual({ + apiVersion: 1, + locale: 'en', + }); + + expect(cms.versionFromQuery(mockContext({ locale: 'zh' }))).toEqual({ + apiVersion: 1, + locale: 'zh', + }); + + expect(() => cms.versionFromQuery(mockContext({ locale: 'zh_Hans' }))).toThrow(); + }); +}); diff --git a/tests/unit/course.spec.ts b/tests/unit/course.spec.ts index bdce219a..3cb20ed6 100644 --- a/tests/unit/course.spec.ts +++ b/tests/unit/course.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import vine from '@vinejs/vine'; import { CourseValidator } from '../../src/backend/validators/course.js'; const validBundle = () => ({ @@ -13,6 +14,10 @@ const validBundle = () => ({ }); test.describe('CourseValidator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test('accepts bundle without imageUrl', async () => { const validator = new CourseValidator(); const result = await validator.validate(validBundle()); diff --git a/tests/unit/dateRangeRule.spec.ts b/tests/unit/dateRangeRule.spec.ts index b6e01a5c..5a039795 100644 --- a/tests/unit/dateRangeRule.spec.ts +++ b/tests/unit/dateRangeRule.spec.ts @@ -3,6 +3,10 @@ import vine from '@vinejs/vine'; import dateRangeRule from '../../src/backend/validators/date_range_rule.js'; test.describe('Date Range Rule Validator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test('should validate valid date ranges', async () => { const validDateRanges = [ '2027-01-08T07:30:00.000Z|2027-01-15T07:30:00.000Z', diff --git a/tests/unit/drop.spec.ts b/tests/unit/drop.spec.ts index a0ff217e..aef36423 100644 --- a/tests/unit/drop.spec.ts +++ b/tests/unit/drop.spec.ts @@ -1,8 +1,13 @@ import { test, expect } from '@playwright/test'; +import vine from '@vinejs/vine'; import DropValidator from '../../src/backend/validators/drop.js'; import { dropBundleFields } from '../mocks.js'; test.describe('Drop Validator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test.describe('Draft mode (isPublished = false)', () => { test('should validate valid draft data with all fields', async () => { const validator = new DropValidator(false, dropBundleFields); diff --git a/tests/unit/invitation.spec.ts b/tests/unit/invitation.spec.ts index 5200304f..d5369a94 100644 --- a/tests/unit/invitation.spec.ts +++ b/tests/unit/invitation.spec.ts @@ -18,6 +18,10 @@ function createMockHttpContext(inputs: Record): HttpContext { } test.describe('Invitation validator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test.describe('Draft Schema', () => { test('allows all fields to be optional', async () => { const data = { diff --git a/tests/unit/language_helpers.spec.ts b/tests/unit/language_helpers.spec.ts new file mode 100644 index 00000000..8060e203 --- /dev/null +++ b/tests/unit/language_helpers.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +import { languages } from '../../src/frontend/settings/languages/languages'; +import { isValidLanguageTag } from '../../src/shared/language_helpers'; + +test.describe('isValidLanguageTag', () => { + test('accepts short and extended BCP 47 tags', () => { + expect(isValidLanguageTag('en')).toBe(true); + expect(isValidLanguageTag('zh')).toBe(true); + expect(isValidLanguageTag('zh-Hans')).toBe(true); + expect(isValidLanguageTag('en-US')).toBe(true); + }); + + test('accepts exotic, well-formed tags', () => { + expect(isValidLanguageTag('qqq')).toBe(true); + expect(isValidLanguageTag('xx-Sw')).toBe(true); + }); + + test('rejects underscore-style tags and empty string', () => { + expect(isValidLanguageTag('zh_Hans')).toBe(false); + expect(isValidLanguageTag('zh_CN')).toBe(false); + expect(isValidLanguageTag('')).toBe(false); + }); + + // Kit language picker defaults — syntax only, not per-client config.languages. + test('kit language catalog locales are valid BCP 47 tags', () => { + for (const { locale } of languages) { + expect(isValidLanguageTag(locale), `invalid locale: ${locale}`).toBe(true); + } + }); +}); diff --git a/tests/unit/page.spec.ts b/tests/unit/page.spec.ts index 33d1f7c9..9a496179 100644 --- a/tests/unit/page.spec.ts +++ b/tests/unit/page.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import vine from '@vinejs/vine'; import PageValidator from '../../src/backend/validators/page.js'; import type { HttpContext } from '@adonisjs/core/http'; @@ -12,6 +13,10 @@ function createMockHttpContext(inputs: Record): HttpContext { } test.describe('Page Validator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test.describe('Draft Schema', () => { test('requires title', async () => { const data = { diff --git a/tests/unit/videoRule.spec.ts b/tests/unit/videoRule.spec.ts index fd06cbde..7c662404 100644 --- a/tests/unit/videoRule.spec.ts +++ b/tests/unit/videoRule.spec.ts @@ -4,6 +4,10 @@ import videoRule from '../../src/backend/validators/video_rule.js'; import { Video } from '../../src/types'; test.describe('Video Rule Validator', () => { + test.beforeEach(() => { + vine.convertEmptyStringsToNull = false; + }); + test('should validate valid video objects with .mp4 extension', async () => { const validVideos: Video[] = [ { url: 'https://example.com/video.mp4' },