# frozen_string_literal: true

require_relative "common"

describe "Sanitize::CSS" do
  make_my_diffs_pretty!
  parallelize_me!

  describe "instance methods" do
    before do
      @default = Sanitize::CSS.new
      @relaxed = Sanitize::CSS.new(Sanitize::Config::RELAXED[:css])
      @custom = Sanitize::CSS.new(properties: %w[background color width])
    end

    describe "#properties" do
      it "should sanitize CSS properties" do
        css = 'background: #fff; width: expression(alert("hi"));'

        _(@default.properties(css)).must_equal " "
        _(@relaxed.properties(css)).must_equal "background: #fff; "
        _(@custom.properties(css)).must_equal "background: #fff; "
      end

      it "should allow allowlisted URL protocols" do
        [
          "background: url(relative.jpg)",
          "background: url('relative.jpg')",
          "background: url(http://example.com/http.jpg)",
          "background: url('ht\\tp://example.com/http.jpg')",
          "background: url(https://example.com/https.jpg)",
          "background: url('https://example.com/https.jpg')",
          "background: image-set('relative.jpg' 1x, 'relative-2x.jpg' 2x)",
          "background: image-set('https://example.com/https.jpg' 1x, 'https://example.com/https-2x.jpg' 2x)",
          "background: image-set('https://example.com/https.jpg' type('image/jpeg'), 'https://example.com/https.avif' type('image/avif'))",
          "background: -webkit-image-set('relative.jpg' 1x, 'relative-2x.jpg' 2x)",
          "background: -webkit-image-set('https://example.com/https.jpg' 1x, 'https://example.com/https-2x.jpg' 2x)",
          "background: -webkit-image-set('https://example.com/https.jpg' type('image/jpeg'), 'https://example.com/https.avif' type('image/avif'))",
          "background: image('relative.jpg');",
          "background: image('https://example.com/https.jpg');",
          "background: image(rtl 'https://example.com/https.jpg');"
        ].each do |css|
          _(@default.properties(css)).must_equal ""
          _(@relaxed.properties(css)).must_equal css
          _(@custom.properties(css)).must_equal ""
        end
      end

      it "should not allow non-allowlisted URL protocols" do
        [
          "background: url(javascript:alert(0))",
          "background: url(ja\\56 ascript:alert(0))",
          "background: url('javascript:foo')",
          "background: url('ja\\56 ascript:alert(0)')",
          "background: url('ja\\va\\script\\:alert(0)')",
          "background: url('javas\\\ncript:alert(0)')",
          "background: url('java\\0script:foo')"
        ].each do |css|
          _(@default.properties(css)).must_equal ""
          _(@relaxed.properties(css)).must_equal ""
          _(@custom.properties(css)).must_equal ""
        end
      end

      it "should not allow -moz-binding" do
        css = "-moz-binding:url('http://ha.ckers.org/xssmoz.xml#xss')"

        _(@default.properties(css)).must_equal ""
        _(@relaxed.properties(css)).must_equal ""
        _(@custom.properties(css)).must_equal ""
      end

      it "should not allow expressions" do
        [
          "width:expression(alert(1))",
          "width:  /**/expression(alert(1)",
          "width:e\\78 pression(\n\nalert(\n1)",
          "width:\nexpression(alert(1));",
          "xss:expression(alert(1))",
          "height: foo(expression(alert(1)));"
        ].each do |css|
          _(@default.properties(css)).must_equal ""
          _(@relaxed.properties(css)).must_equal ""
          _(@custom.properties(css)).must_equal ""
        end
      end

      it "should not allow behaviors" do
        css = "behavior: url(xss.htc);"

        _(@default.properties(css)).must_equal ""
        _(@relaxed.properties(css)).must_equal ""
        _(@custom.properties(css)).must_equal ""
      end

      describe "when :allow_comments is true" do
        it "should preserve comments" do
          _(@relaxed.properties("color: #fff; /* comment */ width: 100px;"))
            .must_equal "color: #fff; /* comment */ width: 100px;"

          _(@relaxed.properties("color: #fff; /* \n\ncomment */ width: 100px;"))
            .must_equal "color: #fff; /* \n\ncomment */ width: 100px;"
        end
      end

      describe "when :allow_comments is false" do
        it "should strip comments" do
          _(@custom.properties("color: #fff; /* comment */ width: 100px;"))
            .must_equal "color: #fff;  width: 100px;"

          _(@custom.properties("color: #fff; /* \n\ncomment */ width: 100px;"))
            .must_equal "color: #fff;  width: 100px;"
        end
      end

      describe "when :allow_hacks is true" do
        it "should allow common CSS hacks" do
          _(@relaxed.properties("_border: 1px solid #fff; *width: 10px"))
            .must_equal "_border: 1px solid #fff; *width: 10px"
        end
      end

      describe "when :allow_hacks is false" do
        it "should not allow common CSS hacks" do
          _(@custom.properties("_border: 1px solid #fff; *width: 10px"))
            .must_equal " "
        end
      end
    end

    describe "#stylesheet" do
      it "should sanitize a CSS stylesheet" do
        css = %[
          /* Yay CSS! */
          .foo { color: #fff; }
          #bar { background: url(yay.jpg); }

          @media screen (max-width:480px) {
            .foo { width: 400px; }
            #bar:not(.baz) { height: 100px; }
          }
        ].strip

        _(@default.stylesheet(css).strip).must_equal %(
          .foo {  }
          #bar {  }
        ).strip

        _(@relaxed.stylesheet(css)).must_equal css

        _(@custom.stylesheet(css).strip).must_equal %(
          .foo { color: #fff; }
          #bar {  }
        ).strip
      end

      describe "when :allow_comments is true" do
        it "should preserve comments" do
          _(@relaxed.stylesheet(".foo { color: #fff; /* comment */ width: 100px; }"))
            .must_equal ".foo { color: #fff; /* comment */ width: 100px; }"

          _(@relaxed.stylesheet(".foo { color: #fff; /* \n\ncomment */ width: 100px; }"))
            .must_equal ".foo { color: #fff; /* \n\ncomment */ width: 100px; }"
        end
      end

      describe "when :allow_comments is false" do
        it "should strip comments" do
          _(@custom.stylesheet(".foo { color: #fff; /* comment */ width: 100px; }"))
            .must_equal ".foo { color: #fff;  width: 100px; }"

          _(@custom.stylesheet(".foo { color: #fff; /* \n\ncomment */ width: 100px; }"))
            .must_equal ".foo { color: #fff;  width: 100px; }"
        end
      end

      describe "when :allow_hacks is true" do
        it "should allow common CSS hacks" do
          _(@relaxed.stylesheet(".foo { _border: 1px solid #fff; *width: 10px }"))
            .must_equal ".foo { _border: 1px solid #fff; *width: 10px }"
        end
      end

      describe "when :allow_hacks is false" do
        it "should not allow common CSS hacks" do
          _(@custom.stylesheet(".foo { _border: 1px solid #fff; *width: 10px }"))
            .must_equal ".foo {  }"
        end
      end
    end

    describe "#tree!" do
      it "should sanitize a Crass CSS parse tree" do
        tree = Crass.parse("@import url(foo.css);\n" \
          ".foo { background: #fff; font: 16pt 'Comic Sans MS'; }\n" \
          "#bar { top: 125px; background: green; }")

        _(@custom.tree!(tree)).must_be_same_as tree

        _(Crass::Parser.stringify(tree)).must_equal "\n" \
          ".foo { background: #fff;  }\n" \
          "#bar {  background: green; }"
      end
    end
  end

  describe "class methods" do
    describe ".properties" do
      it "should sanitize CSS properties with the given config" do
        css = 'background: #fff; width: expression(alert("hi"));'

        _(Sanitize::CSS.properties(css)).must_equal " "
        _(Sanitize::CSS.properties(css, Sanitize::Config::RELAXED[:css])).must_equal "background: #fff; "
        _(Sanitize::CSS.properties(css, properties: %w[background color width])).must_equal "background: #fff; "
      end
    end

    describe ".stylesheet" do
      it "should sanitize a CSS stylesheet with the given config" do
        css = %[
          /* Yay CSS! */
          .foo { color: #fff; }
          #bar { background: url(yay.jpg); }

          @media screen (max-width:480px) {
            .foo { width: 400px; }
            #bar:not(.baz) { height: 100px; }
          }
        ].strip

        _(Sanitize::CSS.stylesheet(css).strip).must_equal %(
          .foo {  }
          #bar {  }
        ).strip

        _(Sanitize::CSS.stylesheet(css, Sanitize::Config::RELAXED[:css])).must_equal css

        _(Sanitize::CSS.stylesheet(css, properties: %w[background color width]).strip).must_equal %(
          .foo { color: #fff; }
          #bar {  }
        ).strip
      end
    end

    describe ".tree!" do
      it "should sanitize a Crass CSS parse tree with the given config" do
        tree = Crass.parse("@import url(foo.css);\n" \
          ".foo { background: #fff; font: 16pt 'Comic Sans MS'; }\n" \
          "#bar { top: 125px; background: green; }")

        _(Sanitize::CSS.tree!(tree, properties: %w[background color width])).must_be_same_as tree

        _(Crass::Parser.stringify(tree)).must_equal "\n" \
          ".foo { background: #fff;  }\n" \
          "#bar {  background: green; }"
      end
    end
  end

  describe "functionality" do
    before do
      @default = Sanitize::CSS.new
      @relaxed = Sanitize::CSS.new(Sanitize::Config::RELAXED[:css])
    end

    # https://github.com/rgrove/sanitize/issues/121
    it "should parse the contents of @media rules properly" do
      css = '@media { p[class="center"] { text-align: center; }}'
      _(@relaxed.stylesheet(css)).must_equal css

      css = %[
        @media (max-width: 720px) {
          p.foo > .bar { float: right; width: expression(body.scrollLeft + 50 + 'px'); }
          #baz { color: green; }

          @media (orientation: portrait) {
            #baz { color: red; }
          }
        }
      ].strip

      _(@relaxed.stylesheet(css)).must_equal %[
        @media (max-width: 720px) {
          p.foo > .bar { float: right;  }
          #baz { color: green; }

          @media (orientation: portrait) {
            #baz { color: red; }
          }
        }
      ].strip
    end

    it "should parse @page rules properly" do
      css = %[
        @page { margin: 2cm } /* All margins set to 2cm */

        @page :right {
          @top-center { content: "Preliminary edition" }
          @bottom-center { content: counter(page) }
        }

        @page {
          size: 8.5in 11in;
          margin: 10%;

          @top-left {
            content: "Hamlet";
          }
          @top-right {
            content: "Page " counter(page);
          }
        }
      ].strip

      _(@relaxed.stylesheet(css)).must_equal css
    end

    describe ":at_rules" do
      it "should remove blockless at-rules that aren't allowlisted" do
        css = %[
          @charset 'utf-8';
          @import url('foo.css');
          .foo { color: green; }
        ].strip

        _(@relaxed.stylesheet(css).strip).must_equal %(
          .foo { color: green; }
        ).strip
      end

      it "preserves allowlisted @container at-rules" do
        # Sample code courtesy of MDN:
        # https://developer.mozilla.org/en-US/docs/Web/CSS/@container
        css = %(
          @container (width > 400px) {
            h2 {
              font-size: 1.5em;
            }
          }

          /* with an optional <container-name> */
          @container tall (height > 30rem) {
            h2 {
              line-height: 1.6;
            }
          }

          /* multiple queries in a single condition */
          @container (width > 400px) and style(--responsive: true) {
            h2 {
              font-size: 1.5em;
            }
          }

          /* condition list */
          @container card (width > 400px), style(--responsive: true) {
            h2 {
              font-size: 1.5em;
            }
          }
        ).strip

        _(@relaxed.stylesheet(css).strip).must_equal css
      end

      describe "when blockless at-rules are allowlisted" do
        before do
          @scss = Sanitize::CSS.new(Sanitize::Config.merge(Sanitize::Config::RELAXED[:css], {
            at_rules: ["charset", "import"]
          }))
        end

        it "should not remove them" do
          css = %[
            @charset 'utf-8';
            @import url('foo.css');
            .foo { color: green; }
          ].strip

          _(@scss.stylesheet(css)).must_equal %[
            @charset 'utf-8';
            @import url('foo.css');
            .foo { color: green; }
          ].strip
        end

        it "should remove them if they have invalid blocks" do
          css = %(
            @charset { color: green }
            @import { color: green }
            .foo { color: green; }
          ).strip

          _(@scss.stylesheet(css).strip).must_equal %(
            .foo { color: green; }
          ).strip
        end
      end

      describe "when validating @import rules" do
        describe "with no validation proc specified" do
          before do
            @scss = Sanitize::CSS.new(Sanitize::Config.merge(Sanitize::Config::RELAXED[:css], {
              at_rules: ["import"]
            }))
          end

          it "should allow any URL value" do
            css = %[
              @import url('https://somesite.com/something.css');
            ].strip

            _(@scss.stylesheet(css).strip).must_equal %[
              @import url('https://somesite.com/something.css');
            ].strip
          end
        end

        describe "with a validation proc specified" do
          before do
            google_font_validator = proc { |url| url.start_with?("https://fonts.googleapis.com") }

            @scss = Sanitize::CSS.new(Sanitize::Config.merge(Sanitize::Config::RELAXED[:css], {
              at_rules: ["import"], import_url_validator: google_font_validator
            }))
          end

          it "should allow a google fonts url" do
            css = %[
              @import 'https://fonts.googleapis.com/css?family=Indie+Flower';
              @import url('https://fonts.googleapis.com/css?family=Indie+Flower');
            ].strip

            _(@scss.stylesheet(css).strip).must_equal %[
              @import 'https://fonts.googleapis.com/css?family=Indie+Flower';
              @import url('https://fonts.googleapis.com/css?family=Indie+Flower');
            ].strip
          end

          it "should not allow a nasty url" do
            css = %[
              @import 'https://fonts.googleapis.com/css?family=Indie+Flower';
              @import 'https://nastysite.com/nasty_hax0r.css';
              @import url('https://nastysite.com/nasty_hax0r.css');
            ].strip

            _(@scss.stylesheet(css).strip).must_equal %(
              @import 'https://fonts.googleapis.com/css?family=Indie+Flower';
            ).strip
          end

          it "should not allow a blank url" do
            css = %[
              @import 'https://fonts.googleapis.com/css?family=Indie+Flower';
              @import '';
              @import url('');
            ].strip

            _(@scss.stylesheet(css).strip).must_equal %(
              @import 'https://fonts.googleapis.com/css?family=Indie+Flower';
            ).strip
          end
        end
      end
    end
  end
end
