Skip to content

Commit 30cc5e1

Browse files
committed
fix: security audit fixes across foundational composables
- createPlugin: stop persist watcher on app unmount - createNested: cycle guard in getPath to prevent infinite loops - createNumeric: reject NaN/Infinity in snap() - createValidation: catch throwing rules in async validation - useVirtualFocus: clean up highlight state on scope dispose - createContext: dev warning for non-namespaced string keys - createRegistry: dev warning when listener count exceeds 100 - useStorage: rename TTL envelope marker to prevent spoofing
1 parent 84867f5 commit 30cc5e1

File tree

17 files changed

+580
-10
lines changed

17 files changed

+580
-10
lines changed
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
// Composables
4+
import { createLocalePlugin, useLocale } from '#v0/composables/useLocale'
5+
6+
// Utilities
7+
import { mount } from '@vue/test-utils'
8+
import { defineComponent, h } from 'vue'
9+
10+
import { Locale } from './index'
11+
12+
function createPlugin (options: Record<string, any> = {}) {
13+
return createLocalePlugin({
14+
default: 'en',
15+
messages: {
16+
en: {
17+
greeting: 'Hello',
18+
farewell: 'Goodbye, {name}!',
19+
count: '{0} items',
20+
},
21+
fr: {
22+
greeting: 'Bonjour',
23+
farewell: 'Au revoir, {name} !',
24+
count: '{0} éléments',
25+
},
26+
es: {
27+
greeting: 'Hola',
28+
farewell: '¡Adiós, {name}!',
29+
count: '{0} elementos',
30+
},
31+
},
32+
...options,
33+
})
34+
}
35+
36+
describe('locale', () => {
37+
it('should render a wrapper element with data-locale and lang attributes', () => {
38+
const plugin = createPlugin()
39+
const wrapper = mount(Locale, {
40+
props: { locale: 'fr' },
41+
global: { plugins: [plugin] },
42+
slots: { default: () => h('span', 'content') },
43+
})
44+
45+
expect(wrapper.element.tagName).toBe('DIV')
46+
expect(wrapper.attributes('data-locale')).toBe('fr')
47+
expect(wrapper.attributes('lang')).toBe('fr')
48+
expect(wrapper.text()).toBe('content')
49+
})
50+
51+
it('should render custom tag via as prop', () => {
52+
const plugin = createPlugin()
53+
const wrapper = mount(Locale, {
54+
props: { locale: 'fr', as: 'section' },
55+
global: { plugins: [plugin] },
56+
slots: { default: () => h('span', 'content') },
57+
})
58+
59+
expect(wrapper.element.tagName).toBe('SECTION')
60+
})
61+
62+
it('should pass attrs via slot in renderless mode', () => {
63+
const plugin = createPlugin()
64+
const wrapper = mount(Locale, {
65+
props: { locale: 'fr', renderless: true },
66+
global: { plugins: [plugin] },
67+
slots: {
68+
default: (props: any) => h('section', props.attrs, 'content'),
69+
},
70+
})
71+
72+
const section = wrapper.find('section')
73+
expect(section.exists()).toBe(true)
74+
expect(section.attributes('data-locale')).toBe('fr')
75+
expect(section.attributes('lang')).toBe('fr')
76+
})
77+
78+
it('should not render a wrapper element in renderless mode', () => {
79+
const plugin = createPlugin()
80+
const wrapper = mount(Locale, {
81+
props: { locale: 'es', renderless: true },
82+
global: { plugins: [plugin] },
83+
slots: {
84+
default: (props: any) => h('div', props.attrs, 'child'),
85+
},
86+
})
87+
88+
// The root element should be the slot content, not a wrapper
89+
const div = wrapper.find('div')
90+
expect(div.exists()).toBe(true)
91+
expect(div.attributes('data-locale')).toBe('es')
92+
expect(div.attributes('lang')).toBe('es')
93+
})
94+
95+
it('should provide scoped locale context to children', () => {
96+
const plugin = createPlugin()
97+
98+
const Child = defineComponent({
99+
setup () {
100+
const locale = useLocale()
101+
return { locale }
102+
},
103+
render () {
104+
return h('span', this.locale.t('greeting'))
105+
},
106+
})
107+
108+
const wrapper = mount(Locale, {
109+
props: { locale: 'fr' },
110+
global: { plugins: [plugin] },
111+
slots: { default: () => h(Child) },
112+
})
113+
114+
expect(wrapper.text()).toBe('Bonjour')
115+
})
116+
117+
it('should translate with positional parameters', () => {
118+
const plugin = createPlugin()
119+
120+
const Child = defineComponent({
121+
setup () {
122+
const locale = useLocale()
123+
return { locale }
124+
},
125+
render () {
126+
return h('span', this.locale.t('count', 5))
127+
},
128+
})
129+
130+
const wrapper = mount(Locale, {
131+
props: { locale: 'fr' },
132+
global: { plugins: [plugin] },
133+
slots: { default: () => h(Child) },
134+
})
135+
136+
expect(wrapper.text()).toBe('5 éléments')
137+
})
138+
139+
it('should translate with named parameters', () => {
140+
const plugin = createPlugin()
141+
142+
const Child = defineComponent({
143+
setup () {
144+
const locale = useLocale()
145+
return { locale }
146+
},
147+
render () {
148+
return h('span', this.locale.t('farewell', { name: 'World' }))
149+
},
150+
})
151+
152+
const wrapper = mount(Locale, {
153+
props: { locale: 'es' },
154+
global: { plugins: [plugin] },
155+
slots: { default: () => h(Child) },
156+
})
157+
158+
expect(wrapper.text()).toBe('¡Adiós, World!')
159+
})
160+
161+
it('should scope selectedId to the provided locale', () => {
162+
const plugin = createPlugin()
163+
let capturedId: string | undefined
164+
165+
const Child = defineComponent({
166+
setup () {
167+
const locale = useLocale()
168+
capturedId = locale.selectedId.value as string
169+
return {}
170+
},
171+
render () {
172+
return h('span')
173+
},
174+
})
175+
176+
mount(Locale, {
177+
props: { locale: 'es' },
178+
global: { plugins: [plugin] },
179+
slots: { default: () => h(Child) },
180+
})
181+
182+
expect(capturedId).toBe('es')
183+
})
184+
185+
it('should support nested locale scoping', () => {
186+
const plugin = createPlugin()
187+
188+
const Child = defineComponent({
189+
setup () {
190+
const locale = useLocale()
191+
return { locale }
192+
},
193+
render () {
194+
return h('span', this.locale.t('greeting'))
195+
},
196+
})
197+
198+
// Outer: fr, Inner: es
199+
const wrapper = mount(Locale, {
200+
props: { locale: 'fr' },
201+
global: { plugins: [plugin] },
202+
slots: {
203+
default: () => h(Locale, { locale: 'es' }, {
204+
default: () => h(Child),
205+
}),
206+
},
207+
})
208+
209+
// Inner child should resolve to Spanish
210+
expect(wrapper.text()).toBe('Hola')
211+
})
212+
213+
it('should not affect parent locale context', () => {
214+
const plugin = createPlugin()
215+
let parentGreeting: string | undefined
216+
let childGreeting: string | undefined
217+
218+
const ParentReader = defineComponent({
219+
setup () {
220+
const locale = useLocale()
221+
parentGreeting = locale.t('greeting')
222+
return {}
223+
},
224+
render () {
225+
return h('span', parentGreeting)
226+
},
227+
})
228+
229+
const ChildReader = defineComponent({
230+
setup () {
231+
const locale = useLocale()
232+
childGreeting = locale.t('greeting')
233+
return {}
234+
},
235+
render () {
236+
return h('span', childGreeting)
237+
},
238+
})
239+
240+
mount({
241+
setup () {
242+
return () => h('div', [
243+
h(ParentReader),
244+
h(Locale, { locale: 'fr' }, {
245+
default: () => h(ChildReader),
246+
}),
247+
])
248+
},
249+
}, {
250+
global: { plugins: [plugin] },
251+
})
252+
253+
expect(parentGreeting).toBe('Hello')
254+
expect(childGreeting).toBe('Bonjour')
255+
})
256+
257+
it('should update data-locale when locale prop changes', async () => {
258+
const plugin = createPlugin()
259+
const wrapper = mount(Locale, {
260+
props: { locale: 'en' },
261+
global: { plugins: [plugin] },
262+
slots: { default: () => h('span', 'content') },
263+
})
264+
265+
expect(wrapper.attributes('data-locale')).toBe('en')
266+
expect(wrapper.attributes('lang')).toBe('en')
267+
268+
await wrapper.setProps({ locale: 'fr' })
269+
270+
expect(wrapper.attributes('data-locale')).toBe('fr')
271+
expect(wrapper.attributes('lang')).toBe('fr')
272+
})
273+
})

