diff --git a/package-lock.json b/package-lock.json index 6448651..ff0faa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,9 +25,11 @@ "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.1.1", "@nextcloud/vite-config": "^2.5.2", + "@types/escape-html": "^1.0.4", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", + "escape-html": "^1.0.3", "jsdom": "^30.0.1", "typescript": "^5.9.3", "vite": "^7.2.7", diff --git a/package.json b/package.json index 9eadd25..5d1ec51 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,11 @@ "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.1.1", "@nextcloud/vite-config": "^2.5.2", + "@types/escape-html": "^1.0.4", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", + "escape-html": "^1.0.3", "jsdom": "^30.0.1", "typescript": "^5.9.3", "vite": "^7.2.7", diff --git a/src/test-utils/translateMock.spec.ts b/src/test-utils/translateMock.spec.ts new file mode 100644 index 0000000..da6da08 --- /dev/null +++ b/src/test-utils/translateMock.spec.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' +import { translate } from '@nextcloud/l10n' + +// vitest.setup.ts mocks translate() for every spec in the suite. Pin its +// contract against the real implementation's default behaviour — real +// translate() escapes every placeholder value (escape-html) before +// substitution, and a mock that skips this hides a whole class of bug: a +// component whose translated string reaches an HTML sink (or, conversely, +// an ARIA attribute, where the escaping is actually wrong) behaves +// differently under test than for a real user. +describe('translate() mock', () => { + it('escapes HTML-significant characters in a placeholder value', () => { + const result = translate('office', 'Shared by {owner}', { owner: '&"\'' }) + + expect(result).toBe('Shared by <b>&"'') + }) + + it('leaves the surrounding template text alone', () => { + const result = translate('office', 'Shared by {owner}', { owner: 'Bob' }) + + expect(result).toBe('Shared by Bob') + }) +}) diff --git a/vitest.setup.ts b/vitest.setup.ts index 1ed31d4..9d452dd 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,6 @@ import { config } from '@vue/test-utils' import { vi } from 'vitest' +import escapeHTML from 'escape-html' // shallowMount()'s auto-stubs render nothing by default, including slot // content passed to them — without this, any content nested inside a @@ -20,12 +21,15 @@ function substitutePlaceholders(text: string, vars?: Record): s }) } +// Real translate() escapes every placeholder value by default (escape-html) — +// match that here, or a component relying on the default silently ships +// double-escaped output that only breaks against the real implementation. vi.mock('@nextcloud/l10n', async (importOriginal) => ({ ...(await importOriginal()), translate: (app: string, text: string, placeholders?: Record) => text.replace(/{([^{}]*)}/g, (match: string, key: string) => { const value = placeholders?.[key] - return value !== undefined ? String(value) : match + return value !== undefined ? escapeHTML(String(value)) : match }), }))