# frozen_string_literal: true require "pretty_text" RSpec.describe PrettyText do fab!(:user) fab!(:post) before { SiteSetting.enable_markdown_typographer = false } def n(html) html.strip end def cook(*args) PrettyText.cook(*args) end let(:wrapped_image) do "
" end describe "Quoting" do context "with avatar" do let(:default_avatar) do "//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/{size}.png" end before { User.stubs(:default_template).returns(default_avatar) } it "correctly extracts usernames from the new quote format" do topic = Fabricate(:topic, title: "this is a test topic :slight_smile:") expected = <<~HTML HTML expect( cook( "[quote=\"Jeff, post:2, topic:#{topic.id}, username:codinghorror\"]\nddd\n[/quote]", topic_id: 1, ), ).to eq(n(expected)) end it "do off topic quoting with emoji unescape" do topic = Fabricate(:topic, title: "this is a test topic :slight_smile:") expected = <<~HTML HTML expect( cook("[quote=\"EvilTrout, post:2, topic:#{topic.id}\"]\nddd\n[/quote]", topic_id: 1), ).to eq(n(expected)) end context "with emojis" do let(:md) { <<~MD } > This is a quote with a regular emoji :upside_down_face: > This is a quote with an emoji shortcut :) > This is a quote with a Unicode emoji ๐ MD it "does not unescape emojis when emojis are disabled" do SiteSetting.enable_emoji = false html = <<~HTMLThis is a quote with a regular emoji :upside_down_face:
This is a quote with an emoji shortcut :)
HTML expect(cook(md)).to eq(html.strip) end it "does not convert emoji shortcuts when emoji shortcuts are disabled" do SiteSetting.enable_emoji_shortcuts = false html = <<~HTMLThis is a quote with a Unicode emoji ๐
This is a quote with a regular emoji
This is a quote with an emoji shortcut :)
HTML expect(cook(md)).to eq(html.strip) end it "unescapes all emojis" do html = <<~HTMLThis is a quote with a Unicode emoji
This is a quote with a regular emoji
This is a quote with an emoji shortcut
HTML expect(cook(md)).to eq(html.strip) end it "adds an only-emoji class when a line has only one emoji" do md = <<~MD โน๏ธ foo ๐ foo ๐ bar :smile_cat: :smile_cat: :smile_cat: :smile_cat: :smile_cat: :smile_cat: :smile_cat: baz? :smile_cat: ๐ ๐ foo ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐๐๐ ๐ ๐ ๐ ๐d๐ ๐ ๐ ๐ ๐d ๐๐๐๐ MD html = <<~HTMLThis is a quote with a Unicode emoji
foo
foo bar
baz?
foo
dโ:wink:
d
This is a quote with a regular emoji
This is a quote with an emoji shortcut
HTML expect(cook(md)).to eq(html.strip) end it "does use emoji CDN when others CDNs are also enabled" do set_cdn_url("https://cdn.com") setup_s3 SiteSetting.s3_cdn_url = "https://s3.cdn.com" SiteSetting.external_emoji_url = "https://emoji.cdn.com" html = <<~HTMLThis is a quote with a Unicode emoji
This is a quote with a regular emoji
This is a quote with an emoji shortcut
HTML expect(cook(md)).to eq(html.strip) end end it "do off topic quoting of posts from secure categories" do category = Fabricate(:category, read_restricted: true) topic = Fabricate(:topic, title: "this is topic with secret category", category: category) expected = <<~HTML HTML expect( cook( "[quote=\"maja, post:3, topic:#{topic.id}\"]\nI have nothing to say.\n[/quote]", topic_id: 1, ), ).to eq(n(expected)) end it "do off topic quoting with the force_quote_link opt and no topic_id opt provided" do topic = Fabricate(:topic, title: "This is an off-topic topic") expected = <<~HTML HTML cooked = cook( "[quote=\"maja, post:3, topic:#{topic.id}\"]\nI have nothing to say.\n[/quote]", force_quote_link: true, ) expect(cooked).to eq(n(expected)) end it "indifferent about missing quotations" do md = <<~MD [quote=#{user.username}, post:123, topic:456, full:true] ddd [/quote] MD html = <<~HTML HTML expect(PrettyText.cook(md)).to eq(html.strip) end it "indifferent about curlies and no curlies" do md = <<~MD [quote=โ#{user.username}, post:123, topic:456, full:trueโ] ddd [/quote] MD html = <<~HTML HTML expect(PrettyText.cook(md)).to eq(html.strip) end it "trims spaces on quote params" do md = <<~MD [quote="#{user.username}, post:555, topic: 666"] ddd [/quote] MD html = <<~HTML HTML expect(PrettyText.cook(md)).to eq(html.strip) end end context "with primary user group" do let(:default_avatar) do "//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/{size}.png" end fab!(:group) fab!(:user) { Fabricate(:user, primary_group: group) } before { User.stubs(:default_template).returns(default_avatar) } it "adds primary group class to referenced users quote" do topic = Fabricate(:topic, title: "this is a test topic") expected = <<~HTML HTML expect( cook( "[quote=\"#{user.username}, post:2, topic:#{topic.id}\"]\nddd\n[/quote]", topic_id: 1, ), ).to eq(n(expected)) end end it "can handle inline block bbcode" do cooked = PrettyText.cook("[quote]te **s** t[/quote]") html = <<~HTML HTML expect(cooked).to eq(html.strip) end it "handles bbcode edge cases" do expect(PrettyText.cook "[constructor]\ntest").to eq("This is a quote with a Unicode emoji
[constructor]
\ntest
test test
\n@bob
", ) end it "should handle 3 mentions in a row" do expect( PrettyText.cook("@hello @hello @hello"), ).to match_html "@hello @hello @hello
" end it "can handle mention edge cases" do expect(PrettyText.cook("hi\n@s")).to eq("hi
\n@s
hi
\n@ss
hi
\n@s.
hi
\n@s.s
hi
\n@.s.s
hi
\n@user. @GROUP @somemention @group2
test @#{group.name} test
|, ) end it "assigns the notify class if the user can mention" do group = Fabricate( :group, visibility_level: Group.visibility_levels[:members], mentionable_level: Group::ALIAS_LEVELS[:members_mods_and_admins], ) expect(PrettyText.cook("test @#{group.name} test", user_id: Fabricate(:admin).id)).to eq( %Q|test @#{group.name} test
|, ) end it "does not mention staged users" do user = Fabricate(:user, staged: true) expect(PrettyText.cook("something @#{user.username} something")).to eq( %Q|something @#{user.username} something
|, ) end context "when mentions are disabled" do before { SiteSetting.enable_mentions = false } it "should not convert mentions to links" do expect(PrettyText.cook("hi @user")).to eq("hi @user
") end end it "can handle mentions inside a hyperlink" do expect(PrettyText.cook(" @inner ")).to match_html "" end it "can handle mentions inside a hyperlink" do expect( PrettyText.cook("[link @inner](http://site.com)"), ).to match_html '' end it "can handle a list of mentions" do expect(PrettyText.cook("@a,@b")).to match_html( '@a,@b
', ) end it "should handle group mentions with a hyphen and without" do expect( PrettyText.cook("@hello @hello-hello"), ).to match_html "@hello @hello-hello
" end it "should allow for @mentions to have punctuation" do expect(PrettyText.cook("hello @bob's @bob,@bob; @bob\"")).to match_html( "hello @bob's @bob,@bob; @bob\"
", ) end it "should not treat a medium link as a mention" do expect(PrettyText.cook(". http://test/@sam")).not_to include("mention") end context "with Unicode usernames disabled" do before { SiteSetting.unicode_usernames = false } it "does not detect mention" do expect(PrettyText.cook("Hello @็ฎๅญ")).to_not include("mention") end end context "with Unicode usernames enabled" do before { SiteSetting.unicode_usernames = true } it "does detect mention" do expect( PrettyText.cook("Hello @็ฎๅญ"), ).to match_html 'Hello @็ฎๅญ
' end end context "with pretty_text_extract_mentions modifier" do it "allows changing the mentions extracted" do cooked_html = <<~HTML@test, @test-group, @test-custom, test1, this is a test
HTML extracted_mentions = PrettyText.extract_mentions(Nokogiri::HTML5.fragment(cooked_html)) expect(extracted_mentions).to contain_exactly("test", "test-group") Plugin::Instance .new .register_modifier(:pretty_text_extract_mentions) do |mentions, cooked_text| custom_mentions = cooked_text .css(".custom-mention") .map do |e| if (name = e.inner_text) name = name[1..-1] name = User.normalize_username(name) name end end mentions + custom_mentions end extracted_mentions = PrettyText.extract_mentions(Nokogiri::HTML5.fragment(cooked_html)) expect(extracted_mentions).to include("test", "test-group", "test-custom") ensure DiscoursePluginRegistry.clear_modifiers! end end end describe "code fences" do it "indents code correctly" do code = <<~MD X ``` # x ``` MD cooked = PrettyText.cook(code) html = <<~HTMLX
#
x
HTML
expect(cooked).to eq(html.strip)
end
it "doesn't replace emoji in code blocks with our emoji sets if emoji is enabled" do
expect(PrettyText.cook("```\n๐ฃ`\n```\n")).not_to match(/\:bomb\:/)
end
it "can include code class correctly" do
SiteSetting.highlighted_languages += "|c++|structured-text|p21"
# keep in mind spaces should be trimmed per spec
expect(PrettyText.cook("``` ruby the mooby\n`````")).to eq(
'
',
)
expect(PrettyText.cook("```cpp\ncpp\n```")).to match_html(
"cpp\n
",
)
expect(PrettyText.cook("```\ncpp\n```")).to match_html(
"cpp\n
",
)
expect(PrettyText.cook("```text\ncpp\n```")).to match_html(
"cpp\n
",
)
expect(PrettyText.cook("```custom\ncustom content\n```")).to match_html(
"custom content\n
",
)
expect(PrettyText.cook("```custom foo=bar\ncustom content\n```")).to match_html(
"custom content
",
)
expect(PrettyText.cook("```INVALID a=1, foo=bar , baz=2\n```")).to match_html(
"\n
",
)
expect(PrettyText.cook("```text\n```")).to match_html(
"\n
",
)
expect(PrettyText.cook("```auto\n```")).to match_html(
"\n
",
)
expect(PrettyText.cook("```ruby startline=3 $%@#\n```")).to match_html(
"\n
",
)
expect(PrettyText.cook("```mermaid a_-ไฝ =17\n```")).to match_html(
"\n
",
)
expect(
PrettyText.cook("```mermaid foo=\n```"),
).to match_html(
"\n
",
)
# Check unicode bidi characters are stripped:
expect(PrettyText.cook("```mermaid foo=\u202E begin admin o\u001C\n```")).to match_html(
"\n
",
)
expect(PrettyText.cook("```c++\nc++\n```")).to match_html(
"c++\n
",
)
expect(PrettyText.cook("```structured-text\nstructured-text\n```")).to match_html(
"structured-text\n
",
)
expect(PrettyText.cook("```p21\np21\n```")).to match_html(
"p21\n
",
)
expect(
PrettyText.cook(""),
).to match_html("")
end
it "indents code correctly" do
code = "X\n```\n\n #\n x\n```"
cooked = PrettyText.cook(code)
expect(cooked).to match_html(
"X
\n\n #\n x\n
",
)
end
it "does censor code fences" do
begin
%w[apple banana].each do |w|
Fabricate(:watched_word, word: w, action: WatchedWord.actions[:censor])
end
expect(PrettyText.cook("# banana")).not_to include("banana")
ensure
Discourse.redis.flushdb
end
end
it "strips out unicode bidirectional (bidi) override characters and replaces with a highlighted span" do
code = <<~MD
X
```auto
var isAdmin = false;
/*โฎ begin admin only */โฆ if (isAdmin) โฉ โฆ {
console.log("You are an admin.");
/* end admins only โฎ*/โฆ }
```
MD
cooked = PrettyText.cook(code)
hidden_bidi_title = I18n.t("post.hidden_bidi_character")
html = <<~HTML
X
var isAdmin = false;
/*<U+202E> begin admin only */<U+2066> if (isAdmin) <U+2069> <U+2066> {
console.log("You are an admin.");
/* end admins only <U+202E>*/<U+2066> }
HTML
expect(cooked).to eq(html.strip)
end
it "fuzzes all possible dangerous unicode bidirectional (bidi) override characters, making sure they are replaced" do
bad_bidi = [
"\u202A",
"\u202B",
"\u202C",
"\u202D",
"\u202E",
"\u2066",
"\u2067",
"\u2068",
"\u2069",
]
bad_bidi.each do |bidi|
code = <<~MD
```
#{bidi}
```
MD
cooked = PrettyText.cook(code)
formatted_bidi = format("<U+%04X>", bidi.ord)
html = <<~HTML
#{formatted_bidi}
HTML
expect(cooked).to eq(html.strip)
end
end
it "fuzzes all possible dangerous unicode bidirectional (bidi) override characters in solo code and pre nodes, making sure they are replaced" do
bad_bidi = [
"\u202A",
"\u202B",
"\u202C",
"\u202D",
"\u202E",
"\u2066",
"\u2067",
"\u2068",
"\u2069",
]
bad_bidi.each do |bidi|
code = <<~MD
#{bidi}
MD
cooked = PrettyText.cook(code)
formatted_bidi = format("<U+%04X>", bidi.ord)
html = <<~HTML
#{formatted_bidi}
#{bidi}MD cooked = PrettyText.cook(code) formatted_bidi = format("<U+%04X>", bidi.ord) html = <<~HTML
#{formatted_bidi}
HTML
expect(cooked).to eq(html.strip)
end
end
end
describe "rel attributes" do
before do
SiteSetting.add_rel_nofollow_to_user_content = true
SiteSetting.exclude_rel_nofollow_domains = "foo.com|bar.com"
end
it "should inject nofollow in all user provided links" do
expect(PrettyText.cook('cnn')).to match(/noopener nofollow ugc/)
end
it "should not inject nofollow in all local links" do
expect(
PrettyText.cook("cnn") !~ /nofollow ugc/,
).to eq(true)
end
it "should not inject nofollow in all subdomain links" do
expect(
PrettyText.cook(
"cnn",
) !~ /nofollow ugc/,
).to eq(true)
end
it "should inject nofollow in all non subdomain links" do
expect(
PrettyText.cook(
"cnn",
),
).to match(/nofollow ugc/)
end
it "should not inject nofollow for foo.com" do
expect(PrettyText.cook("cnn") !~ /nofollow ugc/).to eq(
true,
)
end
it "should inject nofollow for afoo.com" do
expect(PrettyText.cook("cnn")).to match(
/nofollow ugc/,
)
end
it "should not inject nofollow for bar.foo.com" do
expect(
PrettyText.cook("cnn") !~ /nofollow ugc/,
).to eq(true)
end
it "should not inject nofollow if omit_nofollow option is given" do
expect(
PrettyText.cook('cnn', omit_nofollow: true) !~ /nofollow ugc/,
).to eq(true)
end
it "adds the noopener attribute even if omit_nofollow option is given" do
raw_html = 'Check out my site!'
expect(PrettyText.cook(raw_html, omit_nofollow: true)).to match(/noopener/)
end
it "adds the noopener attribute even if omit_nofollow option is given" do
raw_html = 'Check out my site!'
expect(PrettyText.cook(raw_html, omit_nofollow: false)).to match(/noopener nofollow ugc/)
end
end
describe "Excerpt" do
it "sanitizes attempts to inject invalid attributes" do
spinner = "", 100)).to eq("[image]")
end
context "with alt tags" do
it "should keep alt tags" do
expect(
PrettyText.excerpt(
"",
100,
),
).to eq("[car]")
end
describe "when alt tag is empty" do
it "should not keep alt tags" do
expect(PrettyText.excerpt("", 100)).to eq(
"[#{I18n.t("excerpt_image")}]",
)
end
end
end
context "with title tags" do
it "should keep title tags" do
expect(PrettyText.excerpt("", 100)).to eq(
"[car]",
)
end
describe "when title tag is empty" do
it "should not keep title tags" do
expect(PrettyText.excerpt("", 100)).to eq(
"[#{I18n.t("excerpt_image")}]",
)
end
end
end
it "should convert images to markdown if the option is set" do
expect(
PrettyText.excerpt(
"",
100,
markdown_images: true,
),
).to eq("![car](http://cnn.com/a.gif)")
end
it "should keep details if too long" do
expect(
PrettyText.excerpt("hello
hello
", 100)).to eq("") end it "should truncate stuff properly" do expect(PrettyText.excerpt("hello world", 5)).to eq("hello…") expect(PrettyText.excerpt("
hello
world
", 6)).to eq("hello w…") end it "should insert a space between to Ps" do expect(PrettyText.excerpt("a
b
", 5)).to eq("a b") end it "should strip quotes" do expect(PrettyText.excerpt("boom", 5)).to eq( "boom", ) end it "should not count the surrounds of a link" do expect( PrettyText.excerpt("cnn", 3), ).to match_html "cnn" end it "uses an ellipsis instead of html entities if provided with the option" do expect( PrettyText.excerpt("cnn", 2, text_entities: true), ).to match_html "cn..." end it "should truncate links" do expect( PrettyText.excerpt("cnn", 2), ).to match_html "cn…" end it "doesn't extract empty quotes as links" do expect( PrettyText.extract_links("\n").to_a, ).to be_empty end it "doesn't extract links from elided parts" do expect( PrettyText.extract_links( "HTML expect(extract_urls(html)).to eq(["https://example.com"]) end context "when lazy-videos" do it "should extract youtube url" do expect( extract_urls( "", ), ).to eq(["https://www.youtube.com/watch?v=yXEuEUQIP3Q"]) end it "should extract vimeo url" do expect( extract_urls( "", ), ).to eq(["https://vimeo.com/786646692"]) end it "should extract tiktok url" do expect( extract_urls( "", ), ).to eq(["https://m.tiktok.com/v/6718335390845095173"]) end end it "should extract links to posts" do expect( extract_urls(""), ).to eq(["/t/1234/4567"]) end it "should not extract links to anchors" do expect(extract_urls("TOS")).to eq([]) end it "should not extract links inside quotes" do links = PrettyText.extract_links( " http://useless1.com http://useless2.com ", ) expect(links.map { |l| [l.url, l.is_quote] }.sort).to eq( [ ["http://body_only.com", false], ["http://body_and_quote.com", false], ["/t/1234", true], ].sort, ) end it "should not extract links inside oneboxes" do onebox = <<~HTML HTML expect(PrettyText.extract_links(onebox).map(&:url)).to contain_exactly( "https://twitter.com/EDBPostgres/status/1402528437441634306", ) end it "should not preserve tags in code blocks" do expect( PrettyText.excerpt( "
<h3>Hours</h3>
",
100,
),
).to eq("<h3>Hours</h3>")
end
it "should handle nil" do
expect(PrettyText.excerpt(nil, 100)).to eq("")
end
it "handles custom bbcode excerpt" do
raw = <<~MD
[excerpt]
hello [site](https://site.com)
[/excerpt]
more stuff
MD
post = Fabricate(:post, raw: raw)
expect(post.excerpt).to eq(
"hello site",
)
end
it "handles div excerpt at the beginning of a post" do
expect(PrettyText.excerpt("Listen to this!
HTML expect(PrettyText.excerpt(html, 100)).to eq("Listen to this!") html = <<~HTMLWatch this, but do not include the video in the excerpt.
HTML ellipsis = "…" excerpt_size = 40 excerpt = PrettyText.excerpt(html, excerpt_size) expect(excerpt.size).to eq(excerpt_size + ellipsis.size) expect(excerpt).to eq("Watch this, but do not include the video#{ellipsis}") end end describe "strip links" do it "returns blank for blank input" do expect(PrettyText.strip_links("")).to be_blank end it "does nothing to a string without links" do expect(PrettyText.strip_links("I'm the batman")).to eq("I'm the batman") end it "strips links but leaves the text content" do expect( PrettyText.strip_links( "I'm the linked batman", ), ).to eq("I'm the linked batman") end it "escapes the text content" do expect( PrettyText.strip_links( "I'm the linked <batman>", ), ).to eq("I'm the linked <batman>") end end describe "strip_image_wrapping" do def strip_image_wrapping(html) doc = Nokogiri::HTML5.fragment(html) described_class.strip_image_wrapping(doc) doc.to_html end it "doesn't change HTML when there's no wrapped image" do html = "" expect(strip_image_wrapping(html)).to eq(html) end it "strips the metadata" do expect( strip_image_wrapping(wrapped_image), ).to match_html "" end end describe "format_for_email" do context "when (sub)domain" do before { Discourse.stubs(:base_path).returns("") } it "does not crash" do html = <<~HTML test HTML expect(described_class.format_for_email(html, post)).to eq <<~HTML test HTML end it "adds base url to relative links" do html = <<~HTML@wiseguy, @trollol what do you guys think?
HTML expect(described_class.format_for_email(html, post)).to eq <<~HTML@wiseguy, @trollol what do you guys think?
HTML end it "doesn't change external absolute links" do html = <<~HTMLCheck out this guy.
HTML expect(described_class.format_for_email(html, post)).to eq(html) end it "doesn't change internal absolute links" do html = <<~HTMLCheck out this guy.
HTML expect(described_class.format_for_email(html, post)).to eq(html) end it "can tolerate invalid URLs" do html = <<~HTMLCheck out this guy.
HTML expect(described_class.format_for_email(html, post)).to eq(html) end it "doesn't change mailto" do html = <<~HTMLContact me at this address.
HTML expect(described_class.format_for_email(html, post)).to eq(html) end it "prefers data-original-href attribute to get Vimeo iframe link and escapes it" do html = <<~HTMLCheck out this video โ .
HTML expect(described_class.format_for_email(html, post)).to match( Regexp.escape("https://vimeo.com/329875646/%3E%20%3Cscript%3Ealert(1)%3C/script%3E"), ) end it "creates a valid URL when data-original-href is missing from Vimeo link" do html = <<~HTML HTML expect(described_class.format_for_email(html, post)).to match( "https://vimeo.com/508864124/fcbbcc92fa", ) end describe "#convert_vimeo_iframes" do it "converts