Files
upstream-mirror 083a46e93b
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:11:02 +05:00

489 lines
16 KiB
TypeScript

import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Image from '@tiptap/extension-image'
import Link, { isAllowedUri } from '@tiptap/extension-link'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
describe('extension-link', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
const validUrls = [
'https://example.com',
'http://example.com',
'/same-site/index.html',
'../relative.html',
'mailto:info@example.com',
'ftp://info@example.com',
]
it('does output href tag for valid JSON schemas', () => {
validUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Link],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello world!',
marks: [
{
type: 'link',
attrs: {
href: url,
},
},
],
},
],
},
],
},
})
expect(editor.getHTML()).toContain(url)
expect(JSON.stringify(editor.getJSON())).toContain(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
it('does output href tag for valid HTML schemas', () => {
validUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Link],
content: `<p><a href="${url}">hello world!</a></p>`,
})
expect(editor.getHTML()).toContain(url)
expect(JSON.stringify(editor.getJSON())).toContain(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
// We have to disable the oxlint rule here because we're trying to purposely test eval urls
// Examples inspired by: https://portswigger.net/web-security/cross-site-scripting/cheat-sheet#protocols
const invalidUrls = [
// A standard JavaScript protocol
// oxlint-disable-next-line no-script-url
'javascript:alert(window.origin)',
// The protocol is not case sensitive
// oxlint-disable-next-line no-script-url
'jAvAsCrIpT:alert(window.origin)',
// Characters \x01-\x20 are allowed before the protocol
// oxlint-disable-next-line no-script-url
'\x00javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x01javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x02javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x03javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x04javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x05javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x06javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x07javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x08javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x09javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x0ajavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x0bjavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x0cjavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x0djavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x0ejavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x0fjavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x10javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x11javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x12javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x13javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x14javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x15javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x16javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x17javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x18javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x19javascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x1ajavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x1bjavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x1cjavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x1djavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x1ejavascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'\x1fjavascript:alert(window.origin)',
// Characters \x09,\x0a,\x0d are allowed inside the protocol
// oxlint-disable-next-line no-script-url
'java\x09script:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'java\x0ascript:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'java\x0dscript:alert(window.origin)',
// Characters \x09,\x0a,\x0d are allowed after protocol name before the colon
// oxlint-disable-next-line no-script-url
'javascript\x09:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'javascript\x0a:alert(window.origin)',
// oxlint-disable-next-line no-script-url
'javascript\x0d:alert(window.origin)',
]
it('does not output href for :javascript links in JSON schema', () => {
invalidUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Link],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello world!',
marks: [
{
type: 'link',
attrs: {
href: url,
},
},
],
},
],
},
],
},
})
expect(editor.getHTML()).not.toContain(url)
// Unfortunately, if the content is provided as JSON, it stays in the editor instance until it's destroyed
// At least, it cannot be outputted as HTML into a page
// expect(JSON.stringify(editor.getJSON())).not.toContain(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
it('does not output href for :javascript links in HTML schema', () => {
invalidUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Link],
content: `<p><a href="${url}">hello world!</a></p>`,
})
expect(editor.getHTML()).not.toContain(url)
expect(JSON.stringify(editor.getJSON())).not.toContain(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
describe('isAllowedUri', () => {
it('allows whitelisted protocols', () => {
expect(isAllowedUri('https://example.com')).toBeTruthy()
expect(isAllowedUri('http://example.com')).toBeTruthy()
expect(isAllowedUri('ftp://example.com')).toBeTruthy()
expect(isAllowedUri('mailto:info@example.com')).toBeTruthy()
expect(isAllowedUri('tel:+1234567890')).toBeTruthy()
})
it('allows relative URLs (paths, queries, anchors)', () => {
expect(isAllowedUri('/relative/path')).toBeTruthy()
expect(isAllowedUri('../parent.html')).toBeTruthy()
expect(isAllowedUri('?query=1')).toBeTruthy()
expect(isAllowedUri('#anchor')).toBeTruthy()
})
it('rejects unknown protocols with hyphens (https://github.com/ueberdosis/tiptap/issues/7929)', () => {
expect(isAllowedUri('unknown:test')).toBeFalsy()
// Hyphens are valid scheme chars per RFC 3986. They previously slipped
// past the regex because the source `\-` was consumed by the JS
// template parser.
expect(isAllowedUri('unknown-protocol://test')).toBeFalsy()
expect(isAllowedUri('unknown-protocol:test')).toBeFalsy()
expect(isAllowedUri('foo-bar-baz://payload')).toBeFalsy()
expect(isAllowedUri('tg-protocol:start')).toBeFalsy()
})
it('still rejects script-style protocols', () => {
// oxlint-disable-next-line no-script-url
expect(isAllowedUri('javascript:alert(1)')).toBeFalsy()
expect(isAllowedUri('data:text/html,<script>alert(1)</script>')).toBeFalsy()
expect(isAllowedUri('vbscript:msgbox(1)')).toBeFalsy()
})
it('honours additional protocols passed via the second argument', () => {
expect(isAllowedUri('custom://thing', ['custom'])).toBeTruthy()
expect(isAllowedUri('another-custom://thing', [{ scheme: 'another-custom' }])).toBeTruthy()
// A protocol added via the argument list must still be matched as a
// whole; a different unknown protocol must remain rejected.
expect(isAllowedUri('different-custom://thing', ['custom'])).toBeFalsy()
})
})
describe('custom protocols', () => {
it('allows using additional custom protocols', () => {
;['custom://test.css', 'another-custom://protocol.html', ...validUrls].forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Link.configure({
protocols: ['custom', { scheme: 'another-custom' }],
}),
],
content: `<p><a href="${url}">hello world!</a></p>`,
})
expect(editor.getHTML()).toContain(url)
expect(JSON.stringify(editor.getJSON())).toContain(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
})
it('should return false when clicking on non-link elements', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image,
Link.configure({
openOnClick: false,
}),
],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'image',
attrs: {
src: 'https://placehold.co/400',
},
},
],
},
],
},
})
const editorEl = getEditorEl()
const img = editorEl?.querySelector('img')
expect(img).toBeTruthy()
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
})
const wasDefaultPrevented = !img?.dispatchEvent(clickEvent)
// The event should not be prevented by the link handler
expect(wasDefaultPrevented).toBe(false)
editor?.destroy()
getEditorEl()?.remove()
})
it('should return false when clicking non-link elements with enableClickSelection', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
// inline so the image is valid content inside the paragraph (pos 1)
Image.configure({ inline: true }),
Link.configure({
enableClickSelection: true,
}),
],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'image', attrs: { src: 'https://placehold.co/400' } }],
},
],
},
})
const img = getEditorEl()?.querySelector('img')
expect(img).toBeTruthy()
// A left click whose target is the image itself (not a link).
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })
Object.defineProperty(clickEvent, 'target', { value: img })
// The link plugin must report the click unhandled (falsy) so the editor's
// default NodeSelection on the image can run.
const handled = editor.view.someProp('handleClick', fn => fn(editor!.view, 1, clickEvent))
expect(handled).toBeFalsy()
editor?.destroy()
getEditorEl()?.remove()
})
describe('shouldAutoLink', () => {
it('default shouldAutoLink rejects bare hostnames without TLD', () => {
// Test using Link extension's default options
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Link],
})
// Access the default shouldAutoLink function through the extension options
const linkExtension = editor.extensionManager.extensions.find(ext => ext.name === 'link')
const shouldAutoLink = linkExtension?.options?.shouldAutoLink
expect(shouldAutoLink).toBeDefined()
if (shouldAutoLink) {
// Should reject bare hostnames without TLD
expect(shouldAutoLink('localhost')).toBe(false)
expect(shouldAutoLink('myserver')).toBe(false)
expect(shouldAutoLink('intranet')).toBe(false)
// Should allow URLs with protocols
expect(shouldAutoLink('http://localhost')).toBe(true)
expect(shouldAutoLink('https://localhost')).toBe(true)
expect(shouldAutoLink('http://127.0.0.1')).toBe(true)
expect(shouldAutoLink('ftp://myserver')).toBe(true)
// Should allow URLs with TLD
expect(shouldAutoLink('example.com')).toBe(true)
expect(shouldAutoLink('test.example.com')).toBe(true)
expect(shouldAutoLink('https://example.com')).toBe(true)
// Should correctly handle URLs with userinfo (user:pass@host)
expect(shouldAutoLink('user:pass@example.com')).toBe(true)
expect(shouldAutoLink('user@example.com')).toBe(true)
expect(shouldAutoLink('user:pass@localhost')).toBe(false)
}
editor?.destroy()
getEditorEl()?.remove()
})
it('default shouldAutoLink rejects bare IP addresses', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Link],
})
const linkExtension = editor.extensionManager.extensions.find(ext => ext.name === 'link')
const shouldAutoLink = linkExtension?.options?.shouldAutoLink
expect(shouldAutoLink).toBeDefined()
if (shouldAutoLink) {
// Should reject bare IP addresses
expect(shouldAutoLink('127.0.0.1')).toBe(false)
expect(shouldAutoLink('192.168.1.1')).toBe(false)
expect(shouldAutoLink('10.0.0.1')).toBe(false)
expect(shouldAutoLink('0.0.0.0')).toBe(false)
// Should allow IP addresses with protocols
expect(shouldAutoLink('http://127.0.0.1')).toBe(true)
expect(shouldAutoLink('https://192.168.1.1')).toBe(true)
expect(shouldAutoLink('http://10.0.0.1:8080')).toBe(true)
}
editor?.destroy()
getEditorEl()?.remove()
})
it('allows custom shouldAutoLink to override default behavior', () => {
// Custom shouldAutoLink that allows all URLs (old behavior)
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Link.configure({
shouldAutoLink: () => true,
}),
],
})
const linkExtension = editor.extensionManager.extensions.find(ext => ext.name === 'link')
const shouldAutoLink = linkExtension?.options?.shouldAutoLink
expect(shouldAutoLink).toBeDefined()
if (shouldAutoLink) {
// With custom shouldAutoLink, localhost should be allowed
expect(shouldAutoLink('localhost')).toBe(true)
expect(shouldAutoLink('127.0.0.1')).toBe(true)
}
editor?.destroy()
getEditorEl()?.remove()
})
})
})