otwarchive-symphonyarchive/spec/models/skin_spec.rb
2026-03-11 22:22:11 +00:00

400 lines
16 KiB
Ruby

require 'spec_helper'
describe Skin do
describe "save" do
before(:each) do
@skin = Skin.new(title: "Test Skin")
end
# good css
{
"allows basic CSS including font family" =>
"body { background-color: #ffffff;}
h1 { font-family: 'Fertigo Pro', Verdana, serif; }",
"allows valid CSS shorthand values" =>
"body {background:#ffffff url('http://mywebsite.com/img_tree.png') no-repeat right top;}",
"allows images in the images directory" =>
"body {background:#ffffff url('/images/img_tree.png') no-repeat right top;}",
"allows unquoted URLs" =>
"body {background:#ffffff url(http://mywebsite.com/images/img_tree.png) no-repeat right top;}",
"allows comments on their own lines" =>
"/* starting comment */
li {color: green;}
/* middle comment */
dd {color: blue;}
/* end comment */",
"allows hsl(a) colors" =>
"ol {color: hsl(180, 100%, 50%);}
li {color: hsla(90, 30%, 70%, 50%);}",
"allows border-radius (CSS3 property)" =>
".profile { border-radius: 5px }",
"allows specific border radius properties" =>
".profile { border-bottom-right-radius: 10px; }",
"allows box-shadow (CSS3 property)" =>
".profile { box-shadow: 5px 5px 5px black; }",
"allows alphabetic strings as keyword values even if they are not explicitly listed" =>
"#main .navigation input { vertical-align: baseline; }
#header .navigation li { text-transform: capitalize; }
table { border-collapse: separate !important; }
",
"allows valid CSS3 rules using quoted strings as content." =>
"li.characters + li.freeforms:before {content: '||'}
li.relationships + li.freeforms:before { content: 'Freeform: '; }
li:before {content: url('http://foo.com/bullet.jpg')}",
"allows allowlisted image extensions" =>
".a { background: url('http://example.com/i.jpg'); }
.b { background: url('http://example.com/i.jpeg'); }
.c { background: url('http://example.com/i.png'); }
.d { background: url('http://example.com/i.gif'); }",
"allows properties that are variations on the ones in the shorthand config list" =>
"#main ul.sorting {
background: rgba(120,120,120,1) 5%;
-moz-border-radius:0.15em !important;
border-color:rgba(86,86,86,0.75) !important;
box-shadow:0 2px 5px rgba(0,0,0,0.5);
float:none !important;
text-align:center;
}
#main ul.sorting a {
border-color:rgba(86,86,86,1) !important;
color:rgba(231,231,231,1);
text-shadow:-1px -1px 0 rgba(0,0,0,0.75);
}
ul.sorting a:hover {
background: rgba(71,71,71,1) 5% !important;
color:rgba(254,254,254,1);
}
#main .navigation ul.sorting a:visited{
color:rgba(254,254,254,1)
}
.flex-container {
flex: 2 2 20em;
flex-flow: row-reverse wrap;
}",
"allows gradients, clip, scale, skew, translate, rotate" =>
"#main ul.sorting {
background:-moz-linear-gradient(bottom, rgba(120,120,120,1) 5%, rgba(94,94,94,1) 50%, rgba(108,108,108,1) 55%, rgba(137,137,137,1) 100%) ;
}
ul.sorting a:hover {
background:-webkit-linear-gradient(bottom, rgba(71,71,71,1) 5%, rgba(59,59,59,1) 50%, rgba(74,74,74,1) 55%, rgba(91,91,91,1) 100%) !important;
}
#main .clip {clip: rect(1em, 2em, 3em, 4em);}
#main li.blurb:nth-child(2n), #main.works-show .meta, .thread .thread li.comment:nth-child(3n+1) {-moz-transform: rotate(-0.5deg);}
#main .foo {-moz-transform:rotate(120deg); -moz-transform:skewx(25deg) translatex(150px);}
#menu {
background: -webkit-gradient(linear, left bottom, left top, color-stop(0, rgb(82,82,82)), color-stop(1, rgb(125,124,125)));
-webkit-box-shadow: 0 1px 2px #000;
-webkit-border-radius:2px;
-webkit-transition:text-shadow .7s ease-out, background .7s ease-out;
-webkit-transform: scale(2.1) rotate(-90deg)
}
#main .rotatevert {transform: rotatey(180deg);}
.rotatehoriz {transform: rotatex(50deg)}",
# TODO: Only one of the background properties is retained, but this test
# passes because we're only checking that the skin saves, not *what* is
# saved. AO3-7078 is for allowing multiple declarations using the same
# property within a given ruleset.
"allows multiple valid values for a single property" =>
"#outer .actions a:hover,symbol .question:hover,.actions input:hover,#outer input[type=\"submit\"]:hover,button:hover,.actions label:hover
{ background:#ddd;
background:-webkit-linear-gradient(top,#fafafa,#ddd);
background:-moz-linear-gradient(top,#fafafa,#ddd);
background:-ms-linear-gradient(top,#fafafa,#ddd);
background:-o-linear-gradient(top,#fafafa,#ddd);
background:linear-gradient(top,#fafafa,#ddd);
color:#555 }",
"allows color-scheme property and values" =>
".color_scheme_light { color-scheme: light; }
.color_scheme_only_dark { color-scheme: only dark; }",
"allows accent-color property" =>
"input[type='radio'] { accent-color: #900; }",
"allows filter properties" =>
".filter_blur { filter: blur(5px); }
.filter_brightness { filter: brightness(0.4); }
.filter_contrast { filter: contrast(200%); }
.filter_drop { filter: drop-shadow(16px 16px 20px blue); }
.filter_grayscale { filter: grayscale(50%); }
.filter_hue { filter: hue-rotate(90deg); }
.filter_invert { filter: invert(75%); }
.filter_opacity { filter: opacity(25%); }
.filter_saturate { filter: saturate(30%); }
.filter_sepia { filter: sepia(60%); }",
"allows filter properties with multiple values" =>
".filter_multi { filter: contrast(175%) brightness(3%) drop-shadow(3px 3px red) sepia(100%) drop-shadow(blue -3px -3px 5px); }",
"allows display property with flex values" =>
".flex-container { display: flex; }
.flex-container-inline { display: inline-flex; }",
"allows align-content property" =>
"div { align-content: flex-start; }",
"allows align-items property" =>
"div { align-items: stretch }",
"allows align-self property" =>
".flex-item { align-self: center; }",
"allows justify-content property" =>
"div { justify-content: flex-end; }",
"allows order property with negative value" =>
"div { order: -1 }",
"saves box shadows with multiple shadows" =>
"li { box-shadow: 5px 5px 5px black, inset 0 0 0 1px #dadada; }",
"saves very long CSS" =>
"#main { background: url(http://example.com/#{'a' * 70_000}.png); }"
}.each_pair do |condition, css|
it condition do
@skin.css = css
expect(@skin.save).to be_truthy
end
end
# bad bad bad css
{
"errors when saving garbage with braces" => "blhalkdfasd {ljaflkasjdflasd}",
"errors when saving garbage with braces and colon" => "blhalkdfasd {ljaflkasjdflasd: }",
"errors when saving garbage with invalid property" => "blhalkdfasd {ljaflkasjdflasd: aklsdfjsdf}",
"errors when saving urls with xss" => "body {-moz-binding:url('http://ha.ckers.org/xssmoz.xml#xss')}",
"errors when saving @font-face" => "@font-face { font-family: Delicious; src: url('Delicious-Roman.otf');}",
"errors when saving @import" => "@import url('http://ha.ckers.org/xss.css');",
"errors when saving src" => "body {border: src('http://foo.com/')}",
"errors when saving url for font" => "body {font: url(http://foo.com/bar.png)}",
"errors when saving htc urls" => "body {behavior: url(xss.htc);}",
"errors when saving javascript in li" => "li {background-image: url(javascript:alert('XSS'));}",
"errors when saving expression" => "div {width: expression(alert('XSS'));}",
"errors when saving javascript with escaped quote" => "div {background-image: url(javascript:alert('XSS'))}",
"errors when saving gradient with xss" => "div {background: -webkit-linear-gradient(url(xss.htc))}",
"errors when saving dsf images" => "body {background: url(http://foo.com/bar.dsf)}",
"errors when saving urls with invalid domain" => "body {background: url(http://foo.htc/bar.png)}",
"errors when saving xss interrupted with comments" => "div {xss:expr/*XSS*/ession(alert('XSS'))}",
"errors when saving url followed by something else" => 'a {content: url(/images/fakeimage.png) " (" attr(href) ")"}',
"errors when saving custom property with url function" => ":root { --address: url(\"https://example.org/img.jpg\") }"
}.each_pair do |condition, css|
it condition do
@skin.css = css
expect(@skin.save).not_to be_truthy
expect(@skin.errors[:base]).not_to be_empty
end
end
it "requires a title" do
@skin.title = ""
expect(@skin.save).not_to be_truthy
expect(@skin.errors[:title]).not_to be_empty
end
it "has a unique title" do
expect(@skin.save).to be_truthy
skin2 = Skin.new(title: "Test Skin")
expect(skin2.save).not_to be_truthy
expect(skin2.errors[:title]).not_to be_empty
end
it "has a unique title ignoring case" do
expect(@skin.save).to be_truthy
skin2 = Skin.new(title: "test skin")
expect(skin2.save).not_to be_truthy
expect(skin2.errors[:title]).not_to be_empty
end
it "requires a preview image if public" do
@skin.css = "body {background: #fff;}"
@skin.public = true
expect(@skin.save).not_to be_truthy
expect(@skin.errors[:base]).not_to be_empty
expect(@skin.errors[:base].join(' ').match(/upload a screencap/)).to be_truthy
end
context "when a media query is provided" do
[
"all",
"screen",
"handheld",
"speech",
"print",
"braille",
"embossed",
"projection",
"tty",
"tv",
"only screen and (max-width: 42em)",
"only screen and (max-width: 62em)",
"(prefers-color-scheme: dark)",
"(prefers-color-scheme: light)"
].each do |media_query|
it "allows #{media_query}" do
@skin.media = [media_query]
expect(@skin.save).to be_truthy
expect(@skin.errors[:base]).to be_empty
end
end
{
"doesn't allow max-width that isn't allowlisted" => "only screen and (max-width: 1024px)",
"doesn't allow media that isn't allowlisted" => "(min-aspect-ratio: 8/5)",
"doesn't allow two allowlisted media combined with and instead of a comma" => "screen and (prefers-color-scheme: dark",
"doesn't allow combination of allowlisted media and non-allowlisted media" => "(prefers-color-scheme: dark), (monochrome)"
}.each_pair do |description, media_query|
it description do
@skin.media = [media_query]
expect(@skin.save).not_to be_truthy
expect(@skin.errors[:base]).not_to be_empty
end
end
end
it "only allows valid roles" do
@skin.role = "foobar"
expect(@skin.save).not_to be_truthy
expect(@skin.errors[:role]).not_to be_empty
@skin.role = "override"
expect(@skin.save).to be_truthy
expect(@skin.errors[:role]).to be_empty
end
it "only allows valid ie-only conditions" do
@skin.ie_condition = "foobar"
expect(@skin.save).not_to be_truthy
expect(@skin.errors[:ie_condition]).not_to be_empty
@skin.ie_condition = "IE8_or_lower"
expect(@skin.save).to be_truthy
expect(@skin.errors[:ie_condition]).to be_empty
end
end
describe "use", default_skin: true do
before do
Skin.load_site_css
Skin.set_default_to_current_version
end
let(:css) { "body {background: purple;}" }
let(:skin) { Skin.create(title: "Test Skin", css: css) }
let(:style) { skin.get_style }
it "has a valid style block" do
expect(style).to match(%r{<style type="text/css" media="all">})
end
it "includes the css" do
expect(style).to match(/background: purple;/)
end
it "includes links to the default archive skin" do
expect(style).to match(%r{<link rel="stylesheet" type="text/css"})
end
end
describe ".approved_or_owned_by", default_skin: true do
let(:skin_owner) { FactoryBot.create(:user) }
let(:random_user) { FactoryBot.create(:user) }
before do
create(:work_skin, :private, author: skin_owner, title: "Private Skin 1")
create(:work_skin, :private, author: skin_owner, title: "Private Skin 2")
end
context "no user argument given" do
context "User.current_user is nil" do
it "returns approved skins" do
allow(User).to receive(:current_user).and_return(nil)
expect(Skin.approved_or_owned_by.pluck(:title)).to eq(['Default'])
end
end
context "when User.current_user is not nil" do
context "user does not own skins" do
it "returns approved skins" do
allow(User).to receive(:current_user).and_return(random_user)
expect(Skin.approved_or_owned_by.pluck(:title)).to eq(["Default"])
end
end
context "user owns skins" do
it "returns approved and owned skins" do
allow(User).to receive(:current_user).and_return(skin_owner)
expect(Skin.approved_or_owned_by.pluck(:title)).to eq(["Default", "Private Skin 1", "Private Skin 2"])
end
end
end
end
context "user argument is given" do
context "user is nil" do
it "returns approved skins" do
expect(Skin.approved_or_owned_by(nil).pluck(:title)).to eq(["Default"])
end
end
context "user is not nil" do
context "user does not own skins" do
it "returns approved skins" do
expect(Skin.approved_or_owned_by(random_user).pluck(:title)).to eq(["Default"])
end
end
context "user owns skins" do
it "returns approved and owned skins" do
expect(Skin.approved_or_owned_by(skin_owner).pluck(:title)).to eq(["Default",
"Private Skin 1",
"Private Skin 2"])
end
end
end
end
end
describe ".approved_or_owned_by_any", default_skin: true do
let(:users) { Array.new(3) { FactoryBot.create(:user) } }
context "users do not own skins" do
it "returns approved skins" do
expect(Skin.approved_or_owned_by_any(users).pluck(:title)).to eq(["Default"])
end
end
context "users own skins" do
before do
create(:work_skin, :private, author: users[1], title: "User 2's First Skin")
create(:work_skin, :private, author: users[1], title: "User 2's Second Skin")
create(:work_skin, :private, author: users[2], title: "User 3's Skin")
create(:work_skin, :private, title: "Unowned Private Skin")
end
it "returns approved and owned skins" do
expect(Skin.approved_or_owned_by_any(users).pluck(:title)).to eq(["Default",
"User 2's First Skin",
"User 2's Second Skin",
"User 3's Skin"])
end
it "does not return unassociated private work skins" do
expect(Skin.approved_or_owned_by_any(users).pluck(:title)).not_to include(["Unowned Private Skin"])
end
end
end
end