Skip to content
153 changes: 153 additions & 0 deletions e2e/tests/link-control.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {
test,
expect,
waitForBlockEditor,
} from 'e2e/test-utils'

test.describe( 'Link control URL input', () => {
const createdPostIds: Array<string | number> = []

test.afterEach( async ( { requestUtils } ) => {
for ( const id of createdPostIds.splice( 0 ) ) {
await requestUtils.deletePost( id ).catch( () => undefined )
}
} )

test( 'accepts safe link values and rejects executable protocols', async ( {
page,
admin,
editor,
stackable,
} ) => {
await admin.createNewPost( { title: 'Link Control URL Input' } )
await editor.saveDraft()
const postQuery = new URL( editor.page.url() ).search
const postId = new URLSearchParams( postQuery ).get( 'post' )
if ( postId ) {
createdPostIds.push( postId )
}

await stackable.dismissToursAndNotices()
await waitForBlockEditor( editor )

await editor.insertBlock( { name: 'stackable/button-group' } )
await stackable.pickDefaultLayout( editor )
await stackable.selectBlockByName( editor, 'stackable/button' )

await stackable.openInspectorTab( 'Style' )

const inspector = page.getByRole( 'region', { name: 'Editor settings' } )
const linkPanel = inspector.locator( '.ugb-toggle-panel-body.ugb-panel--link' )
await expect( linkPanel ).toBeVisible()
if ( ! ( await linkPanel.getAttribute( 'class' ) || '' ).includes( 'is-opened' ) ) {
await linkPanel.locator( '.components-panel__body-toggle' ).click()
}
await expect( linkPanel ).toHaveClass( /is-opened/ )

const linkControl = linkPanel.locator( '.stk-link-control' ).filter( {
has: page.locator( '.stk-control-label', { hasText: /Link \/ URL/ } ),
} )
const input = linkControl.getByRole( 'combobox', { name: 'URL' } )
await expect( input ).toBeVisible()

const buttonBlock = editor.canvas.locator( '[data-type="stackable/button"]' ).first()
const clientId = await buttonBlock.getAttribute( 'data-block' )

const setLinkValue = async ( value: string ) => {
await input.click()
await input.fill( value )
await page.keyboard.press( 'Escape' )
await input.blur()
}

await setLinkValue( 'https://example.com' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'https://example.com' )
await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' )

await setLinkValue( 'http://192.168.0.10' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'http://192.168.0.10' )

await setLinkValue( 'images/photo.jpg' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'images/photo.jpg' )

await setLinkValue( 'docs/readme.md' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'docs/readme.md' )

await setLinkValue( 'example.org' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'https://example.org' )

await setLinkValue( 'example.photography' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'https://example.photography' )

await setLinkValue( 'example.com:8443' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'https://example.com:8443' )

Comment thread
coderabbitai[bot] marked this conversation as resolved.
await setLinkValue( '[my_shortcode]' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( '[my_shortcode]' )
await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' )

await setLinkValue( '!#stk_dynamic/current-page/url!#' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( '!#stk_dynamic/current-page/url!#' )
await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' )

await setLinkValue( '{{permalink}}' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( '{{permalink}}' )
await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' )

await setLinkValue( 'https://' )
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( 'https://' )
await expect( linkControl ).toContainText( 'Please enter a valid URL.' )

await input.click()
await input.fill( 'javascript:alert(document.domain)' )
await expect( linkControl ).toContainText( 'Please enter a valid URL.' )
await input.blur()
await expect.poll( async () => {
const attributes = await editor.getBlockAttributes( clientId )
return attributes.linkUrl
} ).toBe( '' )

await page.evaluate( id => {
window.wp.data.dispatch( 'core/block-editor' ).updateBlockAttributes( id, {
linkUrl: 'javascript:alert(document.domain)',
} )
}, clientId )
await editor.saveDraft()

const savedContent = await editor.getEditedPostContent()
expect( savedContent ).not.toContain( 'href="javascript:' )
} )
} )
115 changes: 115 additions & 0 deletions src/components/link-control/__test__/validate.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Internal dependencies
*/
import {
isPassThroughLinkValue,
isUrlLike,
isValidLinkValue,
normalizeLinkValue,
} from '../validate'

describe( 'link-control validation', () => {
it( 'treats empty values as pass-through', () => {
expect( isPassThroughLinkValue( '' ) ).toBe( true )
expect( isPassThroughLinkValue( ' ' ) ).toBe( true )
expect( isPassThroughLinkValue( undefined ) ).toBe( true )
expect( isValidLinkValue( '' ) ).toBe( true )
} )

it( 'allows shortcodes without treating them as URLs', () => {
expect( isPassThroughLinkValue( '[my_shortcode]' ) ).toBe( true )
expect( isPassThroughLinkValue( '[contact-form-7 id="1"]' ) ).toBe( true )
expect( isPassThroughLinkValue( '[site.url]' ) ).toBe( true )
expect( isValidLinkValue( '[my_shortcode]' ) ).toBe( true )
expect( normalizeLinkValue( '[my_shortcode]' ) ).toBe( '[my_shortcode]' )
} )

it( 'allows dynamic content tokens', () => {
const token = '!#stk_dynamic/current-page/url!#'
expect( isPassThroughLinkValue( token ) ).toBe( true )
expect( isValidLinkValue( token ) ).toBe( true )
expect( normalizeLinkValue( token ) ).toBe( token )
} )

it( 'allows other non-URL strings', () => {
expect( isPassThroughLinkValue( '{{permalink}}' ) ).toBe( true )
expect( isPassThroughLinkValue( '%post_url%' ) ).toBe( true )
expect( isPassThroughLinkValue( 'hello' ) ).toBe( true )
expect( isValidLinkValue( 'hello' ) ).toBe( true )
expect( isUrlLike( 'hello' ) ).toBe( false )
} )

it( 'recognizes URL-like values', () => {
expect( isUrlLike( 'https://example.com' ) ).toBe( true )
expect( isUrlLike( 'example.com' ) ).toBe( true )
expect( isUrlLike( 'www.example.com' ) ).toBe( true )
expect( isUrlLike( '#section' ) ).toBe( true )
expect( isUrlLike( '/about' ) ).toBe( true )
expect( isUrlLike( 'mailto:hi@example.com' ) ).toBe( true )
} )

it( 'accepts valid URLs, anchors, and relative paths', () => {
expect( isValidLinkValue( 'https://example.com' ) ).toBe( true )
expect( isValidLinkValue( '#section' ) ).toBe( true )
expect( isValidLinkValue( '/about' ) ).toBe( true )
expect( isValidLinkValue( '../parent' ) ).toBe( true )
expect( isValidLinkValue( 'mailto:hi@example.com' ) ).toBe( true )
} )

it( 'rejects incomplete URL-like values', () => {
expect( isValidLinkValue( 'https://' ) ).toBe( false )
expect( isValidLinkValue( 'http://' ) ).toBe( false )
} )

it.each( [
'javascript:alert(1)',
'JAVASCRIPT:alert(1)',
'java\nscript:alert(1)',
'java\tscript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
] )( 'rejects executable protocol value %s', value => {
expect( isPassThroughLinkValue( value ) ).toBe( false )
expect( isValidLinkValue( value ) ).toBe( false )
expect( normalizeLinkValue( value ) ).toBe( '' )
} )

it( 'prepends https to bare domains on normalize', () => {
expect( normalizeLinkValue( 'example.com' ) ).toBe( 'https://example.com' )
expect( normalizeLinkValue( 'example.com/about' ) ).toBe( 'https://example.com/about' )
expect( normalizeLinkValue( 'example.photography' ) ).toBe( 'https://example.photography' )
expect( normalizeLinkValue( 'example.com:8443' ) ).toBe( 'https://example.com:8443' )
expect( normalizeLinkValue( ' example.com ' ) ).toBe( 'https://example.com' )
expect( normalizeLinkValue( 'http://192.168.0.10' ) ).toBe( 'http://192.168.0.10' )
expect( normalizeLinkValue( 'https://example.com' ) ).toBe( 'https://example.com' )
expect( normalizeLinkValue( '#section' ) ).toBe( '#section' )
expect( normalizeLinkValue( '/about' ) ).toBe( '/about' )
} )

it( 'leaves non-domain paths as entered', () => {
expect( normalizeLinkValue( 'images/photo.jpg' ) ).toBe( 'images/photo.jpg' )
expect( normalizeLinkValue( 'docs/readme.md' ) ).toBe( 'docs/readme.md' )
} )

// A file name is not a website. The extension must not be treated as a domain.
it( 'keeps file names instead of adding https', () => {
expect( isUrlLike( 'readme.md' ) ).toBe( false )
expect( isValidLinkValue( 'readme.md' ) ).toBe( true )
expect( normalizeLinkValue( 'readme.md' ) ).toBe( 'readme.md' )
expect( normalizeLinkValue( 'photo.jpg' ) ).toBe( 'photo.jpg' )
expect( normalizeLinkValue( 'jquery.min.js' ) ).toBe( 'jquery.min.js' )
expect( normalizeLinkValue( 'index.html' ) ).toBe( 'index.html' )
} )

// An email is not a website. It must stay as the user typed it.
it( 'keeps an email address instead of turning it into a website', () => {
expect( isValidLinkValue( 'hi@example.com' ) ).toBe( true )
expect( normalizeLinkValue( 'hi@example.com' ) ).toBe( 'hi@example.com' )
} )

// localhost:3000 is a host and a port, same idea as example.com:8443.
it( 'adds https to localhost with a port', () => {
expect( isValidLinkValue( 'localhost:3000' ) ).toBe( true )
expect( normalizeLinkValue( 'localhost:3000' ) ).toBe( 'https://localhost:3000' )
} )
} )
67 changes: 12 additions & 55 deletions src/components/link-control/editor.scss
Original file line number Diff line number Diff line change
@@ -1,81 +1,38 @@
.stk-link-control__input {
// Adjust width to ensure reset button is visible.
width: calc(100% - 32px);
min-width: 0;

> .block-editor-link-control {
min-width: auto;
> .block-editor-url-input {
min-width: 0;
width: 100%;

.components-base-control {
min-width: auto;
min-width: 0;
width: 100%;
}

.components-input-base {
height: 30px;
}

.block-editor-link-control__search-input-wrapper {
margin: 0;
}

.block-editor-link-control__field {
.components-base-control__field {
margin: 0;
}

.block-editor-link-control__search-item-header {
overflow: hidden;
white-space: nowrap;
}

}

.block-editor-url-input__input {
.block-editor-url-input__input,
.components-input-control__input {
margin: 0 !important;
width: 100% !important;
height: 30px !important;
padding: 6px 8px !important;
}
.block-editor-link-control__search-actions {
display: none;
}
.block-editor-link-control__search-results-wrapper {
margin-bottom: 24px !important;
margin-top: 0 !important;
.block-editor-link-control__search-results {
margin: 0;
}
}
.block-editor-link-control__search-item {
flex-wrap: wrap;
}
.block-editor-link-control__search-item.is-current {
margin-top: -2px;
padding: 0 !important;
}
.block-editor-link-control__search-item-details {
max-width: 140px !important;
overflow: hidden;
}
}

.block-editor-link-control__search-item-icon {
display: none;
}

.block-editor-link-control__search-enter {
position: absolute;
top: 0;
right: 3px;
button:hover {
box-shadow: none !important;
}
.stk-link-control--invalid {
.components-base-control__help {
color: #cc1818;
}
}

// Adjust the location of the dynamic and reset buttons since our control is taller.
.stk-link-control {
.block-editor-link-control__field {
margin: auto;
}
.stk-dynamic-content-control {
display: flex;
align-items: center;
Expand Down
Loading
Loading