Skip to content

Commit bb50d4b

Browse files
committed
fix(file-editor): address audit findings without broadening scope
1 parent 0d71045 commit bb50d4b

19 files changed

Lines changed: 475 additions & 84 deletions

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,14 @@ describe('typed bullet list joining', () => {
5050
expect(ed.view.dom.querySelectorAll(':scope > ul')).toHaveLength(1)
5151
})
5252

53-
it('joins both neighbors when turning their separating paragraph into a bullet', () => {
53+
it('joins the preceding list without merging two existing roots', () => {
5454
const ed = mount('<ul><li><p>one</p></li></ul><p></p><ul><li><p>two</p></li></ul>')
5555
ed.commands.setTextSelection(10)
5656
typeBullet(ed)
5757
ed.commands.insertContent('new')
5858

59-
expect(ed.getJSON().content?.filter((node) => node.type === 'bulletList')).toHaveLength(1)
60-
expect(ed.getMarkdown().trim()).toBe('- one\n- new\n- two')
59+
expect(ed.getJSON().content?.filter((node) => node.type === 'bulletList')).toHaveLength(2)
60+
expect(ed.getMarkdown().trim()).toBe('- one\n- new\n\n- two')
6161
})
6262

6363
it('does not join across an intentional blank paragraph', () => {

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,27 @@ afterEach(async () => {
184184
})
185185

186186
describe('loaded rich editor lifecycle', () => {
187+
it('explains a picker selection whose insertion anchor was invalidated', async () => {
188+
await render('before TARGET after')
189+
const editor = getEditor()
190+
await act(async () => {
191+
editor.commands.setTextSelection({ from: 8, to: 14 })
192+
editor.storage.slashCommand.insertImage?.(8)
193+
editor.commands.insertContentAt({ from: 7, to: 15 }, 'changed')
194+
})
195+
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
196+
Object.defineProperty(input, 'files', {
197+
value: [new File(['image'], 'image.png', { type: 'image/png' })],
198+
})
199+
await act(async () => input.dispatchEvent(new Event('change', { bubbles: true })))
200+
expect(uploadFile).not.toHaveBeenCalled()
201+
expect(toast.info).toHaveBeenLastCalledWith(
202+
'The insertion location changed. Choose a new location and select the image again.'
203+
)
204+
expect(editor.getText()).toContain('changed')
205+
expect(input.value).toBe('')
206+
})
207+
187208
it.each(['cancel', 'invalidate'] as const)(
188209
'explains a completed upload without inserting after its anchor is %s',
189210
async (action) => {

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/** @vitest-environment jsdom */
22
import { Editor } from '@tiptap/core'
3+
import Collaboration from '@tiptap/extension-collaboration'
34
import { undoDepth } from '@tiptap/pm/history'
45
import { afterEach, describe, expect, it } from 'vitest'
6+
import * as Y from 'yjs'
57
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
68
import {
79
beginImageUploads,
@@ -24,6 +26,40 @@ function mount(content = '<p>abcd</p>') {
2426
}
2527

2628
describe('image upload anchors', () => {
29+
it.each([false, true])(
30+
'safely cancels an anchor replaced by a peer update (unrelated paragraph=%s)',
31+
(unrelated) => {
32+
const localDoc = new Y.Doc()
33+
const remoteDoc = new Y.Doc()
34+
const extensions = (doc: Y.Doc) => [
35+
...createMarkdownContentExtensions({}, { disableHistory: true }),
36+
Collaboration.configure({ document: doc }),
37+
ImageUploadPlaceholders,
38+
]
39+
editor = new Editor({ extensions: extensions(localDoc) })
40+
editor.commands.setContent('<p>before TARGET after</p><p>other</p>')
41+
Y.applyUpdate(remoteDoc, Y.encodeStateAsUpdate(localDoc))
42+
const peer = new Editor({ extensions: extensions(remoteDoc) })
43+
try {
44+
const [id] = beginImageUploads(editor, { from: 8, to: 14 }, ['image.png'])
45+
const position = unrelated ? peer.state.doc.content.size - 1 : 10
46+
peer.commands.insertContentAt(position, 'PEER ')
47+
Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(remoteDoc))
48+
const updated = editor.getJSON()
49+
expect(updated).toEqual(peer.getJSON())
50+
expect(editor.getText()).toContain('PEER ')
51+
expect(findImageUploadRange(editor, id)).toBeNull()
52+
expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false)
53+
expect(editor.getJSON()).toEqual(updated)
54+
} finally {
55+
peer.destroy()
56+
editor.destroy()
57+
localDoc.destroy()
58+
remoteDoc.destroy()
59+
}
60+
}
61+
)
62+
2763
it('replaces selected text only on success and never serializes the placeholder', () => {
2864
const editor = mount('<p>before REPLACE after</p>')
2965
const [id] = beginImageUploads(editor, { from: 8, to: 15 }, ['image.png'])
@@ -122,6 +158,8 @@ describe('image upload anchors', () => {
122158
expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(true)
123159
expect(editor.state.selection.from).toBe(2)
124160
expect(editor.state.doc.firstChild?.textContent).toBe('PREFIX ab')
161+
expect(editor.state.doc.child(1).type.name).toBe('image')
162+
expect(editor.state.doc.lastChild?.textContent).toBe('cd')
125163
})
126164

127165
it('drops uploads whose surrounding content was deleted', () => {

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-collaboration.test.ts‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ interface Peer {
1616
const cleanups: Array<() => void> = []
1717
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
1818

19-
function pair(): { a: Peer; b: Peer } {
19+
function pair(content = '## Todos\n\n- one\n- two\n- three', html = false): { a: Peer; b: Peer } {
2020
document.elementFromPoint ??= () => null
21-
const seed = markdownToYDoc('## Todos\n\n- one\n- two\n- three')
21+
const seed = markdownToYDoc(html ? '' : content)
2222
const make = (): Peer => {
2323
const doc = new Y.Doc()
2424
Y.applyUpdate(doc, Y.encodeStateAsUpdate(seed))
@@ -41,6 +41,11 @@ function pair(): { a: Peer; b: Peer } {
4141
return { doc, editor, updates }
4242
}
4343
const a = make()
44+
if (html) {
45+
a.editor.commands.setContent(content)
46+
Y.applyUpdate(seed, Y.encodeStateAsUpdate(a.doc))
47+
a.updates.length = 0
48+
}
4449
const b = make()
4550
seed.destroy()
4651
return { a, b }
@@ -75,6 +80,57 @@ function reconnect(a: Peer, b: Peer, reversed: boolean): void {
7580
}
7681

7782
describe('list editing with delayed peer updates', () => {
83+
describe.each(['bullet', 'ordered', 'task'] as const)('%s list boundaries', (kind) => {
84+
it.each(
85+
[1, 2].flatMap((beforeCount) =>
86+
[false, true].flatMap((nested) =>
87+
[false, true].map((reversed) => ({ beforeCount, nested, reversed }))
88+
)
89+
)
90+
)(
91+
'retains both existing roots ($beforeCount items, nested=$nested, reversed=$reversed)',
92+
({ beforeCount, nested, reversed }) => {
93+
const words = [...['one', 'two'].slice(0, beforeCount), 'three', 'four']
94+
const list = (items: string[], start: number) => {
95+
const tag = kind === 'ordered' ? 'ol' : 'ul'
96+
const attrs =
97+
kind === 'task'
98+
? ' data-type="taskList"'
99+
: kind === 'ordered'
100+
? ` start="${start}"`
101+
: ''
102+
const itemAttrs = kind === 'task' ? ' data-type="taskItem" data-checked="false"' : ''
103+
return `<${tag}${attrs}>${items.map((word) => `<li${itemAttrs}><p>${word}</p></li>`).join('')}</${tag}>`
104+
}
105+
const marker = kind === 'ordered' ? `${beforeCount + 1}.` : kind === 'task' ? '[ ]' : '-'
106+
const content = `${list(words.slice(0, beforeCount), 1)}<p>${marker}</p>${list(words.slice(beforeCount), beforeCount + 2)}`
107+
const { a, b } = pair(nested ? `<ul><li><p>parent</p>${content}</li></ul>` : content, true)
108+
for (const word of words) {
109+
b.editor.commands.insertContentAt(findText(b.editor, word), 'PEER ')
110+
}
111+
a.editor.commands.setTextSelection(findText(a.editor, marker) + marker.length)
112+
const { from, to } = a.editor.state.selection
113+
expect(
114+
a.editor.view.someProp('handleTextInput', (handler) =>
115+
handler(a.editor.view, from, to, ' ', () => a.editor.state.tr)
116+
)
117+
).toBe(true)
118+
a.editor.commands.insertContent('new item')
119+
const container = nested ? a.editor.state.doc.firstChild!.firstChild! : a.editor.state.doc
120+
const listType =
121+
kind === 'task' ? 'taskList' : kind === 'ordered' ? 'orderedList' : 'bulletList'
122+
const lists = Array.from({ length: container.childCount }, (_, index) =>
123+
container.child(index)
124+
).filter((node) => node.type.name === listType)
125+
expect(lists.map((node) => node.childCount)).toEqual([beforeCount + 1, 2])
126+
expect(a.editor.state.selection.$from.parent.textContent).toBe('new item')
127+
reconnect(a, b, reversed)
128+
for (const word of words) expect(a.editor.state.doc.textContent).toContain(`PEER ${word}`)
129+
expect(a.editor.state.doc.textContent).toContain('new item')
130+
}
131+
)
132+
})
133+
78134
it.each([false, true])(
79135
'joining above a list retains every peer insertion (reordered=%s)',
80136
(reversed) => {

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.test.ts‎

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,16 @@ describe('typed numbered list joining', () => {
8383
expect(ed.getMarkdown().trim()).toBe(`${start}. new\n${start + 1}. **one**\n${start + 2}. two`)
8484
})
8585

86-
it('joins both neighbors only when both numbering boundaries continue', () => {
86+
it('joins a preceding continuation without merging two existing roots', () => {
8787
const ed = mount(
8888
'<ol start="4"><li><p>one</p></li></ol><p></p><ol start="6"><li><p>three</p></li></ol>'
8989
)
9090
selectEmptyParagraph(ed)
9191
expect(typeMarker(ed, '5.')).toBe(true)
9292
ed.commands.insertContent('two')
9393

94-
expect(ed.getJSON().content?.filter((node) => node.type === 'orderedList')).toHaveLength(1)
95-
expect(ed.getMarkdown().trim()).toBe('4. one\n5. two\n6. three')
94+
expect(ed.getJSON().content?.filter((node) => node.type === 'orderedList')).toHaveLength(2)
95+
expect(ed.getMarkdown().trim()).toBe('4. one\n5. two\n\n6) three')
9696
})
9797

9898
it.each([1, 3, 7])('preserves a following explicit restart at %s', (nextStart) => {
@@ -149,20 +149,17 @@ describe('typed task list joining', () => {
149149
}
150150
)
151151

152-
it('joins checklists on both sides of the new item', () => {
152+
it('joins the preceding checklist without merging two existing roots', () => {
153153
const ed = mount(`${TASKS}<p></p>${TASKS}`)
154154
selectEmptyParagraph(ed)
155155
expect(typeMarker(ed, '[ ]')).toBe(true)
156156
ed.commands.insertContent('new')
157157

158158
const lists = ed.getJSON().content?.filter((node) => node.type === 'taskList')
159-
expect(lists).toHaveLength(1)
160-
expect(lists?.[0].content?.map((node) => node.attrs?.checked)).toEqual([
161-
true,
162-
false,
163-
false,
164-
true,
165-
false,
159+
expect(lists).toHaveLength(2)
160+
expect(lists?.map((list) => list.content?.map((node) => node.attrs?.checked))).toEqual([
161+
[true, false, false],
162+
[true, false],
166163
])
167164
expect(ed.state.selection.$from.parent.textContent).toBe('new')
168165
})
@@ -182,19 +179,22 @@ describe('list input-rule boundaries and undo', () => {
182179
it.each([
183180
['5.', '<ol start="4"><li><p>one</p></li></ol>', '<ol start="6"><li><p>three</p></li></ol>'],
184181
['[x]', TASKS, TASKS],
185-
])('undoes both adjacent joins together for %s', (marker, before, after) => {
186-
const ed = mount(`${before}<p></p>${after}`)
187-
const originalBefore = ed.state.doc.child(0).toJSON()
188-
const originalAfter = ed.state.doc.child(2).toJSON()
189-
selectEmptyParagraph(ed)
190-
expect(typeMarker(ed, marker)).toBe(true)
191-
expect(ed.commands.undoInputRule()).toBe(true)
182+
])(
183+
'undoes the preceding join while retaining the following list for %s',
184+
(marker, before, after) => {
185+
const ed = mount(`${before}<p></p>${after}`)
186+
const originalBefore = ed.state.doc.child(0).toJSON()
187+
const originalAfter = ed.state.doc.child(2).toJSON()
188+
selectEmptyParagraph(ed)
189+
expect(typeMarker(ed, marker)).toBe(true)
190+
expect(ed.commands.undoInputRule()).toBe(true)
192191

193-
expect(ed.state.doc.child(0).toJSON()).toEqual(originalBefore)
194-
expect(ed.state.doc.child(1).textContent).toBe(`${marker} `)
195-
expect(ed.state.doc.child(2).toJSON()).toEqual(originalAfter)
196-
expect(ed.state.selection.$from.parent.textContent).toBe(`${marker} `)
197-
})
192+
expect(ed.state.doc.child(0).toJSON()).toEqual(originalBefore)
193+
expect(ed.state.doc.child(1).textContent).toBe(`${marker} `)
194+
expect(ed.state.doc.child(2).toJSON()).toEqual(originalAfter)
195+
expect(ed.state.selection.$from.parent.textContent).toBe(`${marker} `)
196+
}
197+
)
198198

199199
it.each([
200200
['1.', '<ol start="2"><li><p>one</p></li></ol>', 'orderedList'],

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export function joinListInputRules(
4747
}
4848
}
4949

50+
/** A backward join now includes an existing root; merging another can lose delayed Yjs edits. */
51+
if (list.node.childCount !== 1) return
52+
5053
const after = list.pos + list.node.nodeSize
5154
const next = tr.doc.nodeAt(after)
5255
if (next && compatible(list.node, next) && canJoin(tr.doc, after)) tr.join(after)

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-storage.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,16 @@ describe('GFM table capabilities', () => {
187187
expect(editor.commands.toggleHeaderRow()).toBe(false)
188188
expect(editor.commands.toggleHeaderColumn()).toBe(false)
189189
expect(editor.commands.toggleHeaderCell()).toBe(false)
190-
expect(editor.commands.mergeCells()).toBe(false)
191190
expect(editor.commands.setCellAttribute('colwidth', [240])).toBe(false)
191+
const table = editor.state.doc.firstChild!
192+
const firstCell = 2
193+
const secondCell = firstCell + table.firstChild!.firstChild!.nodeSize
194+
editor.view.dispatch(
195+
editor.state.tr.setSelection(CellSelection.create(editor.state.doc, firstCell, secondCell))
196+
)
197+
expect(editor.state.selection).toBeInstanceOf(CellSelection)
198+
expect(editor.can().mergeCells()).toBe(false)
199+
expect(editor.commands.mergeCells()).toBe(false)
192200
expectPreserved(editor)
193201
})
194202

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1379,7 +1379,14 @@ export function LoadedRichMarkdownEditor({
13791379
if (editor && anchor) removeImageUpload(editor, anchor)
13801380
pendingImageAnchorRef.current = null
13811381
input.value = ''
1382-
if (images.length > 0 && range !== null) void insertImagesRef.current(images, range)
1382+
if (images.length === 0) return
1383+
if (range === null) {
1384+
toast.info(
1385+
'The insertion location changed. Choose a new location and select the image again.'
1386+
)
1387+
return
1388+
}
1389+
void insertImagesRef.current(images, range)
13831390
}}
13841391
/>
13851392
{showPlaceholder && placeholderContent && (
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { toast } from '@sim/emcn'
4+
import { PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste'
5+
import type { Editor } from '@tiptap/core'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { RichMarkdownField } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
9+
10+
vi.mock(
11+
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention',
12+
() => ({
13+
useEditorMentions: vi.fn(),
14+
})
15+
)
16+
vi.mock(
17+
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu',
18+
() => ({
19+
EditorBubbleMenu: () => null,
20+
})
21+
)
22+
vi.mock(
23+
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card',
24+
() => ({
25+
LinkHoverCard: () => null,
26+
})
27+
)
28+
29+
let root: Root
30+
let container: HTMLDivElement
31+
beforeEach(() => {
32+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
33+
vi.spyOn(toast, 'warning').mockReturnValue('paste-warning')
34+
container = document.createElement('div')
35+
document.body.append(container)
36+
root = createRoot(container)
37+
})
38+
afterEach(async () => {
39+
await act(async () => root.unmount())
40+
container.remove()
41+
vi.restoreAllMocks()
42+
})
43+
44+
describe('shared field paste admission with real extensions', () => {
45+
it.each([false, true])('counts preserved frontmatter (near limit=%s)', async (nearLimit) => {
46+
const frontmatter = `---\ndescription: ${'f'.repeat(nearLimit ? 900 : 10)}\n---\n\n`
47+
const body = 'x'.repeat(PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS - 1000)
48+
const onChange = vi.fn()
49+
await act(async () =>
50+
root.render(<RichMarkdownField value={frontmatter + body} onChange={onChange} />)
51+
)
52+
const element = container.querySelector<HTMLElement & { editor: Editor }>('.tiptap')
53+
expect(element).not.toBeNull()
54+
const editor = element!.editor
55+
await act(async () => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
56+
const before = editor.getJSON()
57+
const event = new Event('paste', { bubbles: true, cancelable: true })
58+
Object.defineProperty(event, 'clipboardData', {
59+
value: {
60+
files: [],
61+
items: [],
62+
types: ['text/plain'],
63+
getData: (type: string) => (type === 'text/plain' ? 'y'.repeat(100) : ''),
64+
},
65+
})
66+
await act(async () => element!.dispatchEvent(event))
67+
if (nearLimit) {
68+
expect(editor.getJSON()).toEqual(before)
69+
expect(onChange).not.toHaveBeenCalled()
70+
expect(toast.warning).toHaveBeenCalledOnce()
71+
} else {
72+
expect(onChange).toHaveBeenCalledOnce()
73+
expect(onChange.mock.lastCall?.[0]).toContain(frontmatter.trim())
74+
expect(new TextEncoder().encode(onChange.mock.lastCall?.[0]).byteLength).toBeLessThanOrEqual(
75+
PASTE_LIMITS.RICH_MARKDOWN_BYTES
76+
)
77+
expect(onChange.mock.lastCall?.[0].length).toBeLessThanOrEqual(
78+
PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS
79+
)
80+
expect(editor.getText()).toBe(body + 'y'.repeat(100))
81+
expect(toast.warning).not.toHaveBeenCalled()
82+
}
83+
})
84+
})

0 commit comments

Comments
 (0)