# frozen_string_literal: true RSpec.describe RemoteTheme do describe "#import_theme" do def about_json( love_color: "FAFAFA", tertiary_low_color: "FFFFFF", color_scheme_name: "Amazing", about_url: "https://www.site.com/about" ) <<~JSON { "name": "awesome theme", "about_url": "#{about_url}", "license_url": "https://www.site.com/license", "theme_version": "1.0", "minimum_discourse_version": "1.0.0", "assets": { "font": "assets/font.woff2" }, "color_schemes": { "#{color_scheme_name}": { "love": "#{love_color}", "tertiary-low": "#{tertiary_low_color}" } }, "modifiers": { "serialize_topic_excerpts": true, "custom_homepage": { "type": "setting", "value": "boolean_setting" }, "serialize_post_user_badges": { "type": "setting", "value": "list_setting" } }, "screenshots": ["screenshots/1.jpeg", "screenshots/2.jpeg"] } JSON end let :scss_data do "@font-face { font-family: magic; src: url($font)}; body {color: $color; content: $name;}" end let(:migration_js) { <<~JS } export default function migrate(settings) { return settings; } JS let :initial_repo do settings = <<~YAML boolean_setting: true list_setting: type: list default: "" YAML setup_git_repo( "about.json" => about_json, "desktop/desktop.scss" => scss_data, "scss/oldpath.scss" => ".class2{color:blue}", "stylesheets/file.scss" => ".class1{color:red}", "stylesheets/empty.scss" => "", "javascripts/discourse/controllers/test.js.es6" => "console.log('test');", "test/acceptance/theme-test.js" => "assert.ok(true);", "common/header.html" => "I AM HEADER", "common/random.html" => "I AM SILLY", "common/embedded.scss" => "EMBED", "common/color_definitions.scss" => ":root{--color-var: red}", "assets/font.woff2" => "FAKE FONT", "settings.yaml" => settings, "locales/en.yml" => "sometranslations", "migrations/settings/0001-some-migration.js" => migration_js, "screenshots/1.jpeg" => file_from_fixtures("logo.jpg", "images"), "screenshots/2.jpeg" => file_from_fixtures("logo.jpg", "images"), ) end let :initial_repo_url do MockGitImporter.register("https://example.com/initial_repo.git", initial_repo) end after { `rm -fr #{initial_repo}` } around(:each) { |group| MockGitImporter.with_mock { group.run } } it "run pending theme settings migrations" do add_to_git_repo(initial_repo, "migrations/settings/0002-another-migration.js" => <<~JS) export default function migrate(settings) { settings.set("boolean_setting", false); return settings; } JS theme = RemoteTheme.import_theme(initial_repo_url) migrations = theme.theme_settings_migrations.order(:version) expect(migrations.size).to eq(2) first_migration = migrations[0] second_migration = migrations[1] expect(first_migration.version).to eq(1) expect(second_migration.version).to eq(2) expect(first_migration.name).to eq("some-migration") expect(second_migration.name).to eq("another-migration") expect(first_migration.diff).to eq("additions" => [], "deletions" => []) expect(second_migration.diff).to eq( "additions" => [{ "key" => "boolean_setting", "val" => false }], "deletions" => [], ) expect(theme.get_setting(:boolean_setting)).to eq(false) expect(first_migration.theme_field.value).to eq(<<~JS) export default function migrate(settings) { return settings; } JS expect(second_migration.theme_field.value).to eq(<<~JS) export default function migrate(settings) { settings.set("boolean_setting", false); return settings; } JS end it "doesn't create theme if a migration fails" do add_to_git_repo(initial_repo, "migrations/settings/0002-another-migration.js" => <<~JS) export default function migrate(s) { return null; } JS expect do RemoteTheme.import_theme(initial_repo_url) end.to raise_error( Theme::SettingsMigrationError, ).and not_change(Theme, :count).and not_change(RemoteTheme, :count) end it "doesn't partially update the theme when a migration fails" do theme = RemoteTheme.import_theme(initial_repo_url) add_to_git_repo( initial_repo, "about.json" => JSON .parse(about_json(about_url: "https://updated.site.com")) .tap { |h| h[:component] = true } .to_json, "stylesheets/file.scss" => ".class3 { color: green; }", "common/header.html" => "I AM UPDATED HEADER", "migrations/settings/0002-new-failing-migration.js" => <<~JS, export default function migrate(settings) { null.toString(); return settings; } JS ) expect do theme.remote_theme.update_from_remote end.to raise_error( Theme::SettingsMigrationError, ) theme.reload expect(theme.component).to eq(false) expect(theme.remote_theme.about_url).to eq("https://www.site.com/about") expect(theme.theme_fields.find_by(name: "header").value).to eq("I AM HEADER") expect( theme.theme_fields.find_by(type_id: ThemeField.types[:scss], name: "file").value, ).to eq(".class1{color:red}") end it "can correctly import a remote theme" do time = Time.new("2000") freeze_time time theme = RemoteTheme.import_theme(initial_repo_url) remote = theme.remote_theme expect(theme.name).to eq("awesome theme") expect(remote.remote_url).to eq(initial_repo_url) expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) expect(remote.local_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) expect(remote.about_url).to eq("https://www.site.com/about") expect(remote.license_url).to eq("https://www.site.com/license") expect(remote.theme_version).to eq("1.0") expect(remote.minimum_discourse_version).to eq("1.0.0") expect(theme.theme_modifier_set.serialize_topic_excerpts).to eq(true) expect(theme.theme_modifier_set.custom_homepage).to eq(true) expect(theme.theme_fields.length).to eq(12) mapped = Hash[*theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] expect(mapped["0-header"]).to eq("I AM HEADER") expect(mapped["1-scss"]).to eq(scss_data) expect(mapped["0-embedded_scss"]).to eq("EMBED") expect(mapped["0-color_definitions"]).to eq(":root{--color-var: red}") expect(mapped["0-font"]).to eq("") expect(mapped["3-yaml"]).to eq( "boolean_setting: true\nlist_setting:\n type: list\n default: \"\"\n", ) expect(mapped["4-en"]).to eq("sometranslations") expect(mapped["7-acceptance/theme-test.js"]).to eq("assert.ok(true);") expect(mapped["8-0001-some-migration"]).to eq( "export default function migrate(settings) {\n return settings;\n}\n", ) expect(mapped.length).to eq(12) expect(theme.settings.length).to eq(2) expect(theme.settings[:boolean_setting].value).to eq(true) expect(theme.settings[:list_setting].value).to eq("") # lets change the setting to see modifier reflects theme.update_setting(:boolean_setting, false) theme.update_setting(:list_setting, "badge1|badge2") theme.save! theme.reload expect(theme.theme_modifier_set.custom_homepage).to eq(false) expect(theme.theme_modifier_set.serialize_post_user_badges).to eq(%w[badge1 badge2]) expect(remote.remote_updated_at).to eq_time(time) scheme = ColorScheme.find_by(theme_id: theme.id) expect(scheme.name).to eq("Amazing") expect(scheme.colors.find_by(name: "love").hex).to eq("fafafa") expect(scheme.colors.find_by(name: "tertiary-low").hex).to eq("ffffff") expect(theme.color_scheme_id).to eq(scheme.id) theme.update(color_scheme_id: nil) File.write("#{initial_repo}/common/header.html", "I AM UPDATED") File.write( "#{initial_repo}/about.json", about_json(love_color: "EAEAEA", about_url: "https://newsite.com/about"), ) File.write("#{initial_repo}/settings.yml", "integer_setting: 32") `cd #{initial_repo} && git add settings.yml` File.delete("#{initial_repo}/settings.yaml") File.delete("#{initial_repo}/stylesheets/file.scss") `cd #{initial_repo} && git commit -am "update"` time = Time.new("2001") freeze_time time remote.update_remote_version expect(remote.commits_behind).to eq(1) expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) remote.update_from_remote theme.reload scheme = ColorScheme.find_by(theme_id: theme.id) expect(scheme.name).to eq("Amazing") expect(scheme.colors.find_by(name: "love").hex).to eq("eaeaea") expect(theme.color_scheme_id).to eq(nil) # Should only be set on first import mapped = Hash[*theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] # Scss file was deleted expect(mapped["5-file"]).to eq(nil) expect(mapped["0-header"]).to eq("I AM UPDATED") expect(mapped["1-scss"]).to eq(scss_data) expect(theme.settings.length).to eq(1) expect(theme.settings[:integer_setting].value).to eq(32) expect(remote.remote_updated_at).to eq_time(time) expect(remote.about_url).to eq("https://newsite.com/about") # It should be able to remove old colors as well File.write( "#{initial_repo}/about.json", about_json(love_color: "BABABA", tertiary_low_color: "", color_scheme_name: "Amazing 2"), ) `cd #{initial_repo} && git commit -am "update"` remote.update_from_remote theme.reload scheme_count = ColorScheme.where(theme_id: theme.id).count expect(scheme_count).to eq(1) scheme = ColorScheme.find_by(theme_id: theme.id) expect(scheme.colors.find_by(name: "tertiary_low_color")).to eq(nil) end it "can update themes with overwritten history" do theme = RemoteTheme.import_theme(initial_repo_url) remote = theme.remote_theme old_version = `cd #{initial_repo} && git rev-parse HEAD`.strip expect(theme.name).to eq("awesome theme") expect(remote.remote_url).to eq(initial_repo_url) expect(remote.local_version).to eq(old_version) expect(remote.remote_version).to eq(old_version) `cd #{initial_repo} && git commit --amend -m "amended commit"` new_version = `cd #{initial_repo} && git rev-parse HEAD`.strip # make sure that the amended commit does not exist anymore `cd #{initial_repo} && git reflog expire --all --expire=now` `cd #{initial_repo} && git prune` remote.update_remote_version expect(remote.reload.local_version).to eq(old_version) expect(remote.reload.remote_version).to eq(new_version) expect(remote.reload.commits_behind).to eq(-1) end it "runs only new migrations when updating a theme" do add_to_git_repo(initial_repo, "settings.yaml" => <<~YAML) first_integer_setting: 1 second_integer_setting: 2 YAML add_to_git_repo(initial_repo, "migrations/settings/0002-another-migration.js" => <<~JS) export default function migrate(settings) { settings.set("first_integer_setting", 101); return settings; } JS theme = RemoteTheme.import_theme(initial_repo_url) expect(theme.get_setting(:first_integer_setting)).to eq(101) expect(theme.get_setting(:second_integer_setting)).to eq(2) theme.update_setting(:first_integer_setting, 110) add_to_git_repo(initial_repo, "migrations/settings/0003-yet-another-migration.js" => <<~JS) export default function migrate(settings) { settings.set("second_integer_setting", 201); return settings; } JS theme.remote_theme.update_from_remote theme.reload expect(theme.get_setting(:first_integer_setting)).to eq(110) expect(theme.get_setting(:second_integer_setting)).to eq(201) end it "fails if theme has too many files" do stub_const(RemoteTheme, "MAX_THEME_FILE_COUNT", 1) do expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error( RemoteTheme::ImportError, I18n.t("themes.import_error.too_many_files", count: 17, limit: 1), ) end end it "fails if files are too large" do stub_const(RemoteTheme, "MAX_ASSET_FILE_SIZE", 1.byte) do expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error( RemoteTheme::ImportError, I18n.t( "themes.import_error.asset_too_big", filename: "common/color_definitions.scss", limit: ActiveSupport::NumberHelper.number_to_human_size(1), ), ) end end it "fails if theme is too large" do stub_const(RemoteTheme, "MAX_THEME_SIZE", 1.byte) do expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error( RemoteTheme::ImportError, I18n.t( "themes.import_error.theme_too_big", limit: ActiveSupport::NumberHelper.number_to_human_size(1), ), ) end end describe "screenshots" do before { SiteSetting.theme_download_screenshots = true } it "fails if any of the provided screenshots is not an accepted file type" do stub_const(RemoteTheme, "THEME_SCREENSHOT_ALLOWED_FILE_TYPES", [".bmp"]) do expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error( RemoteTheme::ImportError, I18n.t( "themes.import_error.screenshot_invalid_type", file_name: "1.jpeg", accepted_formats: ".bmp", ), ) end end it "fails if any of the provided screenshots is too big" do stub_const(RemoteTheme, "MAX_THEME_SCREENSHOT_FILE_SIZE", 1.byte) do expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error( RemoteTheme::ImportError, I18n.t( "themes.import_error.screenshot_invalid_size", file_name: "1.jpeg", max_size: "1 Bytes", ), ) end end it "fails if any of the provided screenshots has dimensions that are too big" do FastImage .expects(:size) .with { |arg| arg.match(%r{/screenshots/1\.jpeg}) } .returns([512, 512]) stub_const(RemoteTheme, "MAX_THEME_SCREENSHOT_DIMENSIONS", [1, 1]) do expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error( RemoteTheme::ImportError, I18n.t( "themes.import_error.screenshot_invalid_dimensions", file_name: "1.jpeg", width: 512, height: 512, max_width: 1, max_height: 1, ), ) end end it "creates uploads and associated theme fields for all theme screenshots" do FastImage .stubs(:size) .with { |arg| arg.match(%r{/screenshots/1\.jpeg}) } .returns([800, 600]) FastImage .stubs(:size) .with { |arg| arg.match(%r{/screenshots/2\.jpeg}) } .returns([1024, 768]) theme = RemoteTheme.import_theme(initial_repo_url) screenshot_1 = theme.theme_fields.find_by(name: "screenshot_1") screenshot_2 = theme.theme_fields.find_by(name: "screenshot_2") expect(screenshot_1).to be_present expect(screenshot_1.type_id).to eq(ThemeField.types[:theme_screenshot_upload_var]) expect(screenshot_2).to be_present expect(screenshot_2.type_id).to eq(ThemeField.types[:theme_screenshot_upload_var]) expect(screenshot_1.upload).to be_present expect(screenshot_2.upload).to be_present expect(UploadReference.exists?(target: screenshot_1)).to eq(true) expect(UploadReference.exists?(target: screenshot_2)).to eq(true) expect(screenshot_1.upload.original_filename).to eq("1.jpeg") expect(screenshot_2.upload.original_filename).to eq("2.jpeg") end end end let(:github_repo) do RemoteTheme.create!( remote_url: "https://github.com/org/testtheme.git", local_version: "a2ec030e551fc8d8579790e1954876fe769fe40a", remote_version: "21122230dbfed804067849393c3332083ddd0c07", commits_behind: 2, ) end let(:gitlab_repo) do RemoteTheme.create!( remote_url: "https://gitlab.com/org/repo.git", local_version: "a2ec030e551fc8d8579790e1954876fe769fe40a", remote_version: "21122230dbfed804067849393c3332083ddd0c07", commits_behind: 5, ) end describe "#github_diff_link" do it "is blank for non-github repos" do expect(gitlab_repo.github_diff_link).to be_blank end it "returns URL for comparing between local_version and remote_version" do expect(github_repo.github_diff_link).to eq( "https://github.com/org/testtheme/compare/#{github_repo.local_version}...#{github_repo.remote_version}", ) end it "is blank when theme is up-to-date" do github_repo.update!(local_version: github_repo.remote_version, commits_behind: 0) expect(github_repo.reload.github_diff_link).to be_blank end end describe ".extract_theme_info" do let(:importer) { mock } let(:theme_info) do { "name" => "My Theme", "about_url" => "https://example.com/about", "license_url" => "https://example.com/license", } end it "raises an error if about.json is too big" do importer.stubs(:file_size).with("about.json").returns(100_000_000) expect { RemoteTheme.extract_theme_info(importer) }.to raise_error( RemoteTheme::ImportError, I18n.t( "themes.import_error.about_json_too_big", limit: ActiveSupport::NumberHelper.number_to_human_size((RemoteTheme::MAX_METADATA_FILE_SIZE)), ), ) end it "raises an error if about.json is invalid" do importer.stubs(:file_size).with("about.json").returns(123) importer.stubs(:[]).with("about.json").returns("{") expect { RemoteTheme.extract_theme_info(importer) }.to raise_error( RemoteTheme::ImportError, I18n.t("themes.import_error.about_json"), ) end it "returns extracted theme info" do importer.stubs(:file_size).with("about.json").returns(123) importer.stubs(:[]).with("about.json").returns(theme_info.to_json) expect(RemoteTheme.extract_theme_info(importer)).to eq(theme_info) end end describe ".joined_remotes" do it "finds records that are associated with themes" do github_repo gitlab_repo expect(RemoteTheme.joined_remotes).to eq([]) Fabricate(:theme, remote_theme: github_repo) expect(RemoteTheme.joined_remotes).to eq([github_repo]) Fabricate(:theme, remote_theme: gitlab_repo) expect(RemoteTheme.joined_remotes).to contain_exactly(github_repo, gitlab_repo) end end describe ".out_of_date_themes" do let(:remote) { RemoteTheme.create!(remote_url: "https://github.com/org/testtheme") } let!(:theme) { Fabricate(:theme, remote_theme: remote) } it "finds out of date themes" do remote.update!(local_version: "old version", remote_version: "new version", commits_behind: 2) expect(described_class.out_of_date_themes).to eq([[theme.name, theme.id]]) remote.update!(local_version: "new version", commits_behind: 0) expect(described_class.out_of_date_themes).to eq([]) end it "ignores disabled out of date themes" do remote.update!(local_version: "old version", remote_version: "new version", commits_behind: 2) theme.update!(enabled: false) expect(described_class.out_of_date_themes).to eq([]) end end describe ".unreachable_themes" do let(:remote) do RemoteTheme.create!( remote_url: "https://github.com/org/testtheme", last_error_text: "can't contact this repo :(", ) end let!(:theme) { Fabricate(:theme, remote_theme: remote) } it "finds out of date themes" do expect(described_class.unreachable_themes).to eq([[theme.name, theme.id]]) remote.update!(last_error_text: nil) expect(described_class.unreachable_themes).to eq([]) end end describe ".import_theme_from_directory" do let(:theme_dir) { "#{Rails.root}/spec/fixtures/themes/discourse-test-theme" } it "imports a theme from a directory" do theme = RemoteTheme.import_theme_from_directory(theme_dir) expect(theme.name).to eq("Header Icons") expect(theme.theme_fields.count).to eq(6) end end end