|
14 | 14 | # end |
15 | 15 | module BetterTogether |
16 | 16 | RSpec.describe Content::BlocksHelper do |
17 | | - it 'exists' do |
18 | | - expect(described_class).to be # rubocop:todo RSpec/Be |
| 17 | + describe '#sanitize_block_css' do |
| 18 | + context 'with safe CSS' do |
| 19 | + it 'returns the CSS unchanged' do |
| 20 | + css = '.my-class { color: red; }' |
| 21 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 22 | + end |
| 23 | + |
| 24 | + it 'preserves attribute selectors with quotes' do |
| 25 | + css = '.notification form[action*="mark_as_read"] .btn[type="submit"] { z-index: 1200; }' |
| 26 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 27 | + end |
| 28 | + |
| 29 | + it 'preserves child selectors' do |
| 30 | + css = '.card.journey-stage > .card-body { max-height: 50vh; }' |
| 31 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 32 | + end |
| 33 | + |
| 34 | + it 'preserves pseudo-selectors with quotes' do |
| 35 | + css = '.trix-content a[href]:not([href*="example.com"])::after { content: "\f35d"; }' |
| 36 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 37 | + end |
| 38 | + |
| 39 | + it 'preserves media queries' do |
| 40 | + css = '@media only screen and (min-width: 768px) { .hero-heading { font-size: 3em; } }' |
| 41 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 42 | + end |
| 43 | + |
| 44 | + it 'preserves CSS custom properties' do |
| 45 | + css = '.navbar { --bs-navbar-toggler-padding-x: 0.25rem; }' |
| 46 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 47 | + end |
| 48 | + |
| 49 | + it 'preserves important declarations' do |
| 50 | + css = '.element { color: #404de0 !important; }' |
| 51 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 52 | + end |
| 53 | + |
| 54 | + it 'preserves multiple selectors with various characters' do |
| 55 | + css = '.content_rich_text a, trix-editor a, .trix-content a { text-decoration: none; }' |
| 56 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 57 | + end |
| 58 | + end |
| 59 | + |
| 60 | + context 'with dangerous CSS patterns' do |
| 61 | + it 'removes expression() calls' do |
| 62 | + css = 'width: expression(alert("XSS"));' |
| 63 | + sanitized = helper.sanitize_block_css(css) |
| 64 | + expect(sanitized).not_to include('expression(') |
| 65 | + expect(sanitized).to eq('width: alert("XSS"));') |
| 66 | + end |
| 67 | + |
| 68 | + it 'removes expression() with different casing' do |
| 69 | + css = 'width: ExPrEsSiOn(alert("XSS"));' |
| 70 | + sanitized = helper.sanitize_block_css(css) |
| 71 | + expect(sanitized).not_to match(/expression\s*\(/i) |
| 72 | + end |
| 73 | + |
| 74 | + it 'removes javascript: URLs in url()' do |
| 75 | + css = 'background: url(javascript:alert("XSS"));' |
| 76 | + sanitized = helper.sanitize_block_css(css) |
| 77 | + expect(sanitized).not_to include('javascript:') |
| 78 | + # NOTE: The regex replaces the entire url(...) content but preserves closing paren |
| 79 | + expect(sanitized).to eq('background: url(""));') |
| 80 | + end |
| 81 | + |
| 82 | + it 'preserves safe url() calls' do |
| 83 | + css = 'background: url("/images/bg.png");' |
| 84 | + expect(helper.sanitize_block_css(css)).to eq(css) |
| 85 | + end |
| 86 | + |
| 87 | + it 'removes multiple dangerous patterns' do |
| 88 | + css = 'width: expression(alert(1)); background: url(javascript:void(0));' |
| 89 | + sanitized = helper.sanitize_block_css(css) |
| 90 | + expect(sanitized).not_to include('expression(') |
| 91 | + expect(sanitized).not_to include('javascript:') |
| 92 | + end |
| 93 | + end |
| 94 | + |
| 95 | + context 'with edge cases' do |
| 96 | + it 'returns empty string for nil input' do |
| 97 | + expect(helper.sanitize_block_css(nil)).to eq('') |
| 98 | + end |
| 99 | + |
| 100 | + it 'returns empty string for blank input' do |
| 101 | + expect(helper.sanitize_block_css('')).to eq('') |
| 102 | + # NOTE: sanitize_block_css treats whitespace-only as blank |
| 103 | + expect(helper.sanitize_block_css(' ')).to eq('') |
| 104 | + end |
| 105 | + |
| 106 | + it 'handles very long CSS strings' do |
| 107 | + long_css = '.class { color: red; }' * 1000 |
| 108 | + expect(helper.sanitize_block_css(long_css)).to eq(long_css) |
| 109 | + end |
| 110 | + end |
| 111 | + end |
| 112 | + |
| 113 | + describe '#sanitize_block_html' do |
| 114 | + it 'allows safe HTML tags' do |
| 115 | + html = '<p>Hello <strong>world</strong></p>' |
| 116 | + expect(helper.sanitize_block_html(html)).to eq(html) |
| 117 | + end |
| 118 | + |
| 119 | + it 'removes script tags' do |
| 120 | + html = '<p>Hello</p><script>alert("XSS")</script>' |
| 121 | + sanitized = helper.sanitize_block_html(html) |
| 122 | + expect(sanitized).not_to include('<script>') |
| 123 | + expect(sanitized).to include('<p>Hello</p>') |
| 124 | + end |
| 125 | + |
| 126 | + it 'allows whitelisted attributes' do |
| 127 | + html = '<a href="http://example.com" class="link" target="_blank">Link</a>' |
| 128 | + expect(helper.sanitize_block_html(html)).to eq(html) |
| 129 | + end |
| 130 | + end |
| 131 | + |
| 132 | + describe '#acceptable_image_file_types' do |
| 133 | + it 'returns valid image content types' do |
| 134 | + types = helper.acceptable_image_file_types |
| 135 | + expect(types).to be_an(Array) |
| 136 | + expect(types).to include('image/jpeg', 'image/png', 'image/gif') |
| 137 | + end |
| 138 | + end |
| 139 | + |
| 140 | + describe '#temp_id_for' do |
| 141 | + let(:persisted_model) { double('Model', persisted?: true, id: 123) } |
| 142 | + let(:new_model) { double('Model', persisted?: false) } |
| 143 | + |
| 144 | + it 'returns model id for persisted models' do |
| 145 | + expect(helper.temp_id_for(persisted_model)).to eq(123) |
| 146 | + end |
| 147 | + |
| 148 | + it 'returns temp_id for new models' do |
| 149 | + temp_id = 'temp-uuid-123' |
| 150 | + expect(helper.temp_id_for(new_model, temp_id: temp_id)).to eq(temp_id) |
| 151 | + end |
| 152 | + |
| 153 | + it 'generates a UUID for new models by default' do |
| 154 | + result = helper.temp_id_for(new_model) |
| 155 | + expect(result).to be_a(String) |
| 156 | + expect(result).to match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) |
| 157 | + end |
19 | 158 | end |
20 | 159 | end |
21 | 160 | end |
0 commit comments