packages/0/src/composables/createContext/index.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ describe('createContext', () => {
132132

133133
expect(mockProvide).toHaveBeenCalledWith('component-level-test', testValue)
134134
})
135+
136+
it('should warn on non-namespaced string key in dev mode', () => {
137+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
138+
139+
createContext('no-namespace')
140+
141+
expect(warnSpy).toHaveBeenCalledTimes(1)
142+
expect(warnSpy).toHaveBeenCalledWith(
143+
expect.stringContaining('no-namespace'),
144+
)
145+
146+
warnSpy.mockRestore()
147+
})
148+
149+
it('should not warn on namespaced string key', () => {
150+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
151+
152+
createContext('v0:theme')
153+
154+
expect(warnSpy).not.toHaveBeenCalled()
155+
156+
warnSpy.mockRestore()
157+
})
135158
})
136159

137160
describe('dynamic key mode', () => {

packages/0/src/composables/createContext/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ export function createContext<Z> (
141141
) {
142142
// Static key mode: createContext('my-key') or createContext(Symbol())
143143
if (isString(keyOrOptions) || isSymbol(keyOrOptions)) {
144+
// console.warn (not useLogger) — createContext is Layer 0, cannot import useLogger without circular dep
145+
if (__DEV__ && isString(keyOrOptions) && !keyOrOptions.includes(':')) {
146+
console.warn(
147+
`[v0:context] String key "${keyOrOptions}" has no namespace separator. `
148+
+ `Use "namespace:key" format (e.g. "v0:theme") to prevent collisions.`,
149+
)
150+
}
144151
const _key = keyOrOptions as ContextKey<Z>
145152

146153
function _provideContext (context: Z, app?: App) {

packages/0/src/composables/createNested/index.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,25 @@ describe('createNested', () => {
249249
})
250250
})
251251

252+
describe('circular reference protection', () => {
253+
it('should not infinite loop in getPath when circular parent exists', () => {
254+
const nested = createNested()
255+
256+
nested.register({ id: 'a', value: 'A' })
257+
nested.register({ id: 'b', value: 'B', parentId: 'a' })
258+
259+
// Manually inject a circular reference (a→b→a)
260+
// Cast past ReadonlyMap to simulate corrupted state
261+
;(nested.parents as Map<any, any>).set('a', 'b')
262+
263+
// Without protection this would hang forever
264+
const path = nested.getPath('a')
265+
266+
// Should terminate and return a finite path
267+
expect(path.length).toBeLessThanOrEqual(3)
268+
})
269+
})
270+
252271
describe('open state management', () => {
253272
it('should open single node by ID', () => {
254273
const nested = createNested()

packages/0/src/composables/createNested/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,14 @@ export function createNested (_options: NestedOptions = {}): NestedContext {
296296
function getPath (id: ID): ID[] {
297297
const path: ID[] = []
298298
let currentId: ID | undefined = id
299+
const visited = new Set<ID>()
299300

300301
while (!isUndefined(currentId)) {
302+
if (visited.has(currentId)) {
303+
logger.warn(`Circular parent reference detected at "${currentId}".`)
304+
break
305+
}
306+
visited.add(currentId)
301307
path.unshift(currentId)
302308
currentId = parents.get(currentId)
303309
}

packages/0/src/composables/createNumeric/index.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ describe('createNumeric', () => {
4949
expect(n.snap(-10)).toBe(0)
5050
expect(n.snap(200)).toBe(100)
5151
})
52+
53+
it('should return min for NaN and Infinity', () => {
54+
const n = setup({ min: 0, max: 100, step: 10 })
55+
expect(n.snap(Number.NaN)).toBe(0)
56+
expect(n.snap(Infinity)).toBe(0)
57+
expect(n.snap(-Infinity)).toBe(0)
58+
})
5259
})
5360

5461
describe('fromValue', () => {

packages/0/src/composables/createNumeric/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function createNumeric (options: NumericOptions = {}): NumericContext {
6666
)
6767

6868
function snap (value: number): number {
69+
if (!Number.isFinite(value)) return min
6970
if (step <= 0 || !Number.isFinite(min)) return clamp(value, min, max)
7071
const clamped = clamp(value, min, max)
7172
const steps = Math.round((clamped - min) / step)

0 commit comments

Comments
 (0)