diff --git a/.changeset/30335-storage-test-message-locale.md b/.changeset/30335-storage-test-message-locale.md new file mode 100644 index 00000000000..0953a201223 --- /dev/null +++ b/.changeset/30335-storage-test-message-locale.md @@ -0,0 +1,10 @@ +--- +'@objectstack/service-storage': patch +--- + +fix(service-storage): Setup → Storage → Test connection answers in the caller's language + +Clause-②: no +Metadata-protocol: none + +The `storage/test` settings action writes its `message` in the language the settings action route hands its handler as `locale` (`Accept-Language`, else `?locale`), the same source the mail test notice reads. It covers the sentences the product writes: the success sentence (which still names the adapter), the refusal when the probe read back is not the one written, and the refusal for S3 settings missing a bucket or a region. An adapter's own error text stays verbatim: alone in English, as before; in another language behind a lead-in saying the connection test failed. Every language on the platform locale table is served (`en`, `zh-CN`, `zh-Hant`, `es-ES`, `ar`, `fr`, `pt-BR`, `ru`, `de`, `ja-JP`, `ko`), the caller's code read through `platformLocaleTagOf` and then shortened one subtag at a time; an absent locale, or one the table does not name, is answered in English exactly as before. `ok`, `severity`, the probe and its cleanup, and the operator log lines are unchanged. diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 38bfdf413d1..59063b6c259 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -87,6 +87,7 @@ import { mayActIrreversibly } from '@objectstack/platform-objects/system'; import { FILE_REFERENCES_MIGRATION_ID } from '@objectstack/spec/system'; import { inProcessSessionReadInput } from '@objectstack/types'; import { SwappableStorageService } from './swappable-storage-service.js'; +import { S3SettingsIncompleteError, storageTestCopy } from './storage-test-copy.js'; import { resolveStorageTarget, needsStorageSwap, @@ -286,7 +287,7 @@ export class StorageServicePlugin implements Plugin { const bucket = values.s3_bucket as string | undefined; const region = values.s3_region as string | undefined; if (!bucket || !region) { - throw new Error('StorageServicePlugin: S3 adapter requires s3_bucket and s3_region'); + throw new S3SettingsIncompleteError(); } const opts: S3StorageAdapterOptions = { bucket, @@ -814,7 +815,11 @@ export class StorageServicePlugin implements Plugin { // Register the live `storage/test` probe handler. if (typeof settings.registerAction === 'function' && this.storage) { const proxy = this.storage; - settings.registerAction('storage', 'test', async ({ values, payload }: any) => { + settings.registerAction('storage', 'test', async ({ values, payload, locale }: any) => { + // [#30335] The answer is the toast the administrator reads: written + // in the caller's display language, which the settings action + // route hands over as `locale` (English when none is given). + const copy = storageTestCopy(typeof locale === 'string' ? locale : undefined); // Merge the (possibly unsaved) form state posted as // `payload.values` over the persisted snapshot so an operator // can validate edits before hitting "Save". Matches the @@ -851,7 +856,10 @@ export class StorageServicePlugin implements Plugin { try { target = await this.buildAdapterFromValues(merged); } catch (err: any) { - return { ok: false, severity: 'error', message: err?.message ?? String(err) }; + const message = err instanceof S3SettingsIncompleteError + ? copy.s3SettingsIncomplete + : copy.failed(err?.message ?? String(err)); + return { ok: false, severity: 'error', message }; } } @@ -900,18 +908,18 @@ export class StorageServicePlugin implements Plugin { // unguarded delete, whose failure IS a probe failure the // operator must be told about, and no path deletes twice. await removeProbeObject(); - return { ok: false, severity: 'error', message: 'Probe download did not match upload.' }; + return { ok: false, severity: 'error', message: copy.downloadMismatch }; } await target.delete(probeKey); const adapter = String(merged.adapter ?? this.options.adapter ?? 'local'); return { ok: true, severity: 'info', - message: `Storage round-trip succeeded (adapter=${adapter}).`, + message: copy.succeeded(adapter), }; } catch (err: any) { await removeProbeObject(); - return { ok: false, severity: 'error', message: err?.message ?? String(err) }; + return { ok: false, severity: 'error', message: copy.failed(err?.message ?? String(err)) }; } }); ctx.logger.info('StorageServicePlugin: registered settings action storage/test'); diff --git a/packages/services/service-storage/src/storage-test-copy.ts b/packages/services/service-storage/src/storage-test-copy.ts new file mode 100644 index 00000000000..8d01131d3c5 --- /dev/null +++ b/packages/services/service-storage/src/storage-test-copy.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30335] The sentences Setup → Storage → 测试连接 (`storage/test`) answers + * with, one entry per language on the platform locale table. + * + * The console prints the action's `message` as the toast, bare, so the + * sentence is the administrator's whole answer. The settings action route + * hands the handler the caller's display language (`Accept-Language`, else + * `?locale`) as the input's `locale`; an absent locale, or one with no entry + * here, is answered in English, the sentence every caller got before. + * + * The table carries every language on the platform locale table + * (`@objectstack/spec/system` `PLATFORM_LOCALES`). The four entries this package + * served first keep their keys (`zh-CN`, `ja-JP`, `es-ES`); the caller's code is + * read through the table's own reader, `platformLocaleTagOf`, so a stored + * alias (`zh-TW`, `pt`, `en-US`) and a table tag (`zh-Hans`, `ja`) both find + * their entry. A code the table does not name is shortened one subtag at a + * time (RFC 4647 lookup: `fr-CA` → `fr`, `zh-Hant-TW` → `zh-Hant`); nothing + * left ⇒ English. + * + * Only the sentences the product writes live here. An adapter's own error + * text (the S3 SDK's, the file system's) is passed through verbatim: in + * English on its own, as it always was; in another language behind a lead-in + * that says the connection test failed, so the reader knows what the + * untranslated part is. The operator log lines stay English. + */ + +import { platformLocaleTagOf, type PlatformLocaleTag } from '@objectstack/spec/system'; + +/** The English refusal for S3 settings missing a bucket or a region. */ +export const S3_SETTINGS_INCOMPLETE_MESSAGE = + 'StorageServicePlugin: S3 adapter requires s3_bucket and s3_region'; + +/** + * Thrown when the `storage` settings name the S3 adapter without a bucket or a + * region. Its message is the English refusal the operator log has always + * carried; the `storage/test` answer reads the type to write the refusal in the + * caller's language instead. + */ +export class S3SettingsIncompleteError extends Error { + constructor() { + super(S3_SETTINGS_INCOMPLETE_MESSAGE); + this.name = 'S3SettingsIncompleteError'; + } +} + +export interface StorageTestCopy { + /** The probe object was written, read back identical and deleted. */ + succeeded(adapter: string): string; + /** The probe object read back is not the one written. */ + downloadMismatch: string; + /** The S3 adapter was chosen without a bucket or a region. */ + s3SettingsIncomplete: string; + /** The adapter refused; `reason` is its own text, verbatim. */ + failed(reason: string): string; +} + +/** The table's keys: the platform locale table's languages, the first four under their older codes. */ +export type StorageTestCopyLocale = + 'en' | 'zh-CN' | 'zh-Hant' | 'es-ES' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ja-JP' | 'ko'; + +export const STORAGE_TEST_COPY: Record = { + en: { + succeeded: (adapter) => `Storage round-trip succeeded (adapter=${adapter}).`, + downloadMismatch: 'Probe download did not match upload.', + s3SettingsIncomplete: S3_SETTINGS_INCOMPLETE_MESSAGE, + failed: (reason) => reason, + }, + 'zh-CN': { + succeeded: (adapter) => `存储连接测试成功(适配器:${adapter})。`, + downloadMismatch: '探测文件下载后的内容与上传的不一致。', + s3SettingsIncomplete: 'S3 适配器需要填写存储桶(s3_bucket)和区域(s3_region)。', + failed: (reason) => `存储连接测试失败:${reason}`, + }, + 'zh-Hant': { + succeeded: (adapter) => `儲存空間連線測試成功(轉接器:${adapter})。`, + downloadMismatch: '探測檔案下載後的內容與上傳的不一致。', + s3SettingsIncomplete: 'S3 轉接器需要填寫儲存貯體(s3_bucket)和區域(s3_region)。', + failed: (reason) => `儲存空間連線測試失敗:${reason}`, + }, + 'ja-JP': { + succeeded: (adapter) => `ストレージの接続テストに成功しました(アダプター: ${adapter})。`, + downloadMismatch: 'プローブファイルのダウンロード内容がアップロードした内容と一致しません。', + s3SettingsIncomplete: 'S3 アダプターにはバケット(s3_bucket)とリージョン(s3_region)の指定が必要です。', + failed: (reason) => `ストレージの接続テストに失敗しました: ${reason}`, + }, + ko: { + succeeded: (adapter) => `스토리지 연결 테스트에 성공했습니다(어댑터: ${adapter}).`, + downloadMismatch: '다운로드한 테스트 파일이 업로드한 파일과 일치하지 않습니다.', + s3SettingsIncomplete: 'S3 어댑터에는 버킷(s3_bucket)과 리전(s3_region)이 필요합니다.', + failed: (reason) => `스토리지 연결 테스트에 실패했습니다: ${reason}`, + }, + 'es-ES': { + succeeded: (adapter) => `La prueba de conexión del almacenamiento se ha completado correctamente (adaptador: ${adapter}).`, + downloadMismatch: 'El archivo de prueba descargado no coincide con el que se subió.', + s3SettingsIncomplete: 'El adaptador S3 necesita un bucket (s3_bucket) y una región (s3_region).', + failed: (reason) => `La prueba de conexión del almacenamiento ha fallado: ${reason}`, + }, + ar: { + succeeded: (adapter) => `نجح اختبار الاتصال بالتخزين (المحوّل: ${adapter}).`, + downloadMismatch: 'محتوى ملف الاختبار الذي تم تنزيله لا يطابق الملف الذي تم رفعه.', + s3SettingsIncomplete: 'يتطلب محوّل S3 تحديد الحاوية (s3_bucket) والمنطقة (s3_region).', + failed: (reason) => `فشل اختبار الاتصال بالتخزين: ${reason}`, + }, + fr: { + succeeded: (adapter) => `Le test de connexion au stockage a réussi (adaptateur : ${adapter}).`, + downloadMismatch: 'Le fichier de test téléchargé ne correspond pas à celui qui a été envoyé.', + s3SettingsIncomplete: 'L’adaptateur S3 nécessite un bucket (s3_bucket) et une région (s3_region).', + failed: (reason) => `Le test de connexion au stockage a échoué : ${reason}`, + }, + 'pt-BR': { + succeeded: (adapter) => `O teste de conexão com o armazenamento foi concluído com sucesso (adaptador: ${adapter}).`, + downloadMismatch: 'O arquivo de teste baixado não corresponde ao que foi enviado.', + s3SettingsIncomplete: 'O adaptador S3 exige um bucket (s3_bucket) e uma região (s3_region).', + failed: (reason) => `O teste de conexão com o armazenamento falhou: ${reason}`, + }, + ru: { + succeeded: (adapter) => `Проверка подключения к хранилищу прошла успешно (адаптер: ${adapter}).`, + downloadMismatch: 'Скачанный тестовый файл не совпадает с загруженным.', + s3SettingsIncomplete: 'Для адаптера S3 нужно указать бакет (s3_bucket) и регион (s3_region).', + failed: (reason) => `Проверка подключения к хранилищу не удалась: ${reason}`, + }, + de: { + succeeded: (adapter) => `Der Verbindungstest zum Speicher war erfolgreich (Adapter: ${adapter}).`, + downloadMismatch: 'Die heruntergeladene Testdatei stimmt nicht mit der hochgeladenen überein.', + s3SettingsIncomplete: 'Der S3-Adapter benötigt einen Bucket (s3_bucket) und eine Region (s3_region).', + failed: (reason) => `Der Verbindungstest zum Speicher ist fehlgeschlagen: ${reason}`, + }, +}; + +/** Each platform locale table tag → the key that serves it. */ +const KEY_BY_TABLE_TAG: Record = { + en: 'en', + 'zh-Hans': 'zh-CN', + 'zh-Hant': 'zh-Hant', + es: 'es-ES', + ar: 'ar', + fr: 'fr', + 'pt-BR': 'pt-BR', + ru: 'ru', + de: 'de', + ja: 'ja-JP', + ko: 'ko', +}; + +/** The key that serves a caller's code, or `undefined` (⇒ English). */ +export function storageTestCopyLocaleOf(locale: string | undefined): StorageTestCopyLocale | undefined { + if (!locale) return undefined; + const subtags = locale.split('-'); + for (let n = subtags.length; n > 0; n -= 1) { + const tag = platformLocaleTagOf(subtags.slice(0, n).join('-')); + if (tag) return KEY_BY_TABLE_TAG[tag]; + } + return undefined; +} + +/** The copy for a caller's locale; English when the code names no language on the table. */ +export function storageTestCopy(locale: string | undefined): StorageTestCopy { + return STORAGE_TEST_COPY[storageTestCopyLocaleOf(locale) ?? 'en']; +} diff --git a/packages/services/service-storage/src/storage-test-locale.test.ts b/packages/services/service-storage/src/storage-test-locale.test.ts new file mode 100644 index 00000000000..5717bc7e994 --- /dev/null +++ b/packages/services/service-storage/src/storage-test-locale.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30335] Setup → Storage → 测试连接 answers in the caller's language. + * + * The console prints the `storage/test` action's `message` as the toast, bare. + * The settings action route hands the handler the caller's display language as + * the input's `locale` (`Accept-Language`, else `?locale`), the same source the + * mail test notice reads. These pins run the REAL handler the plugin registers, + * on a real local adapter, with that input member set or absent. + * + * `ok`, `severity`, the adapter name and the probe's cleanup are not the + * subject: each case asserts the first two are what they were, so a + * translation can never have moved a verdict. + */ + +import { describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { StorageServicePlugin } from './storage-service-plugin.js'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; +import { storageTestCopy, storageTestCopyLocaleOf, STORAGE_TEST_COPY } from './storage-test-copy.js'; + +function makeCtx() { + const services = new Map(); + const hooks: Array<() => Promise | void> = []; + const ctx: any = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + registerService: (name: string, svc: unknown) => { services.set(name, svc); }, + getService: (name: string): T => { + const s = services.get(name); + if (!s) throw new Error(`service '${name}' not registered`); + return s as T; + }, + hook: (event: string, fn: () => Promise | void) => { + if (event === 'kernel:ready') hooks.push(fn); + }, + _flushReady: async () => { for (const h of hooks) await h(); }, + }; + return ctx; +} + +/** A settings host that keeps the registered action so a test can run it. */ +function makeSettingsHost() { + const actions = new Map Promise>(); + return { + createClient: (_ns: string) => ({}), + getNamespace: async (_ns: string) => ({ values: {} }), + subscribe: (_ns: string, _fn: () => void) => {}, + registerAction: (ns: string, id: string, fn: (input: unknown) => Promise) => { + actions.set(`${ns}/${id}`, fn); + }, + run: async (input: Record) => { + const fn = actions.get('storage/test'); + if (!fn) throw new Error('storage/test was not registered'); + return await fn({ namespace: 'storage', actionId: 'test', ctx: {}, ...input }); + }, + }; +} + +async function bootPlugin() { + const root = await fs.mkdtemp(join(tmpdir(), 'oss-30335-')); + const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir: root }, registerRoutes: false }); + const ctx = makeCtx(); + const settings = makeSettingsHost(); + ctx.registerService('settings', settings); + await plugin.init(ctx); + await plugin.start(ctx); + await ctx._flushReady(); + return { root, settings, plugin }; +} + +/** The local adapter with a download that answers bytes other than the ones uploaded. */ +function corruptDownloads(): () => void { + const original = LocalStorageAdapter.prototype.download; + LocalStorageAdapter.prototype.download = async function (this: LocalStorageAdapter, key: string) { + void key; + return Buffer.from('not the probe', 'utf-8'); + } as typeof original; + return () => { LocalStorageAdapter.prototype.download = original; }; +} + +describe('[#30335] storage/test answers in the caller\'s language', () => { + it('zh-CN: the success sentence is Chinese and names the adapter; the verdict is unchanged', async () => { + const { root, settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'zh-CN' }); + expect(result.ok).toBe(true); + expect(result.severity).toBe('info'); + expect(result.message).toBe('存储连接测试成功(适配器:local)。'); + expect(result.message).not.toContain('round-trip'); + }); + + it('en: the success sentence is the English one the console showed before', async () => { + const { root, settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'en-US' }); + expect(result.ok).toBe(true); + expect(result.severity).toBe('info'); + expect(result.message).toBe('Storage round-trip succeeded (adapter=local).'); + }); + + it('no locale, or one with no copy: English, exactly as an older settings host gets it', async () => { + const { root, settings } = await bootPlugin(); + const values = { adapter: 'local', local_root: root }; + for (const input of [{ values }, { values, locale: 'tr-TR' }, { values, locale: '' }]) { + const result = await settings.run(input); + expect(result.ok).toBe(true); + expect(result.severity).toBe('info'); + expect(result.message).toBe('Storage round-trip succeeded (adapter=local).'); + } + }); + + it('a language tag without the region still picks its copy (zh → zh-CN)', async () => { + const { root, settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'zh' }); + expect(result.message).toBe('存储连接测试成功(适配器:local)。'); + }); + + it('zh-CN: a download that does not match the upload is refused in Chinese', async () => { + const { root, settings } = await bootPlugin(); + const restore = corruptDownloads(); + try { + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'zh-CN' }); + expect(result.ok).toBe(false); + expect(result.severity).toBe('error'); + expect(result.message).toBe('探测文件下载后的内容与上传的不一致。'); + } finally { + restore(); + } + // The probe object is still removed on this path (#13726). + const left = await fs.readdir(join(root, '__objectstack_probe__')).catch(() => []); + expect(left).toEqual([]); + }); + + it('zh-CN: incomplete S3 settings are refused in Chinese, naming the two fields', async () => { + const { settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 's3', s3_bucket: '', s3_region: '' }, locale: 'zh-CN' }); + expect(result.ok).toBe(false); + expect(result.severity).toBe('error'); + expect(result.message).toBe('S3 适配器需要填写存储桶(s3_bucket)和区域(s3_region)。'); + }); + + it('no locale: incomplete S3 settings keep the English refusal', async () => { + const { settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 's3', s3_bucket: '', s3_region: '' } }); + expect(result.ok).toBe(false); + expect(result.message).toBe('StorageServicePlugin: S3 adapter requires s3_bucket and s3_region'); + }); + + it('zh-CN: the adapter\'s own error text stays verbatim behind a Chinese lead-in', async () => { + const { settings } = await bootPlugin(); + // A root the process cannot create: a path under a regular FILE. + const file = join(await fs.mkdtemp(join(tmpdir(), 'oss-30335-file-')), 'not-a-dir'); + await fs.writeFile(file, 'x'); + const zh = await settings.run({ values: { adapter: 'local', local_root: join(file, 'root') }, locale: 'zh-CN' }); + const en = await settings.run({ values: { adapter: 'local', local_root: join(file, 'root') } }); + expect(zh.ok).toBe(false); + expect(zh.severity).toBe('error'); + expect(en.ok).toBe(false); + // English stays the adapter's sentence alone; Chinese is lead-in + that same sentence. + expect(en.message.length).toBeGreaterThan(0); + expect(zh.message).toBe(`存储连接测试失败:${en.message}`); + }); +}); + +describe('[#30335] the storage/test copy table', () => { + const KEYS = ['ar', 'de', 'en', 'es-ES', 'fr', 'ja-JP', 'ko', 'pt-BR', 'ru', 'zh-CN', 'zh-Hant']; + + it('carries all 11 languages of the platform locale table, each with every sentence', () => { + expect(Object.keys(STORAGE_TEST_COPY).sort()).toEqual(KEYS); + // Every table tag finds its own entry (none falls through to English). + expect(PLATFORM_LOCALE_TAGS).toHaveLength(11); + for (const tag of PLATFORM_LOCALE_TAGS) { + expect(storageTestCopyLocaleOf(tag), tag).toBeDefined(); + } + const en = STORAGE_TEST_COPY.en; + for (const key of KEYS) { + const copy = STORAGE_TEST_COPY[key as keyof typeof STORAGE_TEST_COPY]; + expect(Object.keys(copy).sort(), key).toEqual(['downloadMismatch', 'failed', 's3SettingsIncomplete', 'succeeded']); + // Placeholders survive verbatim; the two setting keys are named as written. + expect(copy.succeeded('ADAPTER_X'), key).toContain('ADAPTER_X'); + expect(copy.failed('REASON_Y'), key).toContain('REASON_Y'); + expect(copy.s3SettingsIncomplete, key).toContain('s3_bucket'); + expect(copy.s3SettingsIncomplete, key).toContain('s3_region'); + expect(copy.downloadMismatch.length, key).toBeGreaterThan(0); + if (key !== 'en') { + // A real translation, not the English sentence copied in. + expect(copy.succeeded('local'), key).not.toBe(en.succeeded('local')); + expect(copy.downloadMismatch, key).not.toBe(en.downloadMismatch); + expect(copy.s3SettingsIncomplete, key).not.toBe(en.s3SettingsIncomplete); + expect(copy.failed('x'), key).not.toBe(en.failed('x')); + } + } + }); + + it('reads a caller\'s code through the platform table: aliases, table tags, then shorter subtags', () => { + const cases: Array<[string | undefined, string | undefined]> = [ + ['zh-CN', 'zh-CN'], ['zh', 'zh-CN'], ['zh-Hans', 'zh-CN'], ['zh-Hans-CN', 'zh-CN'], + ['zh-TW', 'zh-Hant'], ['zh-HK', 'zh-Hant'], ['zh-Hant', 'zh-Hant'], ['zh-Hant-TW', 'zh-Hant'], + ['ja', 'ja-JP'], ['ja-JP', 'ja-JP'], ['es', 'es-ES'], ['es-MX', 'es-ES'], + ['pt', 'pt-BR'], ['pt-BR', 'pt-BR'], ['fr-FR', 'fr'], ['de-DE', 'de'], ['ko-KR', 'ko'], + ['ru', 'ru'], ['ar-SA', 'ar'], ['en-US', 'en'], ['EN-gb', 'en'], + ['xx-YY', undefined], ['', undefined], [undefined, undefined], + ]; + for (const [code, key] of cases) { + expect(storageTestCopyLocaleOf(code), String(code)).toBe(key); + } + expect(storageTestCopy('xx-YY')).toBe(STORAGE_TEST_COPY.en); + }); +